377 lines
15 KiB
Python
377 lines
15 KiB
Python
"""Command line entry point: `python3 -m corpus.cli <command>`.
|
|
|
|
Commands
|
|
|
|
sync incremental update of the corpus (the normal command)
|
|
rebuild drop the collection and rebuild it from content/ (recovery)
|
|
audit freshness audit; reports stale documents, index drift
|
|
verify does the index agree with content/ and with Qdrant?
|
|
demo-filter the same query with and without an `area` filter
|
|
hybrid vectors + FTS5 fused with reciprocal rank fusion
|
|
measure Qdrant memory at 500 / 5,000 / 20,000 chunks
|
|
info what this build thinks it is configured to do
|
|
|
|
Exit codes: 0 success, 1 findings or verification problems, 2 configuration or
|
|
usage error. Every command accepts `--json` where a machine-readable result is
|
|
useful (Prefect reads JSON, a human reads the table).
|
|
|
|
Configuration is environment-first (`corpus/config.py`); the flags exist so a
|
|
one-off run can point at a scratch Qdrant without exporting variables.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from . import config as config_module
|
|
from . import freshness, guides, pipeline, store as store_module
|
|
from .config import Config, ConfigError
|
|
from .qdrant import Qdrant, QdrantError
|
|
from .sources import graph as graph_module
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
if args.command is None:
|
|
parser.print_help()
|
|
return 2
|
|
|
|
overrides = {
|
|
"root": args.root,
|
|
"qdrant_url": args.qdrant_url,
|
|
"collection": args.collection,
|
|
"provider": args.provider,
|
|
"model": args.model,
|
|
"dim": args.dim,
|
|
"db_path": args.db,
|
|
"data_dir": args.data,
|
|
"offline": True if args.offline else None,
|
|
"platforms": tuple(part.strip() for part in args.platforms.split(",") if part.strip())
|
|
if args.platforms
|
|
else None,
|
|
"graph_path": args.graph_path or None,
|
|
}
|
|
try:
|
|
cfg = Config.from_env(**overrides)
|
|
except ConfigError as exc:
|
|
print(f"configuration error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
log = (lambda _msg: None) if args.quiet else (lambda msg: print(msg, file=sys.stderr))
|
|
|
|
try:
|
|
handler = {
|
|
"sync": _cmd_sync,
|
|
"rebuild": _cmd_rebuild,
|
|
"audit": _cmd_audit,
|
|
"verify": _cmd_verify,
|
|
"demo-filter": _cmd_demo_filter,
|
|
"hybrid": _cmd_hybrid,
|
|
"measure": _cmd_measure,
|
|
"info": _cmd_info,
|
|
"graph": _cmd_graph,
|
|
"scan": _cmd_scan,
|
|
"sources": _cmd_sources,
|
|
}[args.command]
|
|
return handler(cfg, args, log)
|
|
except (pipeline.PipelineError, ConfigError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
except QdrantError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="python3 -m corpus.cli",
|
|
description="Build and audit the Ai Resume retrieval corpus (docs/phase-4-corpus.md).",
|
|
)
|
|
parser.add_argument("--root", help="repository root (default: parent of corpus/)")
|
|
parser.add_argument("--qdrant-url", help="Qdrant REST URL (default http://127.0.0.1:6333)")
|
|
parser.add_argument("--collection", help="Qdrant collection name (default resume_corpus)")
|
|
parser.add_argument("--provider", choices=sorted(config_module.PROVIDERS), help="embedding provider")
|
|
parser.add_argument("--model", help="embedding model name")
|
|
parser.add_argument("--dim", type=int, help="embedding dimension")
|
|
parser.add_argument("--db", help="SQLite corpus database path")
|
|
parser.add_argument("--data", help="corpus data directory")
|
|
parser.add_argument("--quiet", action="store_true", help="progress on stderr only becomes silent")
|
|
parser.add_argument("--offline", action="store_true", help="read content/ only; do not harvest the estate")
|
|
parser.add_argument("--platforms", help="comma-separated platforms to harvest (default: all enabled)")
|
|
parser.add_argument("--graph-path", help="read graph.json from this path instead of Gitea")
|
|
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
sync = sub.add_parser("sync", help="incremental update")
|
|
sync.add_argument("--json", action="store_true")
|
|
|
|
rebuild = sub.add_parser("rebuild", help="drop the collection and rebuild from content/")
|
|
rebuild.add_argument("--json", action="store_true")
|
|
|
|
audit = sub.add_parser("audit", help="freshness and drift audit")
|
|
audit.add_argument("--json", action="store_true")
|
|
audit.add_argument("--today", help="audit as of this date (YYYY-MM-DD), for tests")
|
|
audit.add_argument("--fail-on-findings", action="store_true", help="exit 1 when anything is found")
|
|
audit.add_argument("--alert", action="store_true", help="POST the report to RESUME_APPRISE_URL")
|
|
|
|
verify = sub.add_parser("verify", help="check the index against content/ and Qdrant")
|
|
verify.add_argument("--json", action="store_true")
|
|
|
|
demo = sub.add_parser("demo-filter", help="same query with and without an area filter")
|
|
demo.add_argument("--query", required=True)
|
|
demo.add_argument("--area", help="area to filter on (ai, platform, edge, products, history, about)")
|
|
demo.add_argument("--limit", type=int, default=5)
|
|
demo.add_argument("--json", action="store_true")
|
|
|
|
hybrid = sub.add_parser("hybrid", help="vectors + FTS5 fused with RRF")
|
|
hybrid.add_argument("--query", required=True)
|
|
hybrid.add_argument("--area")
|
|
hybrid.add_argument("--limit", type=int, default=5)
|
|
hybrid.add_argument("--json", action="store_true")
|
|
|
|
measure = sub.add_parser("measure", help="Qdrant memory at three corpus sizes")
|
|
measure.add_argument("--sizes", default="500,5000,20000")
|
|
measure.add_argument("--container", default="", help="Qdrant container name for docker stats")
|
|
measure.add_argument("--keep", action="store_true", help="keep the measurement collection")
|
|
measure.add_argument("--json", action="store_true")
|
|
|
|
graph = sub.add_parser("graph", help="query graph nodes and edges by keyword or vector")
|
|
graph.add_argument("--query", required=True)
|
|
graph.add_argument("--area")
|
|
graph.add_argument("--limit", type=int, default=10)
|
|
graph.add_argument("--json", action="store_true")
|
|
|
|
neighbours = sub.add_parser("graph-neighbours", help="edges attached to a graph node id")
|
|
neighbours.add_argument("--node", required=True)
|
|
neighbours.add_argument("--limit", type=int, default=50)
|
|
neighbours.add_argument("--json", action="store_true")
|
|
|
|
scan = sub.add_parser("scan", help="search the finished pool for credential-shaped content")
|
|
scan.add_argument("--json", action="store_true")
|
|
|
|
sources = sub.add_parser("sources", help="show the platform guides and the last harvest")
|
|
sources.add_argument("--json", action="store_true")
|
|
|
|
sub.add_parser("info", help="print the resolved configuration")
|
|
return parser
|
|
|
|
|
|
# -- commands --------------------------------------------------------------
|
|
|
|
|
|
def _cmd_sync(cfg, args, log) -> int:
|
|
stats = pipeline.run(cfg, mode="sync", log=log)
|
|
if args.json:
|
|
print(json.dumps(stats.to_dict(), indent=2, sort_keys=True))
|
|
else:
|
|
print(stats.summary())
|
|
return 0
|
|
|
|
|
|
def _cmd_rebuild(cfg, args, log) -> int:
|
|
stats = pipeline.run(cfg, mode="rebuild", log=log)
|
|
if args.json:
|
|
print(json.dumps(stats.to_dict(), indent=2, sort_keys=True))
|
|
else:
|
|
print(stats.summary())
|
|
return 0
|
|
|
|
|
|
def _cmd_audit(cfg, args, log) -> int:
|
|
today = datetime.date.fromisoformat(args.today) if args.today else None
|
|
report = freshness.audit(cfg, today=today, log=log)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
else:
|
|
print(freshness.render_text(report))
|
|
if args.alert:
|
|
try:
|
|
sent = freshness.alert(report, cfg.apprise_url)
|
|
if not sent:
|
|
log("alert: RESUME_APPRISE_URL is not set; nothing sent")
|
|
except Exception as exc: # noqa: BLE001 - an alert failure must not hide the audit
|
|
print(f"warning: could not send the Apprise alert: {exc}", file=sys.stderr)
|
|
if args.fail_on_findings and not report["ok"]:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _cmd_verify(cfg, args, log) -> int:
|
|
problems = pipeline.verify(cfg, log=log)
|
|
if args.json:
|
|
print(json.dumps({"ok": not problems, "problems": problems}, indent=2))
|
|
else:
|
|
if problems:
|
|
print(f"FAIL: {len(problems)} problem(s)")
|
|
for problem in problems:
|
|
print(f" - {problem}")
|
|
else:
|
|
print("OK: the index agrees with content/ and Qdrant")
|
|
return 1 if problems else 0
|
|
|
|
|
|
def _cmd_demo_filter(cfg, args, log) -> int:
|
|
result = pipeline.demo_area_filter(cfg, args.query, area=args.area, limit=args.limit)
|
|
if args.json:
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
return 0
|
|
print(f"query: {result['query']!r}")
|
|
print("")
|
|
print(f"without a filter ({len(result['unfiltered'])} result(s)):")
|
|
for row in result["unfiltered"]:
|
|
print(f" {row['score']:.3f} [{row['area']}/{row['confidence']}] {row['title']} {row['url']}")
|
|
print("")
|
|
if not args.area:
|
|
print("no --area given, so the filtered set is empty; re-run with e.g. --area edge")
|
|
return 0
|
|
print(f"with area={args.area!r} ({len(result['filtered'])} result(s)):")
|
|
for row in result["filtered"]:
|
|
print(f" {row['score']:.3f} [{row['area']}/{row['confidence']}] {row['title']} {row['url']}")
|
|
wrong = [row for row in result["filtered"] if row["area"] != args.area]
|
|
if wrong:
|
|
print(f"FAIL: {len(wrong)} filtered result(s) are not in area {args.area!r}")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _cmd_hybrid(cfg, args, log) -> int:
|
|
rows = pipeline.hybrid_search(cfg, args.query, area=args.area, limit=args.limit)
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2, sort_keys=True, default=str))
|
|
return 0
|
|
print(f"hybrid (RRF) query={args.query!r} area={args.area!r}: {len(rows)} result(s)")
|
|
for row in rows:
|
|
print(f" {row['score']:.4f} {row.get('title')} {row.get('url')} [{row['id'][:8]}]")
|
|
return 0
|
|
|
|
|
|
def _cmd_measure(cfg, args, log) -> int:
|
|
from . import measure as measure_module
|
|
|
|
sizes = [int(part) for part in args.sizes.split(",") if part.strip()]
|
|
report = measure_module.measure(
|
|
cfg, sizes=sizes, container=args.container, keep=args.keep, log=log
|
|
)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
return 0
|
|
print(measure_module.render_text(report))
|
|
return 0
|
|
|
|
|
|
def _cmd_graph(cfg, args, log) -> int:
|
|
with store_module.Store(cfg.db_path) as store:
|
|
report = graph_module.query(cfg, store, args.query, area=args.area, limit=args.limit)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, sort_keys=True, default=str))
|
|
return 0
|
|
print(f"graph query {args.query!r} area={args.area!r}")
|
|
print("")
|
|
print(f"keyword ({len(report['keyword'])} row(s)):")
|
|
for row in report["keyword"]:
|
|
kind = row.get("kind")
|
|
print(f" [{kind}] {row.get('label')} -> {row.get('source_note')} {row.get('origin_url')}")
|
|
print("")
|
|
print(f"vector ({len(report['vector'])} row(s)):")
|
|
for row in report["vector"]:
|
|
print(f" {row['score']:.3f} [{row['kind']}] {row['label']} -> {row['source_note']}")
|
|
if report.get("vector_note"):
|
|
print(f" note: {report['vector_note']}")
|
|
return 0 if report["ok"] else 1
|
|
|
|
|
|
def _cmd_graph_neighbours(cfg, args, log) -> int:
|
|
with store_module.Store(cfg.db_path) as store:
|
|
node = store.graph_node(args.node)
|
|
edges = store.graph_neighbours(args.node, limit=args.limit)
|
|
if args.json:
|
|
print(json.dumps({"node": node, "edges": edges}, indent=2, sort_keys=True, default=str))
|
|
return 0
|
|
if not node:
|
|
print(f"no graph node with id {args.node!r}")
|
|
return 1
|
|
print(f"{node.get('label')} ({node.get('node_type')})")
|
|
print(f" note: {node.get('source_note')}")
|
|
print(f" origin: {node.get('origin_url')}")
|
|
for edge in edges:
|
|
print(
|
|
f" {edge.get('source_label')} --{edge.get('relation')}--> {edge.get('target_label')}"
|
|
f" [{edge.get('source_note')}]"
|
|
)
|
|
return 0
|
|
|
|
|
|
def _cmd_scan(cfg, args, log) -> int:
|
|
report = pipeline.scan_pool(cfg)
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
else:
|
|
print(
|
|
f"scanned {report['chunks']} chunk(s): "
|
|
+ ("clean" if report["ok"] else f"{len(report['hits'])} hit(s)")
|
|
)
|
|
for hit in report["hits"]:
|
|
print(f" {hit}")
|
|
return 0 if report["ok"] else 1
|
|
|
|
|
|
def _cmd_sources(cfg, args, log) -> int:
|
|
loaded = guides.load_guides(cfg.root)
|
|
public = guides.build_public_manifest(loaded)
|
|
last = {}
|
|
if os.path.exists(cfg.db_path):
|
|
with store_module.Store(cfg.db_path) as store:
|
|
last = store.get_meta("sources") or {}
|
|
if args.json:
|
|
print(json.dumps({"guides": public, "last_harvest": last}, indent=2, sort_keys=True))
|
|
return 0
|
|
for row in public["platforms"]:
|
|
state = "enabled" if row["enabled"] else "disabled"
|
|
print(f"{row['name']} ({state}) — {row['what']}")
|
|
print(f" base: {row['base']} fits: {', '.join(row['fits'])} read: {', '.join(row['read'])}")
|
|
print(f" include: {json.dumps(row['include'])}")
|
|
print(f" exclude: {json.dumps(row['exclude'])}")
|
|
seen = last.get(row["name"]) or {}
|
|
if seen:
|
|
print(
|
|
f" last harvest: {seen.get('documents', 0)} document(s), "
|
|
f"newest {seen.get('newest') or '—'}"
|
|
)
|
|
print("")
|
|
return 0
|
|
|
|
|
|
def _cmd_info(cfg, args, log) -> int:
|
|
print(cfg.describe())
|
|
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
|
print("")
|
|
print(f"qdrant: {'answering, version ' + qdrant.version() if qdrant.health() else 'NOT answering'}")
|
|
if qdrant.collection_exists(cfg.collection):
|
|
print(f"collection {cfg.collection}: {qdrant.point_count(cfg.collection)} point(s)")
|
|
else:
|
|
print(f"collection {cfg.collection}: does not exist")
|
|
if os.path.exists(cfg.db_path):
|
|
with store_module.Store(cfg.db_path) as store:
|
|
print(f"store: {store.chunk_count()} chunk(s) across {store.source_count()} source(s)")
|
|
print(f"contract: {json.dumps(store.get_meta('collection'), sort_keys=True)}")
|
|
print(f"graph: {json.dumps(store.graph_counts(), sort_keys=True)}")
|
|
sources = store.get_meta("sources") or {}
|
|
for name in sorted(sources):
|
|
row = sources[name]
|
|
print(
|
|
f"source {name}: {row.get('documents', 0)} document(s), "
|
|
f"newest {row.get('newest') or '—'}"
|
|
)
|
|
else:
|
|
print(f"store: {cfg.db_path} does not exist")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|