146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""One-shot dumper: extract a search-test seed from a corpus snapshot.
|
|
|
|
Reads the LanceDB tables under
|
|
``/tmp/everos_corpus_v2/.index/lancedb/`` (the snapshot produced by
|
|
``tests/e2e/test_add_flush_user_pipeline_e2e.py`` with ``EVEROS_KEEP_CORPUS_TO``
|
|
set), samples a small representative slice, and emits JSON fixtures
|
|
under ``tests/fixtures/search_seed/``.
|
|
|
|
Sampling rules:
|
|
|
|
- **episode + atomic_fact**: sampled together for fact↔episode
|
|
coherence. Facts link to their host episode via
|
|
``atomic_fact.parent_id == episode.entry_id`` (parent_type="episode";
|
|
see ``memory/strategies/extract_atomic_facts.py``). Episodes that host
|
|
facts are picked first (up to 8/owner) so hierarchical-eviction and the
|
|
fact-first paths (vector MaxSim, agentic) have a non-trivial,
|
|
multi-episode corpus; facts are then kept iff their host episode made
|
|
the cut (≤15/owner), guaranteeing every kept fact bridges back.
|
|
- **foresight**: 5 per owner. Archived for future use; current
|
|
``/search`` does not query foresight, so the seed only exists so
|
|
downstream tests can opt in without re-cutting the corpus.
|
|
- **user_profile**: 1 per owner (= 2 total).
|
|
|
|
Run::
|
|
|
|
python tests/fixtures/_dump_search_seed.py
|
|
|
|
Re-run any time the corpus changes; output JSON is committed to
|
|
git so other contributors don't need the corpus locally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import lancedb
|
|
|
|
CORPUS = Path("/tmp/everos_corpus_v2/.index/lancedb")
|
|
OUT_DIR = Path(__file__).parent / "search_seed"
|
|
ALL_OWNERS = ("caroline", "melanie")
|
|
|
|
|
|
def _serialise(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""Make a LanceDB row dict JSON-safe (numpy → list, datetime → ISO)."""
|
|
out: dict[str, Any] = {}
|
|
for k, v in row.items():
|
|
if v is None:
|
|
out[k] = None
|
|
elif hasattr(v, "tolist"): # numpy ndarray (vector)
|
|
out[k] = v.tolist()
|
|
elif isinstance(v, datetime):
|
|
out[k] = v.isoformat()
|
|
else:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def _read(db: lancedb.DBConnection, table: str) -> list[dict[str, Any]]:
|
|
if f"{table}.lance" not in {p.name for p in CORPUS.iterdir()}:
|
|
raise FileNotFoundError(f"corpus table missing: {table}")
|
|
return db.open_table(table).to_arrow().to_pylist()
|
|
|
|
|
|
def main() -> None:
|
|
if not CORPUS.exists():
|
|
print(f"corpus not found: {CORPUS}", file=sys.stderr)
|
|
print("hint: run the add+flush pipeline first with", file=sys.stderr)
|
|
print(" EVEROS_KEEP_CORPUS_TO=/tmp/everos_corpus_v2", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
db = lancedb.connect(str(CORPUS))
|
|
|
|
# 1) episodes + 2) atomic_facts — sampled together so the slice is
|
|
# fact↔episode coherent AND rich (facts spread across several
|
|
# episodes, not piled on one).
|
|
#
|
|
# Current extraction links a fact to its host episode via
|
|
# ``atomic_fact.parent_id == episode.entry_id`` (parent_type="episode";
|
|
# see ``memory/strategies/extract_atomic_facts.py``). The fact-first
|
|
# search paths (vector MaxSim, agentic) fetch episodes by that
|
|
# entry_id, so the seed must preserve that linkage. Sample the
|
|
# episodes that actually HAVE facts first (up to 8/owner), so the
|
|
# agentic LLM-sufficiency step and hierarchical eviction have a
|
|
# non-trivial, multi-episode corpus to work with; only then fall back
|
|
# to fact-less episodes to top up owner coverage.
|
|
eps_all = _read(db, "episode")
|
|
afs_all = _read(db, "atomic_fact")
|
|
|
|
eps: list[dict[str, Any]] = []
|
|
afs: list[dict[str, Any]] = []
|
|
for owner in ALL_OWNERS:
|
|
owner_eps = [r for r in eps_all if r["owner_id"] == owner]
|
|
owner_facts = [r for r in afs_all if r["owner_id"] == owner]
|
|
facts_by_ep: dict[str, list[dict[str, Any]]] = {}
|
|
for f in owner_facts:
|
|
facts_by_ep.setdefault(f["parent_id"], []).append(f)
|
|
# Episodes that host facts come first (richest bridges), then the
|
|
# rest — capped at 8 to keep the seed compact.
|
|
with_facts = [e for e in owner_eps if e["entry_id"] in facts_by_ep]
|
|
without_facts = [e for e in owner_eps if e["entry_id"] not in facts_by_ep]
|
|
chosen = (with_facts + without_facts)[:8]
|
|
eps.extend(chosen)
|
|
# Spread facts across episodes (<=3 per host) so several episodes are
|
|
# bridged, not one — richer corpus for agentic + hierarchical
|
|
# eviction. Every kept fact bridges back via its host entry_id.
|
|
owner_afs: list[dict[str, Any]] = []
|
|
for e in chosen:
|
|
owner_afs.extend(facts_by_ep.get(e["entry_id"], [])[:3])
|
|
afs.extend(owner_afs[:15])
|
|
|
|
# 3) foresights — 5 per owner, archived for future use.
|
|
fss_all = _read(db, "foresight")
|
|
fss: list[dict[str, Any]] = []
|
|
for owner in ALL_OWNERS:
|
|
fss.extend([r for r in fss_all if r["owner_id"] == owner][:5])
|
|
|
|
# 4) user_profile — 1 per owner.
|
|
ups_all = _read(db, "user_profile")
|
|
ups = [r for r in ups_all if r["owner_id"] in ALL_OWNERS]
|
|
|
|
written: list[tuple[str, int, int]] = []
|
|
for name, rows in (
|
|
("episode", eps),
|
|
("atomic_fact", afs),
|
|
("foresight", fss),
|
|
("user_profile", ups),
|
|
):
|
|
serialised = [_serialise(r) for r in rows]
|
|
out = OUT_DIR / f"{name}.json"
|
|
out.write_text(json.dumps(serialised, indent=2, default=str))
|
|
written.append((name, len(serialised), out.stat().st_size))
|
|
|
|
for name, count, size in written:
|
|
print(f" {name:14s}: {count:3d} rows ({size // 1024} KB)")
|
|
bridged = len({f["parent_id"] for f in afs})
|
|
print(f" fact-bridged episodes: {bridged}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|