Phase 4 ingestion: exclusion/provenance/incremental/graph tests
This commit is contained in:
22
Makefile
22
Makefile
@@ -23,7 +23,8 @@ BIN := bin
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
|
|
||||||
.PHONY: build dist generate dev test validate validate-self-test fmt vet clean help \
|
.PHONY: build dist generate dev test validate validate-self-test fmt vet clean help \
|
||||||
corpus corpus-sync corpus-rebuild corpus-audit corpus-verify corpus-demo corpus-test
|
corpus corpus-sync corpus-rebuild corpus-offline corpus-audit corpus-verify \
|
||||||
|
corpus-demo corpus-test corpus-graph corpus-scan corpus-sources
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "make build templ generate, compile bin/, no dist"
|
@echo "make build templ generate, compile bin/, no dist"
|
||||||
@@ -36,11 +37,15 @@ help:
|
|||||||
@echo "make clean remove bin/ and dist/"
|
@echo "make clean remove bin/ and dist/"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "corpus (Phase 4, python3 -m corpus.cli):"
|
@echo "corpus (Phase 4, python3 -m corpus.cli):"
|
||||||
@echo "make corpus rebuild the corpus from content/ (drops the collection)"
|
@echo "make corpus rebuild the pool from the estate (drops both collections)"
|
||||||
|
@echo "make corpus-offline rebuild content/ only, no Gitea (recovery and CI)"
|
||||||
@echo "make corpus-sync incremental update; only changed documents are re-embedded"
|
@echo "make corpus-sync incremental update; only changed documents are re-embedded"
|
||||||
@echo "make corpus-audit freshness + drift audit (exit 1 on findings)"
|
@echo "make corpus-audit freshness + drift audit (exit 1 on findings)"
|
||||||
@echo "make corpus-verify check the index against content/ and Qdrant"
|
@echo "make corpus-verify check the index against content/ and Qdrant"
|
||||||
@echo "make corpus-demo same query with and without an area filter"
|
@echo "make corpus-demo same query with and without an area filter"
|
||||||
|
@echo "make corpus-graph Q= query graph nodes and edges (with their source note)"
|
||||||
|
@echo "make corpus-scan search the pool for credential-shaped content"
|
||||||
|
@echo "make corpus-sources show the platform guides and the last harvest"
|
||||||
@echo "make corpus-test corpus unit tests (no container, no network)"
|
@echo "make corpus-test corpus unit tests (no container, no network)"
|
||||||
|
|
||||||
build: generate
|
build: generate
|
||||||
@@ -87,6 +92,9 @@ corpus: corpus-rebuild
|
|||||||
corpus-rebuild:
|
corpus-rebuild:
|
||||||
@$(CORPUS) rebuild
|
@$(CORPUS) rebuild
|
||||||
|
|
||||||
|
corpus-offline:
|
||||||
|
@$(CORPUS) --offline rebuild
|
||||||
|
|
||||||
corpus-sync:
|
corpus-sync:
|
||||||
@$(CORPUS) sync
|
@$(CORPUS) sync
|
||||||
|
|
||||||
@@ -99,6 +107,16 @@ corpus-verify:
|
|||||||
corpus-demo:
|
corpus-demo:
|
||||||
@$(CORPUS) demo-filter --query "distributed audio endpoints" --area edge
|
@$(CORPUS) demo-filter --query "distributed audio endpoints" --area edge
|
||||||
|
|
||||||
|
corpus-graph:
|
||||||
|
@test -n "$(Q)" || { echo "usage: make corpus-graph Q='photo pipeline'"; exit 2; }
|
||||||
|
@$(CORPUS) graph --query "$(Q)"
|
||||||
|
|
||||||
|
corpus-scan:
|
||||||
|
@$(CORPUS) scan
|
||||||
|
|
||||||
|
corpus-sources:
|
||||||
|
@$(CORPUS) sources
|
||||||
|
|
||||||
corpus-test:
|
corpus-test:
|
||||||
@$(PYTHON) -m unittest discover -s corpus/tests -t .
|
@$(PYTHON) -m unittest discover -s corpus/tests -t .
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ def main(argv=None) -> int:
|
|||||||
"measure": _cmd_measure,
|
"measure": _cmd_measure,
|
||||||
"info": _cmd_info,
|
"info": _cmd_info,
|
||||||
"graph": _cmd_graph,
|
"graph": _cmd_graph,
|
||||||
|
"graph-neighbours": _cmd_graph_neighbours,
|
||||||
"scan": _cmd_scan,
|
"scan": _cmd_scan,
|
||||||
"sources": _cmd_sources,
|
"sources": _cmd_sources,
|
||||||
}[args.command]
|
}[args.command]
|
||||||
|
|||||||
@@ -30,15 +30,74 @@ from . import loader, store as store_module
|
|||||||
from .config import CHUNKER_VERSION
|
from .config import CHUNKER_VERSION
|
||||||
from .pipeline import _contract
|
from .pipeline import _contract
|
||||||
from .qdrant import Qdrant
|
from .qdrant import Qdrant
|
||||||
|
from .sources import estate as estate_module
|
||||||
|
|
||||||
|
|
||||||
def audit(cfg, today: datetime.date = None, log=None) -> dict:
|
def audit(cfg, today: datetime.date = None, log=None) -> dict:
|
||||||
"""Return the audit report as a plain dict (JSON-serialisable)."""
|
"""Return the audit report as a plain dict (JSON-serialisable)."""
|
||||||
log = log or (lambda _msg: None)
|
log = log or (lambda _msg: None)
|
||||||
today = today or datetime.date.today()
|
today = today or datetime.date.today()
|
||||||
documents = loader.load_documents(cfg)
|
|
||||||
|
|
||||||
findings = {"stale": [], "due_soon": [], "index_drift": [], "contract_drift": []}
|
# The audit reads the same estate the pipeline reads, so "the source has
|
||||||
|
# stopped changing" is measured against the source, not against the index.
|
||||||
|
harvest = None
|
||||||
|
try:
|
||||||
|
harvest = estate_module.harvest(cfg, log=log)
|
||||||
|
documents = harvest.documents
|
||||||
|
except estate_module.EstateError as exc:
|
||||||
|
documents = loader.load_documents(cfg)
|
||||||
|
estate_error = str(exc)
|
||||||
|
else:
|
||||||
|
estate_error = ""
|
||||||
|
|
||||||
|
findings = {
|
||||||
|
"stale": [],
|
||||||
|
"due_soon": [],
|
||||||
|
"index_drift": [],
|
||||||
|
"contract_drift": [],
|
||||||
|
"stale_source": [],
|
||||||
|
"source_missing": [],
|
||||||
|
"source_unavailable": [],
|
||||||
|
}
|
||||||
|
if estate_error:
|
||||||
|
findings["source_unavailable"].append({"platform": "estate", "problem": estate_error})
|
||||||
|
|
||||||
|
if harvest is not None:
|
||||||
|
for name, row in sorted((harvest.sources or {}).items()):
|
||||||
|
if not row.get("enabled"):
|
||||||
|
continue
|
||||||
|
# Curated content is measured by last_verified above; source silence
|
||||||
|
# is about the harvested estate, which should change on its own.
|
||||||
|
if name == "curated":
|
||||||
|
continue
|
||||||
|
if row.get("missing") or not row.get("documents"):
|
||||||
|
findings["source_missing"].append(
|
||||||
|
{
|
||||||
|
"platform": name,
|
||||||
|
"problem": "no documents were harvested",
|
||||||
|
"repo": row.get("repo") or row.get("base") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
newest = row.get("newest") or ""
|
||||||
|
try:
|
||||||
|
changed = datetime.date.fromisoformat(newest[:10])
|
||||||
|
except ValueError:
|
||||||
|
findings["source_missing"].append(
|
||||||
|
{"platform": name, "problem": f"no usable last_modified ({newest!r})"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
age = (today - changed).days
|
||||||
|
if age > cfg.source_silence_days:
|
||||||
|
findings["stale_source"].append(
|
||||||
|
{
|
||||||
|
"platform": name,
|
||||||
|
"repo": row.get("repo") or row.get("base") or "",
|
||||||
|
"newest": newest,
|
||||||
|
"age_days": age,
|
||||||
|
"problem": f"the source has not changed in {age} days",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
try:
|
try:
|
||||||
@@ -90,6 +149,7 @@ def audit(cfg, today: datetime.date = None, log=None) -> dict:
|
|||||||
{"source_path": source, "problem": "indexed but no longer in content/"}
|
{"source_path": source, "problem": "indexed but no longer in content/"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
graph_counts = store.graph_counts()
|
||||||
stored_contract = store.get_meta("collection")
|
stored_contract = store.get_meta("collection")
|
||||||
if stored_contract != _contract(cfg):
|
if stored_contract != _contract(cfg):
|
||||||
findings["contract_drift"].append(
|
findings["contract_drift"].append(
|
||||||
@@ -120,23 +180,36 @@ def audit(cfg, today: datetime.date = None, log=None) -> dict:
|
|||||||
"sources": store.source_count(),
|
"sources": store.source_count(),
|
||||||
"collection": cfg.collection,
|
"collection": cfg.collection,
|
||||||
"collection_points": points,
|
"collection_points": points,
|
||||||
|
"graph_collection": cfg.graph_collection,
|
||||||
|
"graph_nodes": graph_counts["nodes"],
|
||||||
|
"graph_edges": graph_counts["edges"],
|
||||||
"provider": cfg.provider,
|
"provider": cfg.provider,
|
||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"dim": cfg.dim,
|
"dim": cfg.dim,
|
||||||
"chunker_version": CHUNKER_VERSION,
|
"chunker_version": CHUNKER_VERSION,
|
||||||
},
|
},
|
||||||
"findings": findings,
|
"findings": findings,
|
||||||
|
"sources": (harvest.sources if harvest is not None else {}),
|
||||||
|
"warnings": (harvest.warnings if harvest is not None else []),
|
||||||
"summary": {
|
"summary": {
|
||||||
"stale": len(findings["stale"]),
|
"stale": len(findings["stale"]),
|
||||||
"due_soon": len(findings["due_soon"]),
|
"due_soon": len(findings["due_soon"]),
|
||||||
"index_drift": len(findings["index_drift"]),
|
"index_drift": len(findings["index_drift"]),
|
||||||
"contract_drift": len(findings["contract_drift"]),
|
"contract_drift": len(findings["contract_drift"]),
|
||||||
|
"stale_source": len(findings["stale_source"]),
|
||||||
|
"source_missing": len(findings["source_missing"]),
|
||||||
|
"source_unavailable": len(findings["source_unavailable"]),
|
||||||
},
|
},
|
||||||
# `due_soon` is a warning, not a failure: the document is still
|
# `due_soon` is a warning, not a failure: the document is still
|
||||||
# inside its verification horizon. Stale, drift and contract
|
# inside its verification horizon. Stale, drift and contract
|
||||||
# problems are failures.
|
# problems are failures.
|
||||||
"ok": not (
|
"ok": not (
|
||||||
findings["stale"] or findings["index_drift"] or findings["contract_drift"]
|
findings["stale"]
|
||||||
|
or findings["index_drift"]
|
||||||
|
or findings["contract_drift"]
|
||||||
|
or findings["stale_source"]
|
||||||
|
or findings["source_missing"]
|
||||||
|
or findings["source_unavailable"]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
log(f"audit: {json.dumps(report['summary'], sort_keys=True)}")
|
log(f"audit: {json.dumps(report['summary'], sort_keys=True)}")
|
||||||
@@ -147,9 +220,12 @@ def render_text(report: dict) -> str:
|
|||||||
"""Human-readable audit, for a terminal or an Apprise body."""
|
"""Human-readable audit, for a terminal or an Apprise body."""
|
||||||
lines = []
|
lines = []
|
||||||
corpus = report["corpus"]
|
corpus = report["corpus"]
|
||||||
|
graph = ""
|
||||||
|
if corpus.get("graph_nodes") is not None:
|
||||||
|
graph = f", graph {corpus['graph_nodes']} node(s) / {corpus['graph_edges']} edge(s)"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"corpus: {corpus['documents']} document(s), {corpus['chunks']} chunk(s), "
|
f"corpus: {corpus['documents']} document(s), {corpus['chunks']} chunk(s), "
|
||||||
f"{corpus['collection_points']} vector(s) in {corpus['collection']}"
|
f"{corpus['collection_points']} vector(s) in {corpus['collection']}{graph}"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f"horizon: last_verified within {report['horizon']['max_age_days']} days "
|
f"horizon: last_verified within {report['horizon']['max_age_days']} days "
|
||||||
@@ -159,10 +235,13 @@ def render_text(report: dict) -> str:
|
|||||||
for kind, label in (
|
for kind, label in (
|
||||||
("stale", "STALE — past the verification horizon"),
|
("stale", "STALE — past the verification horizon"),
|
||||||
("due_soon", "DUE SOON — verify before it goes stale"),
|
("due_soon", "DUE SOON — verify before it goes stale"),
|
||||||
("index_drift", "INDEX DRIFT — the index does not match content/"),
|
("index_drift", "INDEX DRIFT — the index does not match the sources"),
|
||||||
("contract_drift", "CONTRACT DRIFT — rebuild required"),
|
("contract_drift", "CONTRACT DRIFT — rebuild required"),
|
||||||
|
("stale_source", "STALE SOURCE — the estate has stopped changing"),
|
||||||
|
("source_missing", "SOURCE MISSING — an enabled platform produced nothing"),
|
||||||
|
("source_unavailable", "SOURCE UNAVAILABLE — the estate could not be read"),
|
||||||
):
|
):
|
||||||
rows = findings[kind]
|
rows = findings.get(kind) or []
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -177,7 +256,10 @@ def render_text(report: dict) -> str:
|
|||||||
)
|
)
|
||||||
if report["ok"]:
|
if report["ok"]:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("OK: nothing stale, no drift." + (" (see warnings above)" if findings["due_soon"] else ""))
|
lines.append(
|
||||||
|
"OK: nothing stale, no drift, every source current."
|
||||||
|
+ (" (see warnings above)" if findings["due_soon"] else "")
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
346
corpus/tests/test_ingestion.py
Normal file
346
corpus/tests/test_ingestion.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""Ingestion from the estate: exclusions, provenance, incremental work, graph.
|
||||||
|
|
||||||
|
These tests never touch the network. `FakeGitea` implements the four read calls
|
||||||
|
the harvester uses; `FakeQdrant` implements the collection routes the pipeline
|
||||||
|
uses. The real estate is exercised separately (docs/phase-4-notes.md), so a
|
||||||
|
failure here is a logic failure, not a connectivity one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from corpus import exclusions, guides, loader, pipeline, store as store_module
|
||||||
|
from corpus.sources import estate, graph as graph_module
|
||||||
|
from corpus.tests import support
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_guide(**overrides) -> guides.Guide:
|
||||||
|
values = dict(
|
||||||
|
name="gitea", enabled=True, what="Self-hosted Git.", fits=["ai", "platform", "edge", "products"],
|
||||||
|
read=["readme", "tree", "selected_source", "commits", "languages"],
|
||||||
|
base="https://gitea.example", auth="GITEA_TOKEN",
|
||||||
|
include={"repos": "all", "paths": []},
|
||||||
|
exclude={"repos": [], "paths": []},
|
||||||
|
limits={"max_file_kb": 200, "max_files_per_repo": 10, "max_commits": 5, "max_tree_entries": 100},
|
||||||
|
)
|
||||||
|
values.update(overrides)
|
||||||
|
return guides.Guide(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self, repos=None, trees=None, files=None, commits=None, languages=None):
|
||||||
|
self.repos_list = repos or []
|
||||||
|
self.trees = trees or {}
|
||||||
|
self.files = files or {}
|
||||||
|
self.commit_list = commits or []
|
||||||
|
self.language_map = languages or {}
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def repos(self):
|
||||||
|
return list(self.repos_list)
|
||||||
|
|
||||||
|
def tree(self, full_name, ref):
|
||||||
|
self.calls.append(("tree", full_name, ref))
|
||||||
|
return list(self.trees.get(full_name, []))
|
||||||
|
|
||||||
|
def raw(self, full_name, ref, path):
|
||||||
|
self.calls.append(("raw", full_name, ref, path))
|
||||||
|
try:
|
||||||
|
return self.files[(full_name, path)]
|
||||||
|
except KeyError:
|
||||||
|
from corpus.sources.gitea import GiteaNotFound
|
||||||
|
|
||||||
|
raise GiteaNotFound(f"{full_name}/{path}") from None
|
||||||
|
|
||||||
|
def commits(self, full_name, ref, limit=20):
|
||||||
|
return list(self.commit_list)
|
||||||
|
|
||||||
|
def languages(self, full_name):
|
||||||
|
return dict(self.language_map)
|
||||||
|
|
||||||
|
|
||||||
|
def repo(full_name="sam/photo-pipeline", **overrides):
|
||||||
|
values = dict(
|
||||||
|
full_name=full_name, name=full_name.split("/")[-1], description="Photo ingestion pipeline",
|
||||||
|
topics=["photo", "python"], default_branch="main", archived=False,
|
||||||
|
created_at="2024-01-01T00:00:00Z", updated_at="2026-10-14T05:00:00Z",
|
||||||
|
html_url=f"https://gitea.example/{full_name}",
|
||||||
|
)
|
||||||
|
values.update(overrides)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def blob(path, size=None):
|
||||||
|
return {"type": "blob", "path": path, "size": len(path) if size is None else size}
|
||||||
|
|
||||||
|
|
||||||
|
class ExclusionTest(unittest.TestCase):
|
||||||
|
"""The DoD probe: a file in an excluded path never appears in the pool."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.guide = gitea_guide()
|
||||||
|
self.result = estate.Harvest()
|
||||||
|
tree = [
|
||||||
|
blob("README.md"),
|
||||||
|
blob("node_modules/left-pad/probe.md"),
|
||||||
|
blob(".venv/lib/probe.py"),
|
||||||
|
blob(".git/config"),
|
||||||
|
blob(".trash/old.md"),
|
||||||
|
blob("docs/.silverbullet.auth.json"),
|
||||||
|
blob("src/main.py"),
|
||||||
|
blob("assets/photo.png"),
|
||||||
|
]
|
||||||
|
files = {
|
||||||
|
("sam/photo-pipeline", "README.md"): b"# Photo pipeline\n\nDeduplicates by hash.\n",
|
||||||
|
("sam/photo-pipeline", "node_modules/left-pad/probe.md"): b"# PROBE\n",
|
||||||
|
("sam/photo-pipeline", ".venv/lib/probe.py"): b"PROBE = 1\n",
|
||||||
|
("sam/photo-pipeline", ".git/config"): b"[core]\n",
|
||||||
|
("sam/photo-pipeline", ".trash/old.md"): b"# old\n",
|
||||||
|
("sam/photo-pipeline", "docs/.silverbullet.auth.json"): b'{"secret_key": "x"}\n',
|
||||||
|
("sam/photo-pipeline", "src/main.py"): b"def dedup():\n return True\n",
|
||||||
|
}
|
||||||
|
self.client = FakeGitea(
|
||||||
|
repos=[repo()],
|
||||||
|
trees={"sam/photo-pipeline": tree},
|
||||||
|
files=files,
|
||||||
|
commits=[{"commit": {"message": "dedup by hash", "author": {"date": "2026-10-14T05:00:00Z"}}}],
|
||||||
|
languages={"Python": 900, "Shell": 100},
|
||||||
|
)
|
||||||
|
self.documents = estate._harvest_one_repo(
|
||||||
|
self.result, self.client, self.guide, repo(), "https://gitea.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_probe_in_excluded_paths_never_appears(self):
|
||||||
|
paths = {doc.repo_path for doc in self.documents}
|
||||||
|
for probe in (
|
||||||
|
"node_modules/left-pad/probe.md",
|
||||||
|
".venv/lib/probe.py",
|
||||||
|
".git/config",
|
||||||
|
".trash/old.md",
|
||||||
|
"docs/.silverbullet.auth.json",
|
||||||
|
):
|
||||||
|
self.assertNotIn(probe, paths)
|
||||||
|
self.assertIn("README.md", paths)
|
||||||
|
self.assertIn("src/main.py", paths)
|
||||||
|
|
||||||
|
def test_rejections_are_reported_with_a_reason(self):
|
||||||
|
reasons = {(row["path"], row["reason"]) for row in self.result.rejections}
|
||||||
|
self.assertIn(("node_modules/left-pad/probe.md", "excluded directory 'node_modules'"), reasons)
|
||||||
|
self.assertIn(("docs/.silverbullet.auth.json", "credential-shaped file name"), reasons)
|
||||||
|
self.assertEqual([image["path"] for image in self.result.images], ["assets/photo.png"])
|
||||||
|
|
||||||
|
def test_media_is_an_image_reference_not_text(self):
|
||||||
|
self.assertEqual([image["path"] for image in self.result.images], ["assets/photo.png"])
|
||||||
|
for doc in self.documents:
|
||||||
|
self.assertNotIn("assets/photo.png", doc.body)
|
||||||
|
|
||||||
|
def test_every_document_carries_provenance(self):
|
||||||
|
for doc in self.documents:
|
||||||
|
self.assertEqual(doc.platform, "gitea")
|
||||||
|
self.assertEqual(doc.tier, 2)
|
||||||
|
self.assertTrue(doc.origin_url.startswith("https://gitea.example/sam/photo-pipeline"))
|
||||||
|
self.assertTrue(doc.last_modified)
|
||||||
|
self.assertIn(doc.area, self.guide.fits)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialContentTest(unittest.TestCase):
|
||||||
|
def test_a_valid_path_with_a_private_key_is_rejected(self):
|
||||||
|
result = estate.Harvest()
|
||||||
|
guide = gitea_guide()
|
||||||
|
client = FakeGitea(
|
||||||
|
repos=[repo()],
|
||||||
|
trees={"sam/photo-pipeline": [blob("README.md"), blob("src/leak.py")]},
|
||||||
|
files={
|
||||||
|
("sam/photo-pipeline", "README.md"): b"# Photo pipeline\n",
|
||||||
|
("sam/photo-pipeline", "src/leak.py"): (
|
||||||
|
("KEY = '" + "-----BEGIN " + "PRIVATE KEY-----'\n").encode("utf-8")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
commits=[],
|
||||||
|
languages={"Python": 10},
|
||||||
|
)
|
||||||
|
estate._harvest_one_repo(result, client, guide, repo(), "https://gitea.example")
|
||||||
|
reasons = {row["reason"]: row for row in result.rejections}
|
||||||
|
self.assertIn("credential-shaped content", reasons)
|
||||||
|
self.assertEqual(reasons["credential-shaped content"]["path"], "src/leak.py")
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadProvenanceTest(unittest.TestCase):
|
||||||
|
def test_missing_provenance_field_fails_the_payload(self):
|
||||||
|
root = support.temp_repo()
|
||||||
|
self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True))
|
||||||
|
docs = loader.load_documents(support.make_config(root))
|
||||||
|
chunk = __import__("corpus.chunker", fromlist=["chunk_document"]).chunk_document(docs[0].body)[0]
|
||||||
|
payload = __import__("corpus.payloads", fromlist=["build_payload"]).build_payload(
|
||||||
|
docs[0], chunk, support.make_config(root)
|
||||||
|
)
|
||||||
|
payload["id"] = pipeline.chunk_id(docs[0].path, 0, docs[0].digest)
|
||||||
|
for field in ("platform", "tier", "origin_url", "path", "last_modified"):
|
||||||
|
broken = dict(payload)
|
||||||
|
broken.pop(field)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
__import__("corpus.payloads", fromlist=["validate_payload"]).validate_payload(broken)
|
||||||
|
|
||||||
|
|
||||||
|
GRAPH = {
|
||||||
|
"built_at_commit": "abc123",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "photo_ingest", "label": "Photo ingest", "source_file": "300 areas/Photo.md",
|
||||||
|
"community": 7, "node_type": "entity"},
|
||||||
|
{"id": "settings", "label": "settings.json", "source_file": "settings.json",
|
||||||
|
"community": 118, "node_type": "code"},
|
||||||
|
{"id": "image_node", "label": "diagram", "source_file": "300 areas/diagram.png",
|
||||||
|
"community": 7, "node_type": "image"},
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{"source": "photo_ingest", "target": "settings", "relation": "configured_by",
|
||||||
|
"source_file": "300 areas/Photo.md", "confidence": "EXTRACTED", "confidence_score": 0.9},
|
||||||
|
{"source": "settings", "target": "photo_ingest", "relation": "configures",
|
||||||
|
"source_file": "settings.json", "confidence": "INFERRED"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GraphTest(unittest.TestCase):
|
||||||
|
def parse(self):
|
||||||
|
return graph_module.parse(
|
||||||
|
json.dumps(GRAPH).encode("utf-8"),
|
||||||
|
platform="graphify", base="https://gitea.example", repo="sam/obsidian-vault",
|
||||||
|
ref="main", last_modified="2026-10-14T05:00:00Z", area="ai",
|
||||||
|
vault_paths={"300 areas/Photo.md", "300 areas/diagram.png"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nodes_and_edges_carry_their_source_note(self):
|
||||||
|
result = self.parse()
|
||||||
|
self.assertEqual(len(result.nodes), 1)
|
||||||
|
self.assertEqual(result.nodes[0]["id"], "photo_ingest")
|
||||||
|
self.assertEqual(result.nodes[0]["source_note"], "300 areas/Photo.md")
|
||||||
|
self.assertIn("/sam/obsidian-vault/src/branch/main/300%20areas/Photo.md", result.nodes[0]["origin_url"])
|
||||||
|
self.assertTrue(result.edges)
|
||||||
|
for edge in result.edges:
|
||||||
|
self.assertTrue(edge["source_note"])
|
||||||
|
|
||||||
|
def test_a_node_without_a_resolvable_note_is_refused(self):
|
||||||
|
result = self.parse()
|
||||||
|
rejected = {row["path"] for row in result.rejections}
|
||||||
|
self.assertIn("settings.json", rejected)
|
||||||
|
self.assertNotIn("settings", {row["id"] for row in result.nodes})
|
||||||
|
|
||||||
|
def test_media_becomes_an_image_reference(self):
|
||||||
|
result = self.parse()
|
||||||
|
self.assertEqual([image["path"] for image in result.images], ["300 areas/diagram.png"])
|
||||||
|
self.assertNotIn("image_node", {row["id"] for row in result.nodes})
|
||||||
|
|
||||||
|
def test_graph_query_returns_the_note(self):
|
||||||
|
root = support.temp_repo()
|
||||||
|
self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True))
|
||||||
|
cfg = support.make_config(root)
|
||||||
|
harvest = estate.Harvest()
|
||||||
|
harvest.documents = loader.load_documents(cfg)
|
||||||
|
harvest.graph = self.parse()
|
||||||
|
harvest.sources["graphify"] = {"platform": "graphify", "enabled": True, "documents": 0}
|
||||||
|
fake = support.FakeQdrant(cfg.dim)
|
||||||
|
with mock.patch("corpus.sources.estate.harvest", return_value=harvest):
|
||||||
|
with support.fake_qdrant(cfg, fake):
|
||||||
|
pipeline.run(cfg, mode="rebuild")
|
||||||
|
with store_module.Store(cfg.db_path) as store:
|
||||||
|
rows = store.graph_search("photo")
|
||||||
|
self.assertTrue(rows)
|
||||||
|
self.assertTrue(all(row["source_note"] for row in rows))
|
||||||
|
self.assertEqual(store.graph_node("photo_ingest")["source_note"], "300 areas/Photo.md")
|
||||||
|
self.assertTrue(store.graph_neighbours("photo_ingest"))
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalEstateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.root = support.temp_repo()
|
||||||
|
self.addCleanup(lambda: __import__("shutil").rmtree(self.root, ignore_errors=True))
|
||||||
|
self.cfg = support.make_config(self.root)
|
||||||
|
self.fake = support.FakeQdrant(self.cfg.dim)
|
||||||
|
self.harvest = estate.Harvest()
|
||||||
|
self.harvest.documents = loader.load_documents(self.cfg)
|
||||||
|
self.harvest.documents.append(self.harvested("a" * 64, "First version of the repository."))
|
||||||
|
self.harvest.sources["gitea"] = {"platform": "gitea", "enabled": True, "documents": 1}
|
||||||
|
|
||||||
|
def harvested(self, digest, body):
|
||||||
|
return loader.Document(
|
||||||
|
path="gitea:sam/photo-pipeline/README.md", source_id="sam/photo-pipeline/README.md",
|
||||||
|
area="platform", level=3, title="sam/photo-pipeline — README.md",
|
||||||
|
claim=body, confidence="historic", period="2024–2026", tech=[], metrics=[], artefacts=[],
|
||||||
|
public=True, last_verified="2026-10-14", tags=["gitea"], body=body, digest=digest,
|
||||||
|
url="https://gitea.example/sam/photo-pipeline/src/branch/main/README.md",
|
||||||
|
platform="gitea", tier=2, origin_url="https://gitea.example/sam/photo-pipeline/src/branch/main/README.md",
|
||||||
|
last_modified="2026-10-14T05:00:00Z", source_type="doc", kind="markdown",
|
||||||
|
repo_path="README.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_pipeline(self, mode="sync"):
|
||||||
|
with mock.patch("corpus.sources.estate.harvest", return_value=self.harvest):
|
||||||
|
with support.fake_qdrant(self.cfg, self.fake):
|
||||||
|
return pipeline.run(self.cfg, mode=mode)
|
||||||
|
|
||||||
|
def test_rebuild_and_second_run_do_not_duplicate(self):
|
||||||
|
first = self.run_pipeline("rebuild")
|
||||||
|
points = len(self.fake.collections[self.cfg.collection])
|
||||||
|
self.assertEqual(first.platforms["gitea"]["documents"], 1)
|
||||||
|
second = self.run_pipeline("sync")
|
||||||
|
self.assertEqual(len(self.fake.collections[self.cfg.collection]), points)
|
||||||
|
self.assertEqual(second.added, 0)
|
||||||
|
self.assertEqual(second.updated, 0)
|
||||||
|
self.assertEqual(second.texts_embedded, 0)
|
||||||
|
|
||||||
|
def test_only_the_changed_harvested_document_is_reembedded(self):
|
||||||
|
self.run_pipeline("rebuild")
|
||||||
|
self.harvest.documents[-1] = self.harvested("b" * 64, "Second version, one line changed.")
|
||||||
|
stats = self.run_pipeline("sync")
|
||||||
|
self.assertEqual(stats.updated, 1)
|
||||||
|
expected = len(__import__("corpus.chunker", fromlist=["chunk_document"]).chunk_document(
|
||||||
|
"Second version, one line changed."
|
||||||
|
))
|
||||||
|
self.assertEqual(stats.texts_embedded, expected)
|
||||||
|
self.assertEqual(stats.embed_calls, 1)
|
||||||
|
self.assertEqual(stats.unchanged, 3)
|
||||||
|
|
||||||
|
def test_pool_scan_finds_a_planted_secret(self):
|
||||||
|
self.harvest.documents[-1] = self.harvested(
|
||||||
|
"c" * 64,
|
||||||
|
"AWS_ACCESS_KEY_ID=" + "AKIA" + "ABCDEFGHIJKLMNOP" + "\nThis line is a planted credential.",
|
||||||
|
)
|
||||||
|
with self.assertRaises(pipeline.PipelineError) as caught:
|
||||||
|
self.run_pipeline("rebuild")
|
||||||
|
self.assertIn("credential-shaped content", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class CodeChunkerTest(unittest.TestCase):
|
||||||
|
def test_a_function_is_never_cut(self):
|
||||||
|
from corpus import chunker
|
||||||
|
|
||||||
|
body = "\n\n".join(
|
||||||
|
f"def function_{index}():\n value = {{'index': {index}}}\n return value"
|
||||||
|
for index in range(120)
|
||||||
|
)
|
||||||
|
chunks = chunker.chunk_code(body)
|
||||||
|
self.assertGreater(len(chunks), 1)
|
||||||
|
for chunk in chunks:
|
||||||
|
depth = 0
|
||||||
|
for line in chunk.text.splitlines():
|
||||||
|
depth = chunker._code_depth(line, depth)
|
||||||
|
self.assertEqual(depth, 0, f"chunk {chunk.index} ends inside a block")
|
||||||
|
self.assertIn("def function_", chunk.text)
|
||||||
|
|
||||||
|
def test_oversize_single_function_is_flagged_not_cut(self):
|
||||||
|
from corpus import chunker
|
||||||
|
|
||||||
|
body = "def huge():\n" + "\n".join(f" x{index} = {index}" for index in range(2000))
|
||||||
|
chunks = chunker.chunk_code(body)
|
||||||
|
self.assertEqual(len(chunks), 1)
|
||||||
|
self.assertTrue(chunks[0].oversize)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -48,13 +48,49 @@ from corpus.config import Config # noqa: E402
|
|||||||
|
|
||||||
@_flow(name="corpus-sync", log_prints=True)
|
@_flow(name="corpus-sync", log_prints=True)
|
||||||
def sync_corpus() -> dict:
|
def sync_corpus() -> dict:
|
||||||
"""Incremental refresh. Runs on every push that touches content/."""
|
"""Incremental refresh of the whole estate. Runs on a push or on demand."""
|
||||||
logger = _get_run_logger()
|
logger = _get_run_logger()
|
||||||
cfg = Config.from_env()
|
cfg = Config.from_env()
|
||||||
stats = pipeline.run(cfg, mode="sync", log=logger.info)
|
stats = pipeline.run(cfg, mode="sync", log=logger.info)
|
||||||
return stats.to_dict()
|
return stats.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@_flow(name="corpus-scheduled-ingest", log_prints=True)
|
||||||
|
def scheduled_ingest(alert: bool = True) -> dict:
|
||||||
|
"""Nightly: harvest, report what changed, then audit and alert on staleness.
|
||||||
|
|
||||||
|
This is the scheduled run the DoD asks for: it reports documents added,
|
||||||
|
updated and removed per platform, and it raises an Apprise alert when a
|
||||||
|
source is missing, unavailable or has stopped changing.
|
||||||
|
"""
|
||||||
|
logger = _get_run_logger()
|
||||||
|
cfg = Config.from_env()
|
||||||
|
stats = pipeline.run(cfg, mode="sync", log=logger.info)
|
||||||
|
logger.info(stats.summary())
|
||||||
|
report = freshness.audit(cfg, log=logger.info)
|
||||||
|
logger.info(freshness.render_text(report))
|
||||||
|
if alert and not report["ok"]:
|
||||||
|
try:
|
||||||
|
freshness.alert(report, cfg.apprise_url)
|
||||||
|
except Exception as exc: # noqa: BLE001 - the alert must not fail the run
|
||||||
|
logger.warning(f"Apprise alert failed: {exc}")
|
||||||
|
return {
|
||||||
|
"changed": {
|
||||||
|
"documents": stats.documents,
|
||||||
|
"added": stats.added,
|
||||||
|
"updated": stats.updated,
|
||||||
|
"removed": stats.removed,
|
||||||
|
"unchanged": stats.unchanged,
|
||||||
|
"embedding_calls": stats.embed_calls,
|
||||||
|
"texts_embedded": stats.texts_embedded,
|
||||||
|
"platforms": stats.platforms,
|
||||||
|
"rejections": stats.rejection_summary,
|
||||||
|
"graph_rebuilt": stats.graph_rebuilt,
|
||||||
|
},
|
||||||
|
"audit": report,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@_flow(name="corpus-rebuild", log_prints=True)
|
@_flow(name="corpus-rebuild", log_prints=True)
|
||||||
def rebuild_corpus() -> dict:
|
def rebuild_corpus() -> dict:
|
||||||
"""From-scratch rebuild. The recovery path: deleting the volume is always safe."""
|
"""From-scratch rebuild. The recovery path: deleting the volume is always safe."""
|
||||||
@@ -85,5 +121,7 @@ if __name__ == "__main__": # pragma: no cover - convenience for a manual run
|
|||||||
print(freshness.render_text(freshness_audit(alert=False)))
|
print(freshness.render_text(freshness_audit(alert=False)))
|
||||||
elif command == "rebuild":
|
elif command == "rebuild":
|
||||||
print(rebuild_corpus())
|
print(rebuild_corpus())
|
||||||
|
elif command == "scheduled":
|
||||||
|
print(scheduled_ingest(alert=False))
|
||||||
else:
|
else:
|
||||||
print(sync_corpus())
|
print(sync_corpus())
|
||||||
|
|||||||
Reference in New Issue
Block a user