Files
ai-resume/corpus/tests/fake_qdrant.py
Sam Rolfe f9dc574172 Phase 4: corpus and retrieval — chunks, Qdrant, FTS5, freshness audit
Satisfies docs/phase-4-corpus.md. content/ stays the single source of truth:
one pass over it writes both indexes, so the static pages and the retrieval
corpus cannot drift.

- corpus/: stdlib-only pipeline. validate -> chunk -> enrich -> embed -> upsert.
  Chunking is block-based, so a boundary cannot fall inside a fenced block, a
  table or front-matter (verified structurally, not by inspection).
- Deterministic ids: sha256(source_path + chunk_index + content_hash), rendered
  as a UUID. Re-running updates points; re-chunking deletes the old ones by
  source filter, so duplicates and orphans are both impossible.
- Incremental by content hash against a SQLite manifest. A provider, dimension
  or chunker change is refused instead of mixed: it needs a rebuild.
- Payloads per section 4.2 plus provenance, validated before any vector is
  written; a missing field fails the run and names the document and chunk.
- SQLite FTS5 over chunks (the keyword half of hybrid retrieval, decision D10)
  with area/confidence filters and a `terms` column so front-matter tech and
  tags are findable.
- Freshness audit: stale documents, due soon, index drift, contract drift;
  optional Apprise alert; exit code for Prefect.
- Embeddings behind one interface: omniroute (OpenAI-compatible, retries, key
  from the environment only) and local-hashing (offline placeholder). The
  provider decision is still Sam's; the pipeline does not depend on it.
- deploy/qdrant.yml: mem_limit = memswap_limit = 400m, loopback only.
- docs/phase-4-notes.md: operator manual, DoD evidence, measured memory.
- 76 unit tests, no container and no network.

Measured, Qdrant 1.20.0 under a 400 m cap at 1024 dimensions: 500 / 5,000 /
20,000 chunks settle at 128 / 138 / 151 MiB, peaking at 149 / 305 / 292 MiB
during ingest. Qdrant's default settings are OOM-killed between 5,000 and
20,000, so the collection is created with HNSW and payloads on disk; recorded in
reference/stack.md with the ceiling and the escalation order.

Note: the `make corpus*` targets were swept into the Phase 3 deploy commit
24590cc by concurrent work in this tree; the Makefile content is correct, and
this commit contains the pipeline they call.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 21:04:52 +11:00

113 lines
4.5 KiB
Python

"""An in-memory stand-in for Qdrant, used by the pipeline tests.
It implements the routes the pipeline actually calls and nothing else, and it is
deliberately dumb: brute-force cosine over a dict. If the pipeline passes
against this, the pipeline's own logic is right; the real server is exercised
separately in `docs/phase-4-notes.md` (rebuild against a live container).
"""
from __future__ import annotations
import math
from corpus.qdrant import Qdrant, QdrantError, QdrantNotFound
class FakeQdrant(Qdrant):
def __init__(self, dim: int):
super().__init__(url="fake://qdrant", timeout=1.0, attempts=1)
self.dim = dim
self.collections = {} # name -> {id: {"vector": [...], "payload": {...}}}
self.calls = []
# -- transport --------------------------------------------------------
def _request(self, method: str, path: str, body=None) -> dict:
self.calls.append((method, path, body))
query = ""
if "?" in path:
path, query = path.split("?", 1)
parts = [part for part in path.split("/") if part]
if not parts:
return {"version": "fake-1.0"}
if parts[0] != "collections":
raise QdrantNotFound(f"no route {path}")
if len(parts) == 2:
name = parts[1]
if method == "PUT":
self.collections.setdefault(name, {})
return {"result": True}
if method == "DELETE":
if name not in self.collections:
raise QdrantNotFound("collection not found")
del self.collections[name]
return {"result": True}
if name not in self.collections:
raise QdrantNotFound("collection not found")
return {
"result": {
"points_count": len(self.collections[name]),
"config": {"params": {"vectors": {"size": self.dim, "distance": "Cosine"}}},
}
}
name = parts[1]
if name not in self.collections:
raise QdrantNotFound("collection not found")
points = self.collections[name]
if parts[2:4] == ["points", "count"]:
return {"result": {"count": len(points)}}
if len(parts) == 3 and parts[2] == "points" and method == "PUT":
for point in (body or {}).get("points", []):
if len(point["vector"]) != self.dim:
raise QdrantError("wrong vector dimension")
points[point["id"]] = {"vector": point["vector"], "payload": point["payload"]}
return {"result": {"status": "completed"}}
if parts[2:4] == ["points", "delete"]:
must = (((body or {}).get("filter") or {}).get("must")) or []
for point_id in list(points):
if _matches(points[point_id]["payload"], must):
del points[point_id]
return {"result": {"status": "completed"}}
if parts[2:4] == ["points", "query"]:
vector = body["query"]
must = (((body or {}).get("filter") or {}).get("must")) or []
scored = []
for point_id, point in points.items():
if not _matches(point["payload"], must):
continue
scored.append(
{
"id": point_id,
"score": _cosine(vector, point["vector"]),
"payload": point["payload"],
}
)
scored.sort(key=lambda row: row["score"], reverse=True)
return {"result": {"points": scored[: int(body.get("limit", 10))]}}
if parts[2:4] == ["points", "scroll"]:
limit = int((body or {}).get("limit", 100))
rows = [
{"id": point_id, "payload": {"source_path": point["payload"].get("source_path")}}
for point_id, point in list(points.items())[:limit]
]
return {"result": {"points": rows, "next_page_offset": None}}
raise QdrantNotFound(f"no route {path}")
def _matches(payload: dict, conditions: list) -> bool:
for condition in conditions:
key = condition.get("key")
expected = (condition.get("match") or {}).get("value")
if payload.get(key) != expected:
return False
return True
def _cosine(left: list, right: list) -> float:
dot = sum(a * b for a, b in zip(left, right))
norm = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
return dot / norm if norm else 0.0