680 lines
28 KiB
Python
680 lines
28 KiB
Python
"""The ingestion pipeline: estate -> normalise -> chunk -> enrich -> embed -> upsert.
|
|
|
|
One entry point, `run`, with two modes:
|
|
|
|
* **rebuild** — drop the collections, empty the store, embed everything. This is
|
|
the "one command rebuilds the pool from an empty volume" path.
|
|
* **sync** — the default. Only documents whose content hash, embedding model,
|
|
embedding dimension or chunker version changed are re-chunked and re-embedded.
|
|
Everything else is left alone, including the graph when graph.json is byte-for-byte
|
|
unchanged.
|
|
|
|
The order matters. Every payload for every changed document is built and
|
|
validated *before* a single vector is written, so an invalid payload stops the
|
|
run with an empty diff rather than leaving half an index behind. Embedding is one
|
|
flat pass over the changed chunks, batched, so the provider call count is a
|
|
direct measure of how much work incremental mode actually did.
|
|
|
|
Deterministic ids (`sha256(source_path + chunk_index + content_hash)`) mean
|
|
re-running updates points instead of duplicating them, and the id is a valid
|
|
UUID so Qdrant accepts it as a point id without a mapping table.
|
|
|
|
Two Qdrant collections, one store:
|
|
|
|
* `resume_corpus` — one point per chunk of a document, from every platform;
|
|
* `resume_graph` — one point per graph node and one per graph edge, joined to the
|
|
note it came from by `source_note`.
|
|
|
|
Neither is ever read by the public site (acceptance test A1): the site serves
|
|
pre-rendered files and does not open a socket to Qdrant.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
|
|
from . import chunker, embeddings, exclusions, payloads, store as store_module
|
|
from .config import CHUNKER_VERSION
|
|
from .qdrant import Qdrant, QdrantError
|
|
from .sources import estate as estate_module
|
|
from .sources import graph as graph_module
|
|
|
|
# How many texts one embed call carries. The provider may batch smaller
|
|
# internally (OmniRoute does); this bounds memory on the ingest host.
|
|
EMBED_BATCH = 128
|
|
|
|
|
|
class PipelineError(RuntimeError):
|
|
"""The run cannot continue without corrupting the index."""
|
|
|
|
|
|
def chunk_id(source_path: str, chunk_index: int, content_hash: str) -> str:
|
|
"""Deterministic point id: sha256(source_path + chunk_index + content_hash).
|
|
|
|
Rendered as a UUID because Qdrant only accepts unsigned integers or UUIDs as
|
|
point ids; the first 128 bits of the digest keep collisions improbable.
|
|
"""
|
|
digest = hashlib.sha256(
|
|
f"{source_path}\x00{chunk_index}\x00{content_hash}".encode("utf-8")
|
|
).hexdigest()
|
|
return str(uuid.UUID(hex=digest[:32]))
|
|
|
|
|
|
@dataclass
|
|
class RunStats:
|
|
mode: str = "sync"
|
|
documents: int = 0
|
|
added: int = 0
|
|
updated: int = 0
|
|
unchanged: int = 0
|
|
removed: int = 0
|
|
chunks_written: int = 0
|
|
chunks_deleted: int = 0
|
|
embed_calls: int = 0
|
|
texts_embedded: int = 0
|
|
points_sent: int = 0
|
|
collection_points: int = 0
|
|
store_chunks: int = 0
|
|
graph_nodes: int = 0
|
|
graph_edges: int = 0
|
|
graph_points: int = 0
|
|
graph_rebuilt: bool = False
|
|
rejections: int = 0
|
|
images: int = 0
|
|
duration_s: float = 0.0
|
|
collection: str = ""
|
|
graph_collection: str = ""
|
|
provider: str = ""
|
|
model: str = ""
|
|
dim: int = 0
|
|
chunker_version: str = CHUNKER_VERSION
|
|
platforms: dict = field(default_factory=dict)
|
|
rejection_summary: dict = field(default_factory=dict)
|
|
warnings: list = field(default_factory=list)
|
|
notes: list = field(default_factory=list)
|
|
|
|
def summary(self) -> str:
|
|
rows = [
|
|
f"mode={self.mode} documents={self.documents} chunks={self.store_chunks}",
|
|
f"documents added={self.added} updated={self.updated} unchanged={self.unchanged} removed={self.removed}",
|
|
f"chunks written={self.chunks_written} deleted={self.chunks_deleted}",
|
|
f"embedding calls={self.embed_calls} texts={self.texts_embedded}",
|
|
f"points sent={self.points_sent} collection points={self.collection_points}",
|
|
f"graph nodes={self.graph_nodes} edges={self.graph_edges} points={self.graph_points} "
|
|
f"rebuilt={self.graph_rebuilt}",
|
|
f"rejections={self.rejections} image references={self.images}",
|
|
f"platforms={json.dumps(self.platforms, sort_keys=True)}",
|
|
f"provider={self.provider} model={self.model} dim={self.dim} chunker={self.chunker_version}",
|
|
f"collection={self.collection} graph_collection={self.graph_collection} "
|
|
f"duration={self.duration_s:.2f}s",
|
|
]
|
|
if self.rejection_summary:
|
|
rows.append(f"rejected: {json.dumps(self.rejection_summary, sort_keys=True)}")
|
|
rows.extend(self.notes)
|
|
rows.extend(f"warning: {warning}" for warning in self.warnings)
|
|
return "\n".join(rows)
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
def run(cfg, mode: str = "sync", log=None) -> RunStats:
|
|
"""Build or refresh the pool. Returns what happened; raises on any error."""
|
|
if mode not in ("sync", "rebuild"):
|
|
raise PipelineError(f"unknown mode {mode!r}; expected 'sync' or 'rebuild'")
|
|
started = time.time()
|
|
log = log or (lambda _msg: None)
|
|
stats = RunStats(
|
|
mode=mode,
|
|
collection=cfg.collection,
|
|
graph_collection=cfg.graph_collection,
|
|
provider=cfg.provider,
|
|
model=cfg.model,
|
|
dim=cfg.dim,
|
|
chunker_version=cfg.chunker_version,
|
|
)
|
|
|
|
# 1. Read the estate. Curated content is validated first (loader does it),
|
|
# so a broken content tree stops the run before Gitea is touched.
|
|
harvest = estate_module.harvest(cfg, log=log)
|
|
documents = harvest.documents
|
|
stats.documents = len(documents)
|
|
stats.rejections = len(harvest.rejections)
|
|
stats.images = len(harvest.images)
|
|
stats.rejection_summary = harvest.rejection_summary()
|
|
stats.warnings = list(harvest.warnings)
|
|
stats.platforms = {
|
|
name: {"documents": row.get("documents", 0)} for name, row in harvest.sources.items()
|
|
}
|
|
log(f"estate: {len(documents)} document(s), {stats.rejections} rejection(s), {stats.images} image reference(s)")
|
|
|
|
provider = embeddings.build_provider(cfg)
|
|
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
|
if not qdrant.health():
|
|
raise PipelineError(
|
|
f"Qdrant is not answering at {cfg.qdrant_url}. Start it "
|
|
"(deploy/qdrant.yml) or point RESUME_QDRANT_URL at a running instance."
|
|
)
|
|
|
|
with store_module.Store(cfg.db_path) as store:
|
|
contract = _contract(cfg)
|
|
mode = _check_contract(store, qdrant, cfg, contract, mode)
|
|
_ensure_collection(qdrant, cfg.collection, cfg)
|
|
|
|
manifest = store.manifest()
|
|
changed, removed = _classify(documents, manifest, cfg, mode)
|
|
stats.unchanged = len(documents) - len(changed)
|
|
stats.removed = len(removed)
|
|
log(
|
|
f"incremental plan: {len(changed)} to embed, "
|
|
f"{stats.unchanged} unchanged, {len(removed)} removed"
|
|
)
|
|
|
|
for path in removed:
|
|
stats.chunks_deleted += store.delete_document(path)
|
|
qdrant.delete_by_source(cfg.collection, path)
|
|
log(f"removed {path}")
|
|
|
|
# 2. Build and validate every payload before any write.
|
|
prepared = [] # [(doc, payloads)]
|
|
for doc in changed:
|
|
prepared.append((doc, _prepare(doc, cfg)))
|
|
all_payloads = [payload for _, rows in prepared for payload in rows]
|
|
|
|
# 3. Embed in one flat, batched pass. This is the number the DoD uses to
|
|
# prove incremental mode: it is proportional to changed chunks only.
|
|
vectors = _embed_all(provider, [payload["text"] for payload in all_payloads])
|
|
if len(vectors) != len(all_payloads):
|
|
raise PipelineError(
|
|
f"provider returned {len(vectors)} vectors for {len(all_payloads)} chunks"
|
|
)
|
|
|
|
cursor = 0
|
|
platform_changes = stats.platforms
|
|
for doc, rows in prepared:
|
|
points = []
|
|
for payload in rows:
|
|
vector = vectors[cursor]
|
|
cursor += 1
|
|
if len(vector) != cfg.dim:
|
|
raise PipelineError(
|
|
f"{doc.path}: vector dimension {len(vector)} != configured {cfg.dim}"
|
|
)
|
|
points.append({"id": payload["id"], "vector": vector, "payload": payload})
|
|
|
|
# Remove what this document used to have, then write the new rows and
|
|
# points. A crash between the two leaves an index that reports as
|
|
# incomplete, never one that claims to be whole.
|
|
stats.chunks_deleted += store.delete_document(doc.path)
|
|
qdrant.delete_by_source(cfg.collection, doc.path)
|
|
stats.points_sent += qdrant.upsert(cfg.collection, points)
|
|
store.write_document(doc, rows)
|
|
stats.chunks_written += len(rows)
|
|
|
|
bucket = platform_changes.setdefault(doc.platform, {"documents": 0})
|
|
key = "updated" if doc.path in manifest else "added"
|
|
bucket[key] = bucket.get(key, 0) + 1
|
|
if doc.path in manifest:
|
|
stats.updated += 1
|
|
log(f"updated {doc.path} ({len(points)} chunk(s))")
|
|
else:
|
|
stats.added += 1
|
|
log(f"added {doc.path} ({len(points)} chunk(s))")
|
|
|
|
provider_stats = provider.stats()
|
|
stats.embed_calls = provider_stats["calls"]
|
|
stats.texts_embedded = provider_stats["texts"]
|
|
|
|
# 4. Graph: its own collection, rebuilt only when graph.json changed.
|
|
graph_stats = _sync_graph(qdrant, store, cfg, harvest, contract, mode, provider, log)
|
|
stats.graph_nodes = graph_stats["nodes"]
|
|
stats.graph_edges = graph_stats["edges"]
|
|
stats.graph_points = graph_stats["points"]
|
|
stats.graph_rebuilt = graph_stats["rebuilt"]
|
|
stats.notes.extend(graph_stats["notes"])
|
|
|
|
# 5. Consistency and the credential scan. Both are checks, not hopes.
|
|
stats.store_chunks = store.chunk_count()
|
|
stats.collection_points = qdrant.point_count(cfg.collection)
|
|
if stats.collection_points != stats.store_chunks:
|
|
raise PipelineError(
|
|
"index is inconsistent after the run: "
|
|
f"{stats.collection_points} point(s) in Qdrant but "
|
|
f"{stats.store_chunks} chunk(s) in SQLite. Run `python3 -m corpus.cli verify`."
|
|
)
|
|
_scan_pool(store)
|
|
_scan_graph(store)
|
|
|
|
store.set_meta("collection", contract)
|
|
store.set_meta("sources", harvest.sources)
|
|
store.set_meta("rejections", harvest.rejections[:200])
|
|
store.set_meta("images", harvest.images[:500])
|
|
store.set_meta(
|
|
"last_run",
|
|
{
|
|
**stats.to_dict(),
|
|
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
},
|
|
)
|
|
_write_collection_file(cfg, store, stats, harvest)
|
|
|
|
stats.duration_s = round(time.time() - started, 3)
|
|
return stats
|
|
|
|
|
|
# -- planning -------------------------------------------------------------
|
|
|
|
|
|
def _contract(cfg) -> dict:
|
|
"""What this collection *is*. Recorded so a change is a migration, not drift."""
|
|
return {
|
|
"provider": cfg.provider,
|
|
"model": cfg.model,
|
|
"dim": cfg.dim,
|
|
"chunker_version": cfg.chunker_version,
|
|
"distance": "Cosine",
|
|
}
|
|
|
|
|
|
def _check_contract(store, qdrant, cfg, contract: dict, mode: str) -> str:
|
|
if mode == "rebuild":
|
|
qdrant.drop_collection(cfg.collection)
|
|
qdrant.drop_collection(cfg.graph_collection)
|
|
if store.schema_stale:
|
|
store.rebuild_schema()
|
|
else:
|
|
store.reset()
|
|
store.set_meta("schema_version", store_module.SCHEMA_VERSION)
|
|
return mode
|
|
|
|
if store.schema_stale:
|
|
raise PipelineError(
|
|
"the corpus database was written by an older payload shape and has no "
|
|
"provenance columns. It is derived data: run `python3 -m corpus.cli rebuild`."
|
|
)
|
|
|
|
stored = store.get_meta("collection")
|
|
if stored is None:
|
|
if qdrant.collection_exists(cfg.collection) and qdrant.point_count(cfg.collection) > 0:
|
|
raise PipelineError(
|
|
f"collection {cfg.collection!r} holds points but this store has no "
|
|
"collection contract (was data/corpus/corpus.db deleted?). "
|
|
"Confirm you want to overwrite it: python3 -m corpus.cli rebuild"
|
|
)
|
|
return mode
|
|
if stored != contract:
|
|
raise PipelineError(
|
|
"the collection contract does not match this configuration — refusing to "
|
|
"mix vectors in one collection (docs/phase-4-corpus.md §4).\n"
|
|
f" stored: {json.dumps(stored, sort_keys=True)}\n"
|
|
f" config: {json.dumps(contract, sort_keys=True)}\n"
|
|
"A provider or chunker change is a governed migration: run "
|
|
"`python3 -m corpus.cli rebuild` once Sam has approved the change."
|
|
)
|
|
return mode
|
|
|
|
|
|
def _ensure_collection(qdrant: Qdrant, name: str, cfg) -> None:
|
|
if not qdrant.collection_exists(name):
|
|
qdrant.create_collection(name, cfg.dim, vectors_on_disk=cfg.vectors_on_disk)
|
|
return
|
|
info = qdrant.collection_info(name)
|
|
vectors = ((info.get("config") or {}).get("params") or {}).get("vectors")
|
|
size = None
|
|
if isinstance(vectors, dict):
|
|
size = vectors.get("size")
|
|
if size is not None and int(size) != int(cfg.dim):
|
|
raise PipelineError(
|
|
f"collection {name!r} has vectors of size {size} but this run "
|
|
f"is configured for {cfg.dim}. That is a governed migration: rebuild."
|
|
)
|
|
|
|
|
|
def _classify(documents: list, manifest: dict, cfg, mode: str):
|
|
"""Which documents need work, and which are gone."""
|
|
changed = []
|
|
on_disk = set()
|
|
for doc in documents:
|
|
on_disk.add(doc.path)
|
|
if mode == "rebuild":
|
|
changed.append(doc)
|
|
continue
|
|
entry = manifest.get(doc.path)
|
|
if entry is None:
|
|
changed.append(doc)
|
|
continue
|
|
if (
|
|
entry["content_hash"] != doc.digest
|
|
or entry["embedding_model"] != cfg.model
|
|
or int(entry["embedding_dim"]) != int(cfg.dim)
|
|
or entry["chunker_version"] != cfg.chunker_version
|
|
):
|
|
changed.append(doc)
|
|
removed = sorted(path for path in manifest if path not in on_disk)
|
|
return changed, removed
|
|
|
|
|
|
def _prepare(doc, cfg) -> list:
|
|
"""Chunk one document and build validated payloads. No network, no writes."""
|
|
try:
|
|
if getattr(doc, "kind", "markdown") == "code":
|
|
chunks = chunker.chunk_code(doc.body)
|
|
else:
|
|
chunks = chunker.chunk_document(doc.body)
|
|
except chunker.ChunkError as exc:
|
|
raise PipelineError(f"{doc.path}: {exc}") from exc
|
|
if not chunks:
|
|
raise PipelineError(f"{doc.path}: produced no chunks (empty body?)")
|
|
doc.chunk_count = len(chunks)
|
|
|
|
out = []
|
|
for chunk in chunks:
|
|
payload = payloads.build_payload(doc, chunk, cfg)
|
|
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
|
try:
|
|
payloads.validate_payload(payload, where=f"{doc.path}#chunk-{chunk.index}")
|
|
except payloads.PayloadError as exc:
|
|
raise PipelineError(str(exc)) from exc
|
|
out.append(payload)
|
|
return out
|
|
|
|
|
|
def _embed_all(provider, texts: list) -> list:
|
|
out = []
|
|
for start in range(0, len(texts), EMBED_BATCH):
|
|
out.extend(provider.embed(texts[start : start + EMBED_BATCH]))
|
|
return out
|
|
|
|
|
|
# -- graph ----------------------------------------------------------------
|
|
|
|
|
|
def _sync_graph(qdrant, store, cfg, harvest, contract, mode, provider, log) -> dict:
|
|
graph = harvest.graph
|
|
stored = store.get_meta("graph") or {}
|
|
notes = []
|
|
if graph is None:
|
|
if mode == "rebuild":
|
|
qdrant.drop_collection(cfg.graph_collection)
|
|
store.write_graph([], [])
|
|
store.set_meta("graph", {})
|
|
notes.append("graph: no graph.json was available; the graph collection is empty")
|
|
return {"nodes": 0, "edges": 0, "points": 0, "rebuilt": True, "notes": notes}
|
|
counts = store.graph_counts()
|
|
notes.append(
|
|
f"graph: graph.json was not available this run; kept the previous graph "
|
|
f"({counts['nodes']} nodes, {counts['edges']} edges)"
|
|
)
|
|
return {**counts, "points": counts["nodes"] + counts["edges"], "rebuilt": False, "notes": notes}
|
|
|
|
graph_contract = {
|
|
"digest": graph.digest,
|
|
"source_path": graph.source_path,
|
|
"provider": contract["provider"],
|
|
"model": contract["model"],
|
|
"dim": contract["dim"],
|
|
}
|
|
rebuild = mode == "rebuild" or stored != graph_contract
|
|
if not rebuild:
|
|
counts = store.graph_counts()
|
|
return {
|
|
**counts,
|
|
"points": counts["nodes"] + counts["edges"],
|
|
"rebuilt": False,
|
|
"notes": [f"graph: unchanged ({graph.digest[:12]}), not re-embedded"],
|
|
}
|
|
|
|
if not qdrant.collection_exists(cfg.graph_collection):
|
|
_ensure_collection(qdrant, cfg.graph_collection, cfg)
|
|
else:
|
|
qdrant.drop_collection(cfg.graph_collection)
|
|
qdrant.create_collection(cfg.graph_collection, cfg.dim, vectors_on_disk=cfg.vectors_on_disk)
|
|
|
|
rows = [("node", row) for row in graph.nodes] + [("edge", row) for row in graph.edges]
|
|
payloads_for_graph = [graph_module.point_payload(row, kind) for kind, row in rows]
|
|
vectors = _embed_all(provider, [payload["text"] for payload in payloads_for_graph])
|
|
points = [
|
|
{"id": row["point_id"], "vector": vector, "payload": payload}
|
|
for (_, row), vector, payload in zip(rows, vectors, payloads_for_graph)
|
|
]
|
|
sent = qdrant.upsert(cfg.graph_collection, points)
|
|
store.write_graph(graph.nodes, graph.edges)
|
|
store.set_meta("graph", graph_contract)
|
|
log(f"graph: rebuilt {len(graph.nodes)} node(s) and {len(graph.edges)} edge(s), {sent} point(s)")
|
|
return {
|
|
"nodes": len(graph.nodes),
|
|
"edges": len(graph.edges),
|
|
"points": sent,
|
|
"rebuilt": True,
|
|
"notes": notes,
|
|
}
|
|
|
|
|
|
# -- checks ---------------------------------------------------------------
|
|
|
|
|
|
def _scan_pool(store) -> None:
|
|
"""Search the finished pool for credential-shaped content. Fail, do not warn."""
|
|
hits = []
|
|
for source_path, chunk_index, text in store.pool_texts():
|
|
for kind, line, _excerpt in exclusions.scan_text(text):
|
|
hits.append((source_path, chunk_index, kind, line))
|
|
if hits:
|
|
detail = ", ".join(f"{path}#chunk-{index} ({kind} line {line})" for path, index, kind, line in hits[:10])
|
|
raise PipelineError(
|
|
f"credential-shaped content reached the pool in {len(hits)} place(s): {detail}. "
|
|
"The run is failed rather than published; fix the source or the exclusion rule."
|
|
)
|
|
|
|
|
|
def _scan_graph(store) -> None:
|
|
rows = store.db.execute("SELECT id, text FROM graph_nodes UNION ALL SELECT id, text FROM graph_edges")
|
|
hits = []
|
|
for row in rows:
|
|
for kind, line, _excerpt in exclusions.scan_text(row["text"]):
|
|
hits.append((row["id"], kind, line))
|
|
if hits:
|
|
detail = ", ".join(f"{node} ({kind} line {line})" for node, kind, line in hits[:10])
|
|
raise PipelineError(f"credential-shaped content reached the graph in {len(hits)} place(s): {detail}")
|
|
|
|
|
|
def _write_collection_file(cfg, store, stats: RunStats, harvest) -> None:
|
|
"""Mirror the contract and the last run to data/corpus/collection.json."""
|
|
body = {
|
|
"collection": cfg.collection,
|
|
"contract": _contract(cfg),
|
|
"qdrant_url": cfg.qdrant_url,
|
|
"chunks": store.chunk_count(),
|
|
"sources": store.source_count(),
|
|
"graph_collection": cfg.graph_collection,
|
|
"graph": store.graph_counts(),
|
|
"platforms": harvest.sources,
|
|
"rejections": harvest.rejection_summary(),
|
|
"images": len(harvest.images),
|
|
"last_run": store.get_meta("last_run"),
|
|
}
|
|
with open(cfg.meta_path, "w", encoding="utf-8") as handle:
|
|
json.dump(body, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
|
|
|
|
# -- verification and demonstration ---------------------------------------
|
|
|
|
|
|
def verify(cfg, log=None) -> list:
|
|
"""Return a list of problems. Empty means the index agrees with the sources."""
|
|
log = log or (lambda _msg: None)
|
|
problems = []
|
|
harvest = estate_module.harvest(cfg)
|
|
documents = harvest.documents
|
|
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
|
if not qdrant.health():
|
|
return [f"Qdrant is not answering at {cfg.qdrant_url}"]
|
|
|
|
contract = _contract(cfg)
|
|
with store_module.Store(cfg.db_path) as store:
|
|
stored = store.get_meta("collection")
|
|
if stored != contract:
|
|
problems.append(f"collection contract mismatch: stored={stored} config={contract}")
|
|
|
|
if not qdrant.collection_exists(cfg.collection):
|
|
problems.append(f"collection {cfg.collection!r} does not exist")
|
|
return problems
|
|
|
|
info = qdrant.collection_info(cfg.collection)
|
|
size = (((info.get("config") or {}).get("params") or {}).get("vectors") or {}).get("size")
|
|
if size is not None and int(size) != cfg.dim:
|
|
problems.append(f"collection vector size {size} != configured {cfg.dim}")
|
|
|
|
points_by_source = qdrant.scroll_sources(cfg.collection)
|
|
rows = store.chunk_payloads()
|
|
by_source = {}
|
|
for row in rows:
|
|
by_source.setdefault(row["source_path"], []).append(row)
|
|
try:
|
|
payloads.validate_payload(row, where=f"{row['source_path']}#chunk-{row['chunk_index']}")
|
|
except payloads.PayloadError as exc:
|
|
problems.append(str(exc))
|
|
|
|
on_disk = {doc.path: doc for doc in documents}
|
|
for doc in documents:
|
|
payload_rows = by_source.get(doc.path, [])
|
|
if not payload_rows:
|
|
problems.append(f"{doc.path}: no chunks in the index (run sync)")
|
|
continue
|
|
if payload_rows[0]["content_hash"] != doc.digest:
|
|
problems.append(f"{doc.path}: source changed since it was embedded")
|
|
expected = len(payload_rows)
|
|
actual = points_by_source.get(doc.path, 0)
|
|
if actual != expected:
|
|
problems.append(
|
|
f"{doc.path}: {expected} chunk row(s) but {actual} vector(s) in Qdrant"
|
|
)
|
|
|
|
for source in sorted(set(by_source) - set(on_disk)):
|
|
problems.append(f"{source}: indexed but no longer in content/ or the estate (run sync)")
|
|
|
|
total_points = sum(points_by_source.values())
|
|
if total_points != store.chunk_count():
|
|
problems.append(
|
|
f"{total_points} vector(s) in Qdrant but {store.chunk_count()} chunk(s) in SQLite"
|
|
)
|
|
|
|
# Graph: every row must resolve to a note, and Qdrant must agree.
|
|
counts = store.graph_counts()
|
|
graph_meta = store.get_meta("graph") or {}
|
|
if harvest.graph is not None:
|
|
if not graph_meta:
|
|
problems.append("graph: graph.json is available but the graph was never indexed")
|
|
if qdrant.collection_exists(cfg.graph_collection):
|
|
graph_points = qdrant.point_count(cfg.graph_collection)
|
|
if graph_points != counts["nodes"] + counts["edges"]:
|
|
problems.append(
|
|
f"graph: {graph_points} point(s) in Qdrant but "
|
|
f"{counts['nodes'] + counts['edges']} row(s) in SQLite"
|
|
)
|
|
elif counts["nodes"] or counts["edges"]:
|
|
problems.append(f"graph: collection {cfg.graph_collection!r} does not exist")
|
|
for row in store.db.execute("SELECT id, source_note, origin_url FROM graph_nodes LIMIT 5"):
|
|
if not row["source_note"] or not row["origin_url"]:
|
|
problems.append(f"graph node {row['id']}: no source note or origin")
|
|
missing_notes = [
|
|
row["id"]
|
|
for row in store.db.execute(
|
|
"SELECT id FROM graph_nodes WHERE source_note = '' OR origin_url = '' LIMIT 10"
|
|
)
|
|
]
|
|
if missing_notes:
|
|
problems.append(f"graph: {len(missing_notes)} node(s) carry no source note")
|
|
|
|
log(
|
|
f"verify: {store.chunk_count()} chunk(s) across {store.source_count()} source(s), "
|
|
f"{total_points} vector(s) in Qdrant, {counts['nodes']} node(s), {counts['edges']} edge(s)"
|
|
)
|
|
for warning in harvest.warnings:
|
|
problems.append(f"estate warning: {warning}")
|
|
return problems
|
|
|
|
|
|
def demo_area_filter(cfg, query: str, area: str = None, limit: int = 5) -> dict:
|
|
"""Same query with and without an `area` filter — the DoD's filter evidence."""
|
|
provider = embeddings.build_provider(cfg)
|
|
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
|
vector = provider.embed([query])[0]
|
|
unfiltered = qdrant.search(cfg.collection, vector, limit=limit)
|
|
filtered = qdrant.search(cfg.collection, vector, limit=limit, area=area) if area else []
|
|
return {
|
|
"query": query,
|
|
"area": area,
|
|
"unfiltered": [_hit(row) for row in unfiltered],
|
|
"filtered": [_hit(row) for row in filtered],
|
|
}
|
|
|
|
|
|
def _hit(row: dict) -> dict:
|
|
payload = row.get("payload") or {}
|
|
return {
|
|
"score": round(row["score"], 4),
|
|
"area": payload.get("area"),
|
|
"platform": payload.get("platform"),
|
|
"tier": payload.get("tier"),
|
|
"confidence": payload.get("confidence"),
|
|
"title": payload.get("title"),
|
|
"url": payload.get("url"),
|
|
"source_path": payload.get("source_path"),
|
|
"chunk_index": payload.get("chunk_index"),
|
|
"preview": (payload.get("text") or "").replace("\n", " ")[:120],
|
|
}
|
|
|
|
|
|
def hybrid_search(cfg, query: str, area: str = None, limit: int = 5, platform: str = None, tier: int = None) -> list:
|
|
"""Vectors + FTS5 fused with reciprocal rank fusion (decision D10).
|
|
|
|
Phase 5 owns the query graph; this is here because Phase 4 is the phase that
|
|
has to prove the two halves line up — same ids, same metadata, one ranking.
|
|
"""
|
|
provider = embeddings.build_provider(cfg)
|
|
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
|
vector = provider.embed([query])[0]
|
|
ranked = {}
|
|
for rank, hit in enumerate(
|
|
qdrant.search(cfg.collection, vector, limit=limit * 4, area=area, platform=platform, tier=tier)
|
|
):
|
|
entry = ranked.setdefault(hit["id"], {"id": hit["id"], "payload": hit["payload"], "score": 0.0})
|
|
entry["score"] += 1.0 / (60 + rank)
|
|
with store_module.Store(cfg.db_path) as store:
|
|
for rank, hit in enumerate(
|
|
store.keyword_search(query, area=area, platform=platform, tier=tier, limit=limit * 4)
|
|
):
|
|
entry = ranked.setdefault(hit["id"], {"id": hit["id"], "payload": {}, "score": 0.0})
|
|
entry.setdefault("keyword", hit["snippet"])
|
|
entry["score"] += 1.0 / (60 + rank)
|
|
ordered = sorted(ranked.values(), key=lambda row: row["score"], reverse=True)[:limit]
|
|
for row in ordered:
|
|
payload = row.get("payload") or {}
|
|
row["title"] = payload.get("title")
|
|
row["url"] = payload.get("url")
|
|
return ordered
|
|
|
|
|
|
def scan_pool(cfg) -> dict:
|
|
"""Run the credential scan without writing anything. Used by `make corpus-scan`."""
|
|
with store_module.Store(cfg.db_path) as store:
|
|
hits = []
|
|
texts = store.pool_texts()
|
|
for source_path, chunk_index, text in texts:
|
|
for kind, line, _excerpt in exclusions.scan_text(text):
|
|
hits.append(
|
|
{"source_path": source_path, "chunk": chunk_index, "kind": kind, "line": line}
|
|
)
|
|
try:
|
|
_scan_graph(store)
|
|
except PipelineError as exc:
|
|
hits.append({"source_path": "graph", "kind": "credential-shaped content", "detail": str(exc)})
|
|
chunks = len(texts)
|
|
return {"ok": not hits, "hits": hits, "chunks": chunks}
|