nanobot: 2026-09-10 12:33:37
This commit is contained in:
141
skills/wiki/tests/conftest.py
Normal file
141
skills/wiki/tests/conftest.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import hashlib
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# The modules under test live in the sibling scripts/ directory.
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
import wiki_config
|
||||
import wiki_search
|
||||
import wiki_sync
|
||||
from wiki_db import EMBEDDING_DIMS
|
||||
|
||||
CONFIG_TEMPLATE = """
|
||||
embedding:
|
||||
endpoint: http://embed.invalid:11434
|
||||
model: qwen3-embedding:0.6b
|
||||
dims: 1024
|
||||
batch: 4
|
||||
keep_alive: -1
|
||||
query_prefix: "Instruct: task\\nQuery: "
|
||||
|
||||
sources:
|
||||
{sources}
|
||||
"""
|
||||
|
||||
WORKSPACE_SOURCE = """ workspace:
|
||||
kind: workspace
|
||||
paths:
|
||||
- "notes/**"
|
||||
- "develop/**"
|
||||
include: ["*.md"]
|
||||
exclude:
|
||||
- "**/inbox/**"
|
||||
- "develop/history.md"
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WikiEnv:
|
||||
workspace: Path
|
||||
wiki_dir: Path
|
||||
config_path: Path
|
||||
db_path: Path
|
||||
log_path: Path
|
||||
|
||||
def write_config(self, sources: str = WORKSPACE_SOURCE) -> None:
|
||||
self.config_path.write_text(CONFIG_TEMPLATE.format(sources=sources), encoding="utf-8")
|
||||
|
||||
def write_file(self, rel_path: str, text: str) -> Path:
|
||||
path = self.workspace / rel_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def log_text(self) -> str:
|
||||
return self.log_path.read_text(encoding="utf-8") if self.log_path.exists() else ""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wiki_env(tmp_path, monkeypatch) -> WikiEnv:
|
||||
"""Redirect every wiki path at a throwaway workspace."""
|
||||
workspace = tmp_path / "workspace"
|
||||
wiki_dir = workspace / "wiki"
|
||||
wiki_dir.mkdir(parents=True)
|
||||
|
||||
env = WikiEnv(
|
||||
workspace=workspace,
|
||||
wiki_dir=wiki_dir,
|
||||
config_path=wiki_dir / "config.yaml",
|
||||
db_path=wiki_dir / "index.sqlite",
|
||||
log_path=workspace / "log" / "wiki_sync.log",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("WIKI_DB", str(env.db_path))
|
||||
monkeypatch.setenv("WIKI_CONFIG", str(env.config_path))
|
||||
monkeypatch.setattr(wiki_config, "WORKSPACE", workspace)
|
||||
monkeypatch.setattr(wiki_config, "WIKI_DIR", wiki_dir)
|
||||
monkeypatch.setattr(wiki_config, "REMOTE_DIR", wiki_dir / "remote")
|
||||
monkeypatch.setattr(wiki_config, "LOCK_PATH", wiki_dir / ".sync.lock")
|
||||
monkeypatch.setattr(wiki_sync, "WIKI_DIR", wiki_dir)
|
||||
monkeypatch.setattr(wiki_sync, "LOCK_PATH", wiki_dir / ".sync.lock")
|
||||
monkeypatch.setattr(wiki_sync, "SYNC_LOG_PATH", env.log_path)
|
||||
|
||||
env.write_config()
|
||||
return env
|
||||
|
||||
|
||||
class FakeEmbedder:
|
||||
"""Bag-of-words vectors: cosine tracks lexical overlap, so ranks are predictable.
|
||||
|
||||
Enough to exercise the KNN and RRF plumbing without a live model. Semantic quality
|
||||
is measured against the real endpoint, not here.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.calls: list[list[str]] = []
|
||||
|
||||
def embed_documents(self, texts):
|
||||
self.calls.append(list(texts))
|
||||
return [self._vector(text) for text in texts]
|
||||
|
||||
def embed_query(self, text):
|
||||
return self._vector(self.config.query_prefix + text)
|
||||
|
||||
def probe(self):
|
||||
return None
|
||||
|
||||
def _vector(self, text: str) -> list[float]:
|
||||
vector = [0.0] * EMBEDDING_DIMS
|
||||
for word in _words(text):
|
||||
digest = hashlib.sha256(word.encode("utf-8")).digest()
|
||||
vector[int.from_bytes(digest[:4], "big") % EMBEDDING_DIMS] += 1.0
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
if norm == 0.0:
|
||||
vector[0] = 1.0
|
||||
return vector
|
||||
return [value / norm for value in vector]
|
||||
|
||||
|
||||
def _words(text: str) -> list[str]:
|
||||
return [word for word in "".join(c.lower() if c.isalnum() else " " for c in text).split() if len(word) > 2]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embedder(monkeypatch):
|
||||
"""Swap the Ollama client for the deterministic fake in both entry points."""
|
||||
created: list[FakeEmbedder] = []
|
||||
|
||||
def factory(config):
|
||||
embedder = FakeEmbedder(config)
|
||||
created.append(embedder)
|
||||
return embedder
|
||||
|
||||
monkeypatch.setattr(wiki_sync, "OllamaEmbedder", factory)
|
||||
monkeypatch.setattr(wiki_search, "OllamaEmbedder", factory)
|
||||
return created
|
||||
209
skills/wiki/tests/test_wiki_chunker.py
Normal file
209
skills/wiki/tests/test_wiki_chunker.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Chunker policy: heading sections, breadcrumbs, sibling merge, block-aligned split."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from wiki_chunker import MERGE_BELOW, OVERLAP, SPLIT_ABOVE, parse_markdown, token_estimate
|
||||
|
||||
FILLER = "Tohle je odstavec s dostatkem textu na to, aby se do velikosti chunku počítal. "
|
||||
|
||||
|
||||
def _paragraph(tokens: int) -> str:
|
||||
return (FILLER * (1 + tokens * 4 // len(FILLER)))[: tokens * 4]
|
||||
|
||||
|
||||
def _crumbs(parsed):
|
||||
return [chunk.breadcrumb for chunk in parsed.chunks]
|
||||
|
||||
|
||||
def test_heading_hierarchy_builds_breadcrumbs():
|
||||
doc = "\n".join(
|
||||
[
|
||||
"# Kořen",
|
||||
"",
|
||||
_paragraph(MERGE_BELOW),
|
||||
"",
|
||||
"## Sekce A",
|
||||
"",
|
||||
_paragraph(MERGE_BELOW),
|
||||
"",
|
||||
"### Podsekce",
|
||||
"",
|
||||
_paragraph(MERGE_BELOW),
|
||||
"",
|
||||
"## Sekce B",
|
||||
"",
|
||||
_paragraph(MERGE_BELOW),
|
||||
]
|
||||
)
|
||||
parsed = parse_markdown(doc, "notes/doc.md")
|
||||
assert _crumbs(parsed) == [
|
||||
"notes/doc.md > Kořen",
|
||||
"notes/doc.md > Kořen > Sekce A",
|
||||
"notes/doc.md > Kořen > Sekce A > Podsekce",
|
||||
"notes/doc.md > Kořen > Sekce B",
|
||||
]
|
||||
assert parsed.headings == ["Kořen", "Sekce A", "Podsekce", "Sekce B"]
|
||||
|
||||
|
||||
def test_frontmatter_title_and_tags():
|
||||
doc = "---\ntitle: Tokyo metro tips\ntags: [transit, jr-pass]\n---\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "japan/metro.md")
|
||||
assert parsed.title == "Tokyo metro tips"
|
||||
assert parsed.tags == ["transit", "jr-pass"]
|
||||
# Only the title reaches the embedded text; tags stay metadata for filtering.
|
||||
assert _crumbs(parsed) == ["japan/metro.md > Tokyo metro tips"]
|
||||
assert "transit" not in parsed.chunks[0].text
|
||||
|
||||
|
||||
def test_frontmatter_title_matching_h1_is_not_duplicated():
|
||||
doc = "---\ntitle: Zálohy\n---\n\n# Zálohy\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert _crumbs(parsed) == ["a.md > Zálohy"]
|
||||
|
||||
|
||||
def test_comma_separated_tags():
|
||||
doc = "---\ntags: gear, safety\n---\n\ntext"
|
||||
assert parse_markdown(doc, "a.md").tags == ["gear", "safety"]
|
||||
|
||||
|
||||
def test_malformed_frontmatter_stays_body():
|
||||
"""Unparseable frontmatter is not consumed — it stays body text, tags included."""
|
||||
doc = "---\ntitle: [unclosed\ntags: [a\n---\n\ntext"
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert parsed.tags == []
|
||||
assert "title: [unclosed" in parsed.chunks[-1].text # the raw block survived as prose
|
||||
|
||||
|
||||
def test_small_adjacent_siblings_merge():
|
||||
doc = "\n".join(["# Root", "", "## A", "", "krátké A", "", "## B", "", "krátké B"])
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
# A and B share the parent `a.md > Root` and are both under MERGE_BELOW.
|
||||
# `# Root` carries no prose of its own, so it contributes no chunk.
|
||||
assert _crumbs(parsed) == ["a.md > Root > A"]
|
||||
assert "krátké A" in parsed.chunks[0].text
|
||||
assert "krátké B" in parsed.chunks[0].text
|
||||
|
||||
|
||||
def test_heading_without_own_prose_yields_no_chunk():
|
||||
"""`# Titul` immediately followed by `## Sekce` must not embed a bare title."""
|
||||
doc = "\n".join(["# Titul", "", "## Sekce", "", _paragraph(MERGE_BELOW)])
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert _crumbs(parsed) == ["a.md > Titul > Sekce"]
|
||||
# The dropped heading still reaches the index through the breadcrumb and headings list.
|
||||
assert parsed.headings == ["Titul", "Sekce"]
|
||||
|
||||
|
||||
def test_merge_does_not_cross_parents():
|
||||
"""A small H3 must not be glued onto the next H2 — different parents."""
|
||||
doc = "\n".join(["# Root", "", "## A", "", "### A1", "", "malé", "", "## B", "", "malé B"])
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert "a.md > Root > A > A1" in _crumbs(parsed)
|
||||
a1 = next(c for c in parsed.chunks if c.breadcrumb.endswith("A1"))
|
||||
assert "malé B" not in a1.text
|
||||
|
||||
|
||||
def test_merge_never_exceeds_split_threshold():
|
||||
big = _paragraph(SPLIT_ABOVE - 100)
|
||||
doc = "\n".join(["# Root", "", "## A", "", "malé", "", "## B", "", big])
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
for chunk in parsed.chunks:
|
||||
assert token_estimate(chunk.text) <= SPLIT_ABOVE + token_estimate(chunk.breadcrumb) + 1
|
||||
|
||||
|
||||
def test_h4_stays_inside_its_h3_section():
|
||||
doc = "\n".join(["# Root", "", "### Trojka", "", "text", "", "#### Čtyřka", "", "hluboký text"])
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert "a.md > Root > Trojka" in _crumbs(parsed)
|
||||
assert not any("Čtyřka" in crumb for crumb in _crumbs(parsed))
|
||||
section = next(c for c in parsed.chunks if c.breadcrumb.endswith("Trojka"))
|
||||
assert "hluboký text" in section.text
|
||||
|
||||
|
||||
def test_preamble_before_first_heading_is_kept():
|
||||
doc = "úvodní odstavec bez nadpisu\n\n" + "# Root\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert "úvodní odstavec bez nadpisu" in parsed.chunks[0].text
|
||||
|
||||
|
||||
def test_document_without_headings_is_one_chunk():
|
||||
parsed = parse_markdown(_paragraph(MERGE_BELOW), "a.md")
|
||||
assert _crumbs(parsed) == ["a.md"]
|
||||
|
||||
|
||||
def test_empty_document_yields_no_chunks():
|
||||
assert parse_markdown("", "a.md").chunks == []
|
||||
assert parse_markdown(" \n\n \n", "a.md").chunks == []
|
||||
|
||||
|
||||
def test_setext_heading_is_a_section():
|
||||
doc = "Nadpis setext\n=============\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert _crumbs(parsed) == ["a.md > Nadpis setext"]
|
||||
|
||||
|
||||
def test_oversized_section_splits_and_keeps_code_fence_whole():
|
||||
fence = "```python\n" + "\n".join(f"value_{i} = {i} # a comment long enough to count" for i in range(60)) + "\n```"
|
||||
doc = "# Velká sekce\n\n" + "\n\n".join([_paragraph(120)] * 6) + "\n\n" + fence + "\n\n" + _paragraph(120)
|
||||
parsed = parse_markdown(doc, "big.md")
|
||||
|
||||
assert len(parsed.chunks) > 1
|
||||
assert {c.breadcrumb for c in parsed.chunks} == {"big.md > Velká sekce"}
|
||||
# The fence is larger than SPLIT_ABOVE on its own, so it must sit alone and unbroken.
|
||||
assert sum(fence in chunk.text for chunk in parsed.chunks) == 1
|
||||
|
||||
|
||||
def test_split_carries_block_aligned_overlap():
|
||||
blocks = [_paragraph(40) + f" marker{i:03d}" for i in range(40)]
|
||||
doc = "# Root\n\n" + "\n\n".join(blocks)
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
|
||||
assert len(parsed.chunks) > 1
|
||||
# Each boundary repeats at least one whole block, capped at OVERLAP tokens.
|
||||
for earlier, later in zip(parsed.chunks, parsed.chunks[1:], strict=False):
|
||||
shared = [b for b in blocks if b in earlier.text and b in later.text]
|
||||
assert shared, "expected overlap blocks between consecutive chunks"
|
||||
assert token_estimate("".join(shared)) <= OVERLAP
|
||||
|
||||
|
||||
def test_table_is_not_broken():
|
||||
table = "\n".join(["| a | b |", "|---|---|"] + [f"| {i} | {i * 2} |" for i in range(80)])
|
||||
doc = "# Root\n\n" + "\n\n".join([_paragraph(150)] * 5) + "\n\n" + table
|
||||
parsed = parse_markdown(doc, "a.md")
|
||||
assert sum(table in chunk.text for chunk in parsed.chunks) == 1
|
||||
|
||||
|
||||
def test_breadcrumb_is_prefixed_to_chunk_text():
|
||||
parsed = parse_markdown("# Root\n\n" + _paragraph(MERGE_BELOW), "notes/a.md")
|
||||
chunk = parsed.chunks[0]
|
||||
assert chunk.text.startswith(chunk.breadcrumb + "\n\n")
|
||||
|
||||
|
||||
def test_catalog_title_falls_back_to_first_heading():
|
||||
"""Notes carry their title as `# H1` far more often than as frontmatter."""
|
||||
doc = "# Zálohování dat\n\n" + _paragraph(MERGE_BELOW) + "\n\n## Retence\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "notes/zalohy.md")
|
||||
assert parsed.title == "Zálohování dat"
|
||||
|
||||
|
||||
def test_frontmatter_title_wins_over_first_heading():
|
||||
doc = "---\ntitle: Z frontmatteru\n---\n\n# Z nadpisu\n\n" + _paragraph(MERGE_BELOW)
|
||||
assert parse_markdown(doc, "a.md").title == "Z frontmatteru"
|
||||
|
||||
|
||||
def test_document_without_headings_has_no_title():
|
||||
assert parse_markdown(_paragraph(MERGE_BELOW), "a.md").title is None
|
||||
assert parse_markdown("", "a.md").title is None
|
||||
|
||||
|
||||
def test_heading_title_fallback_does_not_change_any_chunk():
|
||||
"""The fallback feeds only the catalog — touching the breadcrumb root would force --full."""
|
||||
doc = "úvod bez nadpisu\n\n# Root\n\n" + _paragraph(MERGE_BELOW) + "\n\n## Sekce\n\n" + _paragraph(MERGE_BELOW)
|
||||
parsed = parse_markdown(doc, "notes/a.md")
|
||||
|
||||
assert parsed.title == "Root"
|
||||
# The root stays the bare path, so no chunk text is rewritten by the fallback.
|
||||
assert _crumbs(parsed) == ["notes/a.md", "notes/a.md > Root", "notes/a.md > Root > Sekce"]
|
||||
165
skills/wiki/tests/test_wiki_config.py
Normal file
165
skills/wiki/tests/test_wiki_config.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Scope precedence (paths -> include -> exclude) and config validation."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import wiki_config
|
||||
from wiki_config import ConfigError, SourceConfig, load_config, source_root
|
||||
|
||||
WORKSPACE_YAML = """
|
||||
embedding:
|
||||
endpoint: http://nvidia.hell:11434/
|
||||
model: qwen3-embedding:0.6b
|
||||
dims: 1024
|
||||
batch: 32
|
||||
keep_alive: -1
|
||||
query_prefix: "Instruct: task\\nQuery: "
|
||||
|
||||
sources:
|
||||
index:
|
||||
kind: git
|
||||
url: git@git.fnet.cz:lachtan/index.git
|
||||
paths: ["**"]
|
||||
include: ["*.md"]
|
||||
exclude:
|
||||
- "**/node_modules/**"
|
||||
- "**/vendor/**"
|
||||
|
||||
workspace:
|
||||
kind: workspace
|
||||
paths:
|
||||
- "notes/**"
|
||||
- "develop/**"
|
||||
include: ["*.md"]
|
||||
exclude:
|
||||
- "**/inbox/**"
|
||||
- "develop/history.md"
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path, text):
|
||||
path = tmp_path / "config.yaml"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _source(
|
||||
source_id: str = "ws",
|
||||
kind: str = "workspace",
|
||||
url: str | None = None,
|
||||
paths: tuple[str, ...] = ("**",),
|
||||
include: tuple[str, ...] = ("*.md",),
|
||||
exclude: tuple[str, ...] = (),
|
||||
) -> SourceConfig:
|
||||
return SourceConfig(source_id=source_id, kind=kind, url=url, paths=paths, include=include, exclude=exclude)
|
||||
|
||||
|
||||
def test_loads_embedding_and_sources(tmp_path):
|
||||
config = load_config(_write(tmp_path, WORKSPACE_YAML))
|
||||
assert config.embedding.endpoint == "http://nvidia.hell:11434" # trailing slash trimmed
|
||||
assert config.embedding.model == "qwen3-embedding:0.6b"
|
||||
assert config.embedding.dims == 1024
|
||||
assert config.embedding.keep_alive == -1
|
||||
assert config.embedding.query_prefix.endswith("Query: ")
|
||||
assert [s.source_id for s in config.sources] == ["index", "workspace"]
|
||||
index = config.source("index")
|
||||
assert index is not None and index.kind == "git"
|
||||
assert config.source("nope") is None
|
||||
|
||||
|
||||
def test_string_keep_alive_is_rejected():
|
||||
"""Ollama answers HTTP 400 to a string "-1" — catch it in config, not at runtime."""
|
||||
with pytest.raises(ConfigError, match="keep_alive"):
|
||||
wiki_config._parse_embedding({"endpoint": "http://x", "model": "m", "dims": 1024, "keep_alive": "-1"})
|
||||
|
||||
|
||||
def test_missing_config_file(tmp_path):
|
||||
with pytest.raises(ConfigError, match="missing config"):
|
||||
load_config(tmp_path / "nope.yaml")
|
||||
|
||||
|
||||
def test_git_source_needs_url(tmp_path):
|
||||
yaml_text = WORKSPACE_YAML.replace(" url: git@git.fnet.cz:lachtan/index.git\n", "")
|
||||
with pytest.raises(ConfigError, match="need a url"):
|
||||
load_config(_write(tmp_path, yaml_text))
|
||||
|
||||
|
||||
def test_unknown_kind_is_rejected(tmp_path):
|
||||
yaml_text = WORKSPACE_YAML.replace(" kind: workspace", " kind: mirror")
|
||||
with pytest.raises(ConfigError, match="kind must be one of"):
|
||||
load_config(_write(tmp_path, yaml_text))
|
||||
|
||||
|
||||
def test_paths_is_required(tmp_path):
|
||||
yaml_text = WORKSPACE_YAML.replace(' paths: ["**"]\n', "")
|
||||
with pytest.raises(ConfigError, match="`paths` is required"):
|
||||
load_config(_write(tmp_path, yaml_text))
|
||||
|
||||
|
||||
def test_paths_whitelist_gates_everything():
|
||||
source = _source(paths=("notes/**", "develop/**"))
|
||||
assert source.covers("notes/a.md")
|
||||
assert source.covers("notes/deep/nested/a.md")
|
||||
assert source.covers("develop/knowledge.md")
|
||||
# Not on the whitelist -> does not exist for the index.
|
||||
assert not source.covers("tmp/a.md")
|
||||
assert not source.covers("skills/wiki/SKILL.md")
|
||||
assert not source.covers("AGENTS.md")
|
||||
|
||||
|
||||
def test_include_filters_extensions():
|
||||
source = _source(paths=("**",))
|
||||
assert source.covers("notes/a.md")
|
||||
assert not source.covers("notes/main.py")
|
||||
assert not source.covers("memory/history.jsonl")
|
||||
assert not source.covers("assets/photo.png")
|
||||
|
||||
|
||||
def test_exclude_wins_over_paths_and_include():
|
||||
source = _source(paths=("notes/**", "develop/**"), exclude=("**/inbox/**", "develop/history.md"))
|
||||
assert not source.covers("notes/inbox/raw.md")
|
||||
assert not source.covers("develop/history.md")
|
||||
assert source.covers("develop/knowledge.md")
|
||||
assert source.covers("notes/notes.md")
|
||||
|
||||
|
||||
def test_double_star_matches_whole_repo():
|
||||
source = _source(paths=("**",), exclude=("**/node_modules/**", "**/vendor/**"))
|
||||
assert source.covers("README.md")
|
||||
assert source.covers("japan/tokyo/metro.md")
|
||||
assert not source.covers("node_modules/pkg/README.md")
|
||||
assert not source.covers("web/vendor/lib/CHANGELOG.md")
|
||||
|
||||
|
||||
def test_glob_star_does_not_cross_a_slash():
|
||||
source = _source(paths=("notes/*",))
|
||||
assert source.covers("notes/a.md")
|
||||
assert not source.covers("notes/deep/a.md")
|
||||
|
||||
|
||||
def test_covers_dir_prunes_the_walk():
|
||||
source = _source(paths=("notes/**", "develop/**"), exclude=("**/inbox/**",))
|
||||
assert source.covers_dir("")
|
||||
assert source.covers_dir("notes")
|
||||
assert source.covers_dir("notes/deep")
|
||||
assert source.covers_dir("develop")
|
||||
assert not source.covers_dir("tmp")
|
||||
assert not source.covers_dir("notes/inbox")
|
||||
|
||||
|
||||
def test_covers_dir_never_prunes_a_double_star_source():
|
||||
source = _source(paths=("**",), exclude=("**/node_modules/**",))
|
||||
assert source.covers_dir("anything/deep")
|
||||
assert not source.covers_dir("app/node_modules")
|
||||
|
||||
|
||||
def test_source_root_derives_clone_path_from_id():
|
||||
assert source_root(_source(source_id="travel", kind="git", url="git@x:y.git")) == (
|
||||
wiki_config.REMOTE_DIR / "travel"
|
||||
)
|
||||
assert source_root(_source(kind="workspace")) == wiki_config.WORKSPACE
|
||||
136
skills/wiki/tests/test_wiki_db.py
Normal file
136
skills/wiki/tests/test_wiki_db.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Schema-level guarantees: the cascade, and the one hole the cascade leaves."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import wiki_store as store
|
||||
from wiki_db import EMBEDDING_DIMS
|
||||
|
||||
NOW = "2026-09-09T12:00:00+00:00"
|
||||
|
||||
|
||||
def _seed_file(conn, source_id, path, chunk_texts):
|
||||
store.upsert_source(conn, source_id, "workspace")
|
||||
store.upsert_file(conn, source_id, path, "T", ["t"], ["H"], "sha", 10, 1.0, NOW)
|
||||
store.replace_chunks(conn, source_id, path, [(f"{path} > s", t) for t in chunk_texts])
|
||||
for row in store.pending_chunks(conn, 100):
|
||||
store.store_embedding(conn, row["id"], [0.1] * EMBEDDING_DIMS, NOW)
|
||||
|
||||
|
||||
def test_delete_file_leaves_no_orphan_vectors(tmp_path):
|
||||
"""The FK cascade reaches chunks and chunks_fts but never vec0 — deletes must be explicit."""
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
_seed_file(conn, "ws", "a.md", ["alpha text", "beta text"])
|
||||
_seed_file(conn, "ws", "b.md", ["gamma text"])
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.index_stats(conn) == {"files": 2, "chunks": 3, "vectors": 3, "pending": 0}
|
||||
|
||||
with store.transaction(db_path) as conn:
|
||||
store.delete_file(conn, "ws", "a.md")
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.index_stats(conn) == {"files": 1, "chunks": 1, "vectors": 1, "pending": 0}
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
assert store.bm25_ranked_ids(conn, "alpha", 10) == []
|
||||
assert len(store.bm25_ranked_ids(conn, "gamma", 10)) == 1
|
||||
|
||||
|
||||
def test_replace_chunks_drops_old_vectors(tmp_path):
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
_seed_file(conn, "ws", "a.md", ["alpha text", "beta text", "delta text"])
|
||||
|
||||
with store.transaction(db_path) as conn:
|
||||
store.replace_chunks(conn, "ws", "a.md", [("a.md > s", "only one now")])
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.index_stats(conn) == {"files": 1, "chunks": 1, "vectors": 0, "pending": 1}
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
assert store.bm25_ranked_ids(conn, "alpha", 10) == []
|
||||
|
||||
|
||||
def test_delete_by_path_does_not_touch_other_source(tmp_path):
|
||||
"""The key is (source_id, path); a path-only delete would eat a foreign source's chunks."""
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
_seed_file(conn, "ws", "notes.md", ["shared name one"])
|
||||
_seed_file(conn, "git", "notes.md", ["shared name two"])
|
||||
|
||||
with store.transaction(db_path) as conn:
|
||||
store.delete_file(conn, "ws", "notes.md")
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.index_stats(conn)["chunks"] == 1
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
remaining = list(conn.execute("SELECT source_id FROM chunks"))
|
||||
assert remaining[0]["source_id"] == "git"
|
||||
|
||||
|
||||
def test_embedded_at_update_keeps_fts_row(tmp_path):
|
||||
"""Step 4b flips embedded_at on unchanged text; the WHEN guard must keep FTS intact."""
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
store.upsert_source(conn, "ws", "workspace")
|
||||
store.upsert_file(conn, "ws", "a.md", "T", [], [], "sha", 10, 1.0, NOW)
|
||||
store.replace_chunks(conn, "ws", "a.md", [("a.md", "zaloha dat na disk")])
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert len(store.bm25_ranked_ids(conn, "zaloha", 10)) == 1
|
||||
|
||||
with store.transaction(db_path) as conn:
|
||||
pending = store.pending_chunks(conn, 10)
|
||||
store.store_embedding(conn, pending[0]["id"], [0.2] * EMBEDDING_DIMS, NOW)
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert len(store.bm25_ranked_ids(conn, "zaloha", 10)) == 1
|
||||
assert store.index_stats(conn) == {"files": 1, "chunks": 1, "vectors": 1, "pending": 0}
|
||||
|
||||
|
||||
def test_fts_folds_czech_diacritics(tmp_path):
|
||||
"""remove_diacritics 2 is what makes `zaloha` find `záloha`."""
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
store.upsert_source(conn, "ws", "workspace")
|
||||
store.upsert_file(conn, "ws", "a.md", "T", [], [], "sha", 10, 1.0, NOW)
|
||||
store.replace_chunks(conn, "ws", "a.md", [("a.md", "zálohování dat probíhá denně")])
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert len(store.bm25_ranked_ids(conn, "zaloh*", 10)) == 1
|
||||
assert len(store.bm25_ranked_ids(conn, "záloh*", 10)) == 1
|
||||
|
||||
|
||||
def test_meta_roundtrip_and_rollback(tmp_path):
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
store.write_meta(conn, {"embedding_model": "qwen3-embedding:0.6b", "embedding_dims": "1024"})
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.read_meta(conn)["embedding_dims"] == "1024"
|
||||
|
||||
try:
|
||||
with store.transaction(db_path) as conn:
|
||||
store.write_meta(conn, {"embedding_dims": "768"})
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert store.read_meta(conn)["embedding_dims"] == "1024"
|
||||
|
||||
|
||||
def test_toc_filters_by_tag(tmp_path):
|
||||
db_path = tmp_path / "index.sqlite"
|
||||
with store.transaction(db_path) as conn:
|
||||
store.upsert_source(conn, "travel", "git")
|
||||
store.upsert_file(conn, "travel", "japan/metro.md", "Metro", ["transit"], [], "s", 1, 1.0, NOW)
|
||||
store.upsert_file(conn, "travel", "alps/gear.md", "Gear", ["gear", "safety"], [], "s", 1, 1.0, NOW)
|
||||
|
||||
with store.connection(db_path) as conn:
|
||||
assert [r["path"] for r in store.list_toc_files(conn, tag="gear")] == ["alps/gear.md"]
|
||||
assert len(store.list_toc_files(conn, source_id="travel")) == 2
|
||||
assert store.list_toc_files(conn, source_id="nope") == []
|
||||
266
skills/wiki/tests/test_wiki_git.py
Normal file
266
skills/wiki/tests/test_wiki_git.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Git driver: clone, ls-remote change detection, diff, deletions, per-source failure."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import wiki_store as store
|
||||
import wiki_sync
|
||||
from conftest import WORKSPACE_SOURCE
|
||||
|
||||
DOC = """# Zálohování
|
||||
|
||||
Záloha běží každou noc přes rsync na druhý disk.
|
||||
"""
|
||||
|
||||
TRAVEL_DOC = """# Tokijské metro
|
||||
|
||||
Z Narity do centra jede Skyliner za osmatřicet minut.
|
||||
"""
|
||||
|
||||
GIT_SOURCE = """ notes:
|
||||
kind: git
|
||||
url: {url}
|
||||
paths: ["**"]
|
||||
include: ["*.md"]
|
||||
exclude:
|
||||
- "**/node_modules/**"
|
||||
- "**/vendor/**"
|
||||
"""
|
||||
|
||||
|
||||
def _git(args, cwd=None):
|
||||
result = subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
class Remote:
|
||||
"""A bare repo plus a working clone to push commits from."""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.bare = root / "origin.git"
|
||||
self.work = root / "origin-work"
|
||||
_git(["init", "--bare", "--initial-branch=master", str(self.bare)])
|
||||
_git(["clone", str(self.bare), str(self.work)])
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return str(self.bare)
|
||||
|
||||
def commit(self, files: dict[str, str | None], message: str = "change") -> str:
|
||||
for rel_path, text in files.items():
|
||||
path = self.work / rel_path
|
||||
if text is None:
|
||||
_git(["rm", "-q", rel_path], cwd=self.work)
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
_git(["add", rel_path], cwd=self.work)
|
||||
_git(["commit", "-m", message], cwd=self.work)
|
||||
_git(["push", "-q", "origin", "master"], cwd=self.work)
|
||||
return _git(["rev-parse", "HEAD"], cwd=self.work).strip()
|
||||
|
||||
def move(self, old: str, new: str) -> str:
|
||||
(self.work / new).parent.mkdir(parents=True, exist_ok=True)
|
||||
_git(["mv", old, new], cwd=self.work)
|
||||
_git(["commit", "-m", "rename"], cwd=self.work)
|
||||
_git(["push", "-q", "origin", "master"], cwd=self.work)
|
||||
return _git(["rev-parse", "HEAD"], cwd=self.work).strip()
|
||||
|
||||
|
||||
def _use_git_source(env, url, with_workspace=False):
|
||||
sources = GIT_SOURCE.format(url=url)
|
||||
if with_workspace:
|
||||
sources += WORKSPACE_SOURCE
|
||||
env.write_config(sources)
|
||||
|
||||
|
||||
def _stats(env):
|
||||
with store.connection(env.db_path) as conn:
|
||||
return store.index_stats(conn)
|
||||
|
||||
|
||||
def test_first_run_clones_and_indexes(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
head = remote.commit({"zalohy.md": DOC, "japan/metro.md": TRAVEL_DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
clone = wiki_env.wiki_dir / "remote" / "notes"
|
||||
assert (clone / "zalohy.md").is_file() # non-bare: a working tree grep can read
|
||||
assert (clone / ".git").is_dir()
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["japan/metro.md", "zalohy.md"]
|
||||
source = store.get_source(conn, "notes")
|
||||
assert source is not None
|
||||
assert source["indexed_rev"] == head
|
||||
assert source["kind"] == "git"
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["vectors"] == stats["chunks"] > 0
|
||||
|
||||
|
||||
def test_second_run_over_the_same_revision_changes_nothing(wiki_env, fake_embedder, tmp_path):
|
||||
"""Verification 2 for the git driver."""
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"zalohy.md": DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
before = _stats(wiki_env)
|
||||
log_before = wiki_env.log_text()
|
||||
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
assert _stats(wiki_env) == before
|
||||
assert wiki_env.log_text() == log_before # ls-remote matched, so not even a fetch
|
||||
|
||||
|
||||
def test_new_commit_reindexes_only_the_changed_file(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"zalohy.md": DOC, "japan/metro.md": TRAVEL_DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
metro_indexed_at = store.list_source_files(conn, "notes")["japan/metro.md"]["indexed_at"]
|
||||
|
||||
head = remote.commit({"zalohy.md": DOC + "\n## Offsite\n\nKopie jede do S3.\n"})
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
files = store.list_source_files(conn, "notes")
|
||||
assert files["japan/metro.md"]["indexed_at"] == metro_indexed_at # untouched
|
||||
source = store.get_source(conn, "notes")
|
||||
assert source is not None and source["indexed_rev"] == head
|
||||
assert len(store.bm25_ranked_ids(conn, "offsite", 10)) == 1
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
|
||||
|
||||
def test_deleted_file_upstream_drops_its_chunks_and_vectors(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"zalohy.md": DOC, "japan/metro.md": TRAVEL_DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
remote.commit({"japan/metro.md": None}, message="drop metro")
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["zalohy.md"]
|
||||
assert store.bm25_ranked_ids(conn, "skyliner", 10) == []
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["vectors"] == stats["chunks"]
|
||||
|
||||
|
||||
def test_renamed_file_moves_its_chunks(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"japan/metro.md": TRAVEL_DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
remote.move("japan/metro.md", "japan/tokyo-metro.md")
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["japan/tokyo-metro.md"]
|
||||
assert len(store.bm25_ranked_ids(conn, "skyliner", 10)) == 1
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
|
||||
|
||||
def test_commit_touching_nothing_indexed_only_records_the_rev(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"zalohy.md": DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
before = _stats(wiki_env)
|
||||
|
||||
head = remote.commit({"tool.py": "print('hello')\n"})
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
assert _stats(wiki_env) == before
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
source = store.get_source(conn, "notes")
|
||||
assert source is not None and source["indexed_rev"] == head
|
||||
assert "no indexed file changed" in wiki_env.log_text()
|
||||
|
||||
|
||||
def test_vendored_markdown_is_excluded(wiki_env, fake_embedder, tmp_path):
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit(
|
||||
{
|
||||
"zalohy.md": DOC,
|
||||
"node_modules/pkg/README.md": "# Foreign readme\n\nnenasazovat\n",
|
||||
"web/vendor/lib/CHANGELOG.md": "# Changelog\n\nnenasazovat\n",
|
||||
}
|
||||
)
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["zalohy.md"]
|
||||
|
||||
|
||||
def test_unreachable_remote_warns_skips_and_lets_other_sources_finish(wiki_env, fake_embedder, tmp_path):
|
||||
"""Verification 8: per-source failure, indexed_rev untouched, workspace completes."""
|
||||
_use_git_source(wiki_env, str(tmp_path / "does-not-exist.git"), with_workspace=True)
|
||||
wiki_env.write_file("notes/local.md", DOC)
|
||||
|
||||
assert wiki_sync.main([]) == 1
|
||||
|
||||
log = wiki_env.log_text()
|
||||
assert "WARN notes:" in log
|
||||
# A permanent failure warns every tick, so each warning must stay a single line.
|
||||
assert len([line for line in log.splitlines() if "WARN notes:" in line]) == 1
|
||||
assert all(line.strip() for line in log.splitlines())
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.list_source_files(conn, "notes") == {}
|
||||
git_source = store.get_source(conn, "notes")
|
||||
assert git_source is not None and git_source["indexed_rev"] is None
|
||||
# The workspace source, which has nothing to do with the network, finished.
|
||||
assert sorted(store.list_source_files(conn, "workspace")) == ["notes/local.md"]
|
||||
|
||||
|
||||
def test_remote_that_recovers_is_picked_up_on_the_next_tick(wiki_env, fake_embedder, tmp_path):
|
||||
missing = tmp_path / "later.git"
|
||||
_use_git_source(wiki_env, str(missing))
|
||||
assert wiki_sync.main([]) == 1
|
||||
|
||||
remote = Remote(tmp_path / "real")
|
||||
(tmp_path / "real").mkdir(exist_ok=True)
|
||||
remote_bare = remote.bare
|
||||
remote.commit({"zalohy.md": DOC})
|
||||
_use_git_source(wiki_env, str(remote_bare))
|
||||
|
||||
assert wiki_sync.main([]) == 0
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["zalohy.md"]
|
||||
|
||||
|
||||
def test_missing_indexed_rev_falls_back_to_a_full_reindex(wiki_env, fake_embedder, tmp_path):
|
||||
"""A force-pushed or gc'd base revision must degrade to a full pass, not crash."""
|
||||
remote = Remote(tmp_path)
|
||||
remote.commit({"zalohy.md": DOC})
|
||||
_use_git_source(wiki_env, remote.url)
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn, store.tx(conn):
|
||||
store.mark_synced(conn, "notes", "0" * 40, "2026-01-01T00:00:00+00:00")
|
||||
|
||||
remote.commit({"japan/metro.md": TRAVEL_DOC})
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "notes")) == ["japan/metro.md", "zalohy.md"]
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
306
skills/wiki/tests/test_wiki_search.py
Normal file
306
skills/wiki/tests/test_wiki_search.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Query layers: RRF merge, the FTS expression, degraded mode, toc and grep."""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import wiki_search
|
||||
import wiki_store as store
|
||||
import wiki_sync
|
||||
from wiki_config import SourceConfig, load_config
|
||||
|
||||
BACKUP_DOC = """---
|
||||
title: Zálohování dat
|
||||
tags: [devops, backup]
|
||||
---
|
||||
|
||||
# Zálohování dat
|
||||
|
||||
Záloha běží každou noc přes rsync na druhý disk.
|
||||
|
||||
## Retence snapshotů
|
||||
|
||||
Držíme třicet denních snapshotů a dvanáct měsíčních.
|
||||
"""
|
||||
|
||||
TRAVEL_DOC = """---
|
||||
title: Tokijské metro
|
||||
tags: [transit]
|
||||
---
|
||||
|
||||
# Tokijské metro
|
||||
|
||||
Z Narity do centra jede Skyliner za osmatřicet minut.
|
||||
"""
|
||||
|
||||
|
||||
def _index(env):
|
||||
assert wiki_sync.main([]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pure functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rrf_merge_over_known_ranks():
|
||||
merged = wiki_search.rrf_merge([[1, 2, 3], [3, 1, 4]])
|
||||
assert [chunk_id for chunk_id, _ in merged] == [1, 3, 2, 4]
|
||||
scores = dict(merged)
|
||||
assert scores[1] == pytest.approx(1 / 61 + 1 / 62)
|
||||
assert scores[3] == pytest.approx(1 / 63 + 1 / 61)
|
||||
assert scores[4] == pytest.approx(1 / 63)
|
||||
|
||||
|
||||
def test_rrf_merge_of_a_single_list_keeps_its_order():
|
||||
assert [i for i, _ in wiki_search.rrf_merge([[7, 8, 9]])] == [7, 8, 9]
|
||||
|
||||
|
||||
def test_fts_expression_adds_prefix_wildcards():
|
||||
"""Czech inflection is covered by the wildcard; a 2-char prefix is too broad to keep."""
|
||||
assert wiki_search.fts_match_expression("záloha dat") == '"záloha"* OR "dat"*'
|
||||
assert wiki_search.fts_match_expression("v ok dva") == '"v" OR "ok" OR "dva"*'
|
||||
|
||||
|
||||
def test_fts_expression_neutralises_operators_and_punctuation():
|
||||
expression = wiki_search.fts_match_expression("NOT (a AND b) -c*")
|
||||
assert expression == '"NOT"* OR "a" OR "AND"* OR "b" OR "c"'
|
||||
|
||||
|
||||
def test_fts_expression_of_empty_query_is_empty():
|
||||
assert wiki_search.fts_match_expression("!!! ") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_reports_which_half_found_each_hit(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
wiki_env.write_file("notes/tokio.md", TRAVEL_DOC)
|
||||
_index(wiki_env)
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "retence snapshotů", limit=5) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "notes/zalohy.md" in out
|
||||
assert "Retence snapshotů" in out
|
||||
assert "bm25 #" in out and "vec #" in out
|
||||
|
||||
|
||||
def test_search_prefix_match_finds_inflected_form(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
_index(wiki_env)
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "zaloh", limit=5) == 0
|
||||
assert "notes/zalohy.md" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_search_says_out_loud_when_embeddings_are_unavailable(wiki_env, fake_embedder, capsys):
|
||||
"""Verification 4: degrade to FTS-only, never silently."""
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
# Drop the fake and let the config's unreachable endpoint take over.
|
||||
wiki_search.OllamaEmbedder = wiki_search.__dict__["OllamaEmbedder"]
|
||||
from wiki_embed import OllamaEmbedder as RealEmbedder
|
||||
|
||||
wiki_search.OllamaEmbedder = RealEmbedder
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "záloha", limit=5) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "embeddings unavailable" in out
|
||||
assert "FTS-only" in out
|
||||
assert "notes/zalohy.md" in out # the lexical half still answers
|
||||
|
||||
|
||||
def test_search_refuses_an_index_from_another_contract(wiki_env, fake_embedder, capsys):
|
||||
"""Verification 5: a mismatch must say `reindex needed`, not return results."""
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
_index(wiki_env)
|
||||
|
||||
wiki_env.config_path.write_text(
|
||||
wiki_env.config_path.read_text(encoding="utf-8").replace(
|
||||
"model: qwen3-embedding:0.6b", "model: qwen3-embedding:8b"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "záloha", limit=5) == 1
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "reindex needed" in captured.err
|
||||
assert "notes/zalohy.md" not in captured.out
|
||||
|
||||
|
||||
def test_search_warns_while_vectors_are_still_pending(wiki_env, monkeypatch, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
assert wiki_sync.main([]) == 1 # unreachable endpoint -> chunks stay pending
|
||||
|
||||
from conftest import FakeEmbedder
|
||||
|
||||
monkeypatch.setattr(wiki_search, "OllamaEmbedder", FakeEmbedder)
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "záloha", limit=5) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "awaiting vectors" in out
|
||||
|
||||
|
||||
def test_search_over_an_empty_index_says_no_matches(wiki_env, fake_embedder, capsys):
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "záloha", limit=5) == 0
|
||||
assert "(no matches)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_an_unrelated_query_still_returns_nearest_neighbours(wiki_env, fake_embedder, capsys):
|
||||
"""KNN has no distance floor: the semantic half always offers its k closest chunks.
|
||||
|
||||
That is deliberate for retrieval — the agent reads the chunk and judges it — but it
|
||||
means an empty result set only ever means an empty index.
|
||||
"""
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "kajakářství", limit=5) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "notes/zalohy.md" in out
|
||||
assert "bm25 #" not in out # nothing lexical matched; every hit came from the vectors
|
||||
|
||||
|
||||
def test_search_with_an_empty_query(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "???", limit=5) == 0
|
||||
assert "(empty query)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_search_limit_caps_the_result_count(wiki_env, fake_embedder, capsys):
|
||||
for index in range(8):
|
||||
wiki_env.write_file(f"notes/doc{index}.md", f"# Dokument {index}\n\nZáloha dat číslo {index}.\n")
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_search(config, "záloha dat", limit=3) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert len([line for line in out.splitlines() if line.startswith(("1. ", "2. ", "3. ", "4. "))]) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# toc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_toc_groups_by_directory_and_shows_titles_and_tags(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/devops/zalohy.md", BACKUP_DOC)
|
||||
wiki_env.write_file("notes/travel/tokio.md", TRAVEL_DOC)
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
assert wiki_search.run_toc(None, None) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "workspace (2 files)" in out
|
||||
assert "notes/devops/" in out
|
||||
assert "zalohy.md" in out
|
||||
assert "Zálohování dat" in out
|
||||
assert "[devops, backup]" in out
|
||||
|
||||
|
||||
def test_toc_filters_by_tag(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
wiki_env.write_file("notes/tokio.md", TRAVEL_DOC)
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
assert wiki_search.run_toc(None, "transit") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "tokio.md" in out
|
||||
assert "zalohy.md" not in out
|
||||
|
||||
|
||||
def test_toc_on_an_empty_index(wiki_env, fake_embedder, capsys):
|
||||
assert wiki_search.run_toc(None, None) == 0
|
||||
assert "(no indexed files match)" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# grep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="ripgrep not installed")
|
||||
def test_grep_sees_source_code_the_index_never_touches(wiki_env, fake_embedder, capsys):
|
||||
"""Layer 1 is the substitute for indexing code, and it costs nothing extra."""
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
wiki_env.write_file("notes/tools/backup.py", "def rotate_snapshots(keep=30):\n return keep\n")
|
||||
_index(wiki_env)
|
||||
capsys.readouterr()
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_grep(config, "rotate_snapshots", None) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "backup.py" in out
|
||||
assert "rotate_snapshots" in out
|
||||
|
||||
# The same identifier is absent from the index — grep is the only layer that has it.
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.bm25_ranked_ids(conn, "rotate_snapshots", 10) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="ripgrep not installed")
|
||||
def test_grep_stays_inside_the_source_paths(wiki_env, fake_embedder, capsys):
|
||||
wiki_env.write_file("notes/zalohy.md", BACKUP_DOC)
|
||||
wiki_env.write_file("tmp/dump/leak.md", "tajnastruna v tmp\n")
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_grep(config, "tajnastruna", None) == 0
|
||||
assert "(no matches)" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="ripgrep not installed")
|
||||
def test_grep_reports_an_unknown_source(wiki_env, fake_embedder, capsys):
|
||||
config = load_config(wiki_env.config_path)
|
||||
assert wiki_search.run_grep(config, "cokoli", "nope") == 1
|
||||
assert "unknown source" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_grep_roots_use_the_literal_prefix_of_each_glob(wiki_env):
|
||||
wiki_env.write_file("notes/a.md", "x")
|
||||
wiki_env.write_file("develop/b.md", "x")
|
||||
source = load_config(wiki_env.config_path).source("workspace")
|
||||
assert source is not None
|
||||
assert wiki_search.grep_roots(source) == [
|
||||
wiki_env.workspace / "notes",
|
||||
wiki_env.workspace / "develop",
|
||||
]
|
||||
|
||||
|
||||
def test_grep_roots_of_a_git_source_is_the_whole_clone(wiki_env):
|
||||
clone = wiki_env.wiki_dir / "remote" / "travel"
|
||||
clone.mkdir(parents=True)
|
||||
source = SourceConfig(
|
||||
source_id="travel",
|
||||
kind="git",
|
||||
url="git@example:travel.git",
|
||||
paths=("**",),
|
||||
include=("*.md",),
|
||||
exclude=(),
|
||||
)
|
||||
assert wiki_search.grep_roots(source) == [clone]
|
||||
279
skills/wiki/tests/test_wiki_sync.py
Normal file
279
skills/wiki/tests/test_wiki_sync.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Sync driver: idempotence, the lock, degraded mode, the meta guard, coverage."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import wiki_store as store
|
||||
import wiki_sync
|
||||
from wiki_config import load_config
|
||||
from wiki_embed import expected_meta
|
||||
|
||||
DOC = """# Zálohování
|
||||
|
||||
Záloha dat na disk probíhá každý den v noci pomocí rsyncu.
|
||||
|
||||
## Retence
|
||||
|
||||
Držíme třicet denních snapshotů a dvanáct měsíčních.
|
||||
"""
|
||||
|
||||
OTHER_DOC = """# Cestování
|
||||
|
||||
Tokio metro je nejrychlejší cesta z Narity do centra.
|
||||
"""
|
||||
|
||||
|
||||
def _stats(env):
|
||||
with store.connection(env.db_path) as conn:
|
||||
return store.index_stats(conn)
|
||||
|
||||
|
||||
def _run(argv=None):
|
||||
return wiki_sync.main(argv or [])
|
||||
|
||||
|
||||
def _file_row(conn, path):
|
||||
row = store.get_file(conn, "workspace", path)
|
||||
assert row is not None, f"{path} is not indexed"
|
||||
return row
|
||||
|
||||
|
||||
def test_indexes_covered_workspace_files(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
wiki_env.write_file("develop/knowledge.md", OTHER_DOC)
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["files"] == 2
|
||||
assert stats["chunks"] > 0
|
||||
assert stats["vectors"] == stats["chunks"]
|
||||
assert stats["pending"] == 0
|
||||
assert "indexed 2 files" in wiki_env.log_text() or "indexed 1 files" in wiki_env.log_text()
|
||||
|
||||
|
||||
def test_paths_whitelist_and_exclude_are_honoured(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/keep.md", DOC)
|
||||
wiki_env.write_file("notes/inbox/raw.md", DOC) # excluded
|
||||
wiki_env.write_file("develop/history.md", DOC) # excluded
|
||||
wiki_env.write_file("tmp/clone/README.md", DOC) # outside paths
|
||||
wiki_env.write_file("notes/script.py", "# TODO: fix this\n") # outside include
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
paths = sorted(store.list_source_files(conn, "workspace"))
|
||||
assert paths == ["notes/keep.md"]
|
||||
|
||||
|
||||
def test_second_run_over_unchanged_tree_is_a_no_op(wiki_env, fake_embedder):
|
||||
"""Verification 2: counts identical and nothing appended to the log."""
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
|
||||
first_stats = _stats(wiki_env)
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
first_indexed_at = _file_row(conn, "notes/zalohy.md")["indexed_at"]
|
||||
log_after_first = wiki_env.log_text()
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
assert _stats(wiki_env) == first_stats
|
||||
assert wiki_env.log_text() == log_after_first
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert _file_row(conn, "notes/zalohy.md")["indexed_at"] == first_indexed_at
|
||||
|
||||
|
||||
def test_touching_a_file_without_changing_it_only_refreshes_stats(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
before = _stats(wiki_env)
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
indexed_at = _file_row(conn, "notes/zalohy.md")["indexed_at"]
|
||||
|
||||
os.utime(wiki_env.workspace / "notes/zalohy.md", (1_600_000_000, 1_600_000_000))
|
||||
assert _run() == 0
|
||||
|
||||
assert _stats(wiki_env) == before
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
row = _file_row(conn, "notes/zalohy.md")
|
||||
assert row["indexed_at"] == indexed_at # content untouched -> no re-chunk
|
||||
assert row["mtime"] == 1_600_000_000
|
||||
|
||||
|
||||
def test_edited_file_is_rechunked_and_reembedded(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
|
||||
wiki_env.write_file("notes/zalohy.md", DOC + "\n## Offsite\n\nKopie jede do S3 každý týden.\n")
|
||||
assert _run() == 0
|
||||
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["pending"] == 0
|
||||
assert stats["vectors"] == stats["chunks"]
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
assert len(store.bm25_ranked_ids(conn, "offsite", 10)) == 1
|
||||
|
||||
|
||||
def test_deleted_file_leaves_no_chunks_or_vectors(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
wiki_env.write_file("notes/travel.md", OTHER_DOC)
|
||||
assert _run() == 0
|
||||
|
||||
(wiki_env.workspace / "notes/travel.md").unlink()
|
||||
assert _run() == 0
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert sorted(store.list_source_files(conn, "workspace")) == ["notes/zalohy.md"]
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
assert store.bm25_ranked_ids(conn, "tokio", 10) == []
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["vectors"] == stats["chunks"]
|
||||
|
||||
|
||||
def test_live_lock_makes_the_run_a_silent_no_op(wiki_env, fake_embedder):
|
||||
"""Verification 3: a second concurrent sync exits 0 without writing."""
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
wiki_sync.LOCK_PATH.write_text(
|
||||
json.dumps({"pid": os.getpid(), "started_at": wiki_sync.datetime.now().astimezone().isoformat()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
assert not wiki_env.db_path.exists()
|
||||
assert wiki_env.log_text() == ""
|
||||
|
||||
|
||||
def test_stale_lock_is_reclaimed_and_logged(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
dead_pid = 999_999
|
||||
wiki_sync.LOCK_PATH.write_text(
|
||||
json.dumps({"pid": dead_pid, "started_at": wiki_sync.datetime.now().astimezone().isoformat()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
assert "stale lock, reclaiming" in wiki_env.log_text()
|
||||
assert _stats(wiki_env)["files"] == 1
|
||||
assert not wiki_sync.LOCK_PATH.exists()
|
||||
|
||||
|
||||
def test_unreadable_lock_is_treated_as_stale(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
wiki_sync.LOCK_PATH.write_text("not json", encoding="utf-8")
|
||||
|
||||
assert _run() == 0
|
||||
assert _stats(wiki_env)["files"] == 1
|
||||
|
||||
|
||||
def test_coverage_report_names_uncovered_directories(wiki_env, fake_embedder):
|
||||
"""Verification 6: markdown outside `paths` must surface as a log line."""
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
wiki_env.write_file("recepty/gulas.md", "# Guláš\n\nCibule na dva kusy hovězího.\n")
|
||||
# The skill's own clone storage holds markdown but is never a candidate.
|
||||
(wiki_env.wiki_dir / "remote" / "index").mkdir(parents=True)
|
||||
(wiki_env.wiki_dir / "remote" / "index" / "foreign.md").write_text("# Cizí\n", encoding="utf-8")
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
coverage = [line for line in wiki_env.log_text().splitlines() if "coverage" in line]
|
||||
assert len(coverage) == 1
|
||||
assert "recepty" in coverage[0]
|
||||
assert "wiki" not in coverage[0]
|
||||
|
||||
|
||||
def test_degraded_mode_then_recovery_without_file_change(wiki_env, monkeypatch):
|
||||
"""Verification 4: no Ollama -> FTS-only pending; once back, step 4b fills the vectors."""
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
|
||||
# The fixture config points at embed.invalid, so no embedder is reachable here.
|
||||
assert _run() == 1
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["chunks"] > 0
|
||||
assert stats["vectors"] == 0
|
||||
assert stats["pending"] == stats["chunks"]
|
||||
assert "embeddings unavailable" in wiki_env.log_text()
|
||||
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert len(store.bm25_ranked_ids(conn, "zaloh*", 10)) >= 1 # FTS works meanwhile
|
||||
|
||||
from conftest import FakeEmbedder
|
||||
|
||||
monkeypatch.setattr(wiki_sync, "OllamaEmbedder", FakeEmbedder)
|
||||
assert _run() == 0 # no file changed, yet the pending chunks get vectors
|
||||
|
||||
stats = _stats(wiki_env)
|
||||
assert stats["pending"] == 0
|
||||
assert stats["vectors"] == stats["chunks"]
|
||||
|
||||
|
||||
def test_meta_mismatch_blocks_indexing_until_full(wiki_env, fake_embedder):
|
||||
"""Verification 5: never mix vectors from two contracts."""
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
before = _stats(wiki_env)
|
||||
|
||||
wiki_env.config_path.write_text(
|
||||
wiki_env.config_path.read_text(encoding="utf-8").replace(
|
||||
"model: qwen3-embedding:0.6b", "model: qwen3-embedding:4b"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
wiki_env.write_file("notes/new.md", OTHER_DOC)
|
||||
|
||||
assert _run() == 1
|
||||
assert "index identity mismatch" in wiki_env.log_text()
|
||||
assert _stats(wiki_env) == before # nothing indexed under the new contract
|
||||
|
||||
assert _run(["--full"]) == 0
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.read_meta(conn)["embedding_model"] == "qwen3-embedding:4b"
|
||||
assert sorted(store.list_source_files(conn, "workspace")) == ["notes/new.md", "notes/zalohy.md"]
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
|
||||
|
||||
def test_full_rebuild_wipes_and_reindexes(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
before = _stats(wiki_env)
|
||||
|
||||
assert _run(["--full"]) == 0
|
||||
|
||||
assert _stats(wiki_env) == before
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.orphan_vector_ids(conn) == []
|
||||
|
||||
|
||||
def test_meta_is_written_on_a_fresh_index(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run() == 0
|
||||
|
||||
config = load_config(wiki_env.config_path)
|
||||
with store.connection(wiki_env.db_path) as conn:
|
||||
assert store.read_meta(conn) == expected_meta(config.embedding)
|
||||
|
||||
|
||||
def test_unknown_source_argument_is_reported(wiki_env, fake_embedder):
|
||||
wiki_env.write_file("notes/zalohy.md", DOC)
|
||||
assert _run(["--source", "nope"]) == 1
|
||||
assert "unknown source" in wiki_env.log_text()
|
||||
|
||||
|
||||
def test_batching_respects_the_configured_size(wiki_env, fake_embedder):
|
||||
"""batch: 4 in the fixture config — the embedder must be called in chunks of 4."""
|
||||
for index in range(6):
|
||||
wiki_env.write_file(f"notes/doc{index}.md", f"# Dokument {index}\n\n{DOC}\n")
|
||||
|
||||
assert _run() == 0
|
||||
|
||||
calls = [len(call) for embedder in fake_embedder for call in embedder.calls]
|
||||
assert calls, "the embedder was never called"
|
||||
assert max(calls) <= 4
|
||||
Reference in New Issue
Block a user