docq 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docq/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """docq — structured lexical retrieval from documents.
2
+
3
+ Turn a source text into a portable, cited, principle-anchored corpus that an
4
+ agent can query for the relevant primary-source passages given a situation.
5
+
6
+ This package is the corpus-agnostic *engine*. A specific corpus (e.g. APOSD) is
7
+ an *instance* configured under ``corpora/<name>/`` (parse profile, taxonomy,
8
+ enrichment prompt, eval cases). See ``docs/superpowers/specs/`` for the design.
9
+ """
10
+
11
+ __version__ = "0.1.0"
docq/build.py ADDED
@@ -0,0 +1,127 @@
1
+ """Wire parse -> segment -> enrich -> store for one chapter (or the whole book).
2
+
3
+ Loads the corpus instance (profile, taxonomy, prompt) from corpora/<name>/ and
4
+ sizes num_ctx from the actual prompts (conservative chars//3 + headroom, capped
5
+ and warned) rather than guessing a constant.
6
+ """
7
+ from __future__ import annotations
8
+ import importlib.util
9
+ from pathlib import Path
10
+
11
+ from .parse import parse_pdf
12
+ from .segment import segment, split_chapters
13
+ from .enrich import build_prompt, enrich_units
14
+ from .extract import OllamaExtractor
15
+ from .taxonomy import load_taxonomy, principle_for_chapter, card_for
16
+
17
+ def load_profile(instance: Path):
18
+ """Import the corpus instance's Profile (its module-level ``APOSD``)."""
19
+ spec = importlib.util.spec_from_file_location("_docq_instance_profile", Path(instance) / "profile.py")
20
+ module = importlib.util.module_from_spec(spec)
21
+ spec.loader.exec_module(module)
22
+ return module.APOSD
23
+
24
+
25
+ def load_prompt(instance: Path, filename: str = "prompt.md") -> tuple[str, str]:
26
+ """Split an instance prompt file into (system, user_template) on the TEMPLATE marker."""
27
+ text = (Path(instance) / filename).read_text()
28
+ system, template = text.split("<!-- TEMPLATE -->", 1)
29
+ return system.replace("<!-- SYSTEM -->", "").strip(), template.strip()
30
+
31
+
32
+ def estimate_num_ctx(prompts: list[str], system: str,
33
+ headroom: int = 2048, floor: int = 8192, cap: int = 32768) -> int:
34
+ """Size num_ctx from real prompts. chars//3 deliberately OVER-estimates tokens
35
+ (undercounting would truncate); warn rather than silently exceed the cap."""
36
+ longest = max((len(system) + len(p) for p in prompts), default=0)
37
+ need = longest // 3 + headroom
38
+ if need > cap:
39
+ print(f"WARNING: largest prompt ~{need} est tokens exceeds num_ctx cap {cap}; "
40
+ f"trim situating context or raise the cap")
41
+ return max(floor, min(need, cap))
42
+
43
+
44
+ def run_build(chapter, model, db, resume, instance: Path,
45
+ extractor=None, build_dir: Path = Path("build"), workers: int = 1):
46
+ """Build the corpus db: one chapter (``chapter`` set) or the whole book (``chapter`` None).
47
+
48
+ Chapters are detected via ``profile.chapter_re`` (or taken from ``profile.chapter_pages``
49
+ when that override is set). Each chapter is enriched with its own principle card; appendix
50
+ ranges are indexed with no card. All rows accumulate into one db.
51
+
52
+ Args:
53
+ chapter: Chapter id to build, or None for the whole book + appendices.
54
+ model: Ollama model tag for enrichment (ignored when ``extractor`` is given).
55
+ db: Output db path.
56
+ resume: Keep existing per-chapter checkpoints instead of wiping them.
57
+ instance: Corpus instance dir (profile/taxonomy/prompt).
58
+ extractor: Optional pre-built StructuredExtractor (tests inject a stub); when None,
59
+ one ``OllamaExtractor`` is built and reused across all chapters.
60
+ build_dir: Root for per-chapter JSONL checkpoints.
61
+ workers: Concurrent enrichment requests per chapter (1 = serial).
62
+
63
+ Returns:
64
+ All enrichment rows across every built chapter.
65
+ """
66
+ profile = load_profile(instance)
67
+ taxonomy = load_taxonomy(Path(instance) / "taxonomy.yaml")
68
+ system, template = load_prompt(instance)
69
+
70
+ # 1) Resolve per-chapter element spans: explicit override, else dynamic detection.
71
+ if profile.chapter_pages:
72
+ specs = [(cid, parse_pdf(profile.corpus_path, first, last, profile))
73
+ for cid, (first, last) in profile.chapter_pages.items()]
74
+ else:
75
+ whole = parse_pdf(profile.corpus_path, None, None, profile)
76
+ specs = split_chapters(whole, profile)
77
+ if chapter is None:
78
+ specs += [(aid, parse_pdf(profile.corpus_path, first, last, profile))
79
+ for aid, (first, last) in profile.appendices.items()]
80
+ else:
81
+ specs = [(cid, els) for cid, els in specs if cid == chapter]
82
+ if not specs:
83
+ raise SystemExit(f"chapter {chapter!r} not found by detection/override")
84
+
85
+ # 2) Segment each span + build prompts; size num_ctx once over the whole build.
86
+ plans = [] # (chapter_id, units, section_texts, card, principle)
87
+ all_prompts: list[str] = []
88
+ for cid, els in specs:
89
+ units, section_texts = segment(els, profile, cid)
90
+ principle = principle_for_chapter(taxonomy, cid)
91
+ card = card_for(taxonomy, principle) if principle else ""
92
+ plans.append((cid, units, section_texts, card, principle))
93
+ all_prompts += [build_prompt(u, section_texts.get(u.section, ""), card, template)
94
+ for u in units]
95
+
96
+ num_ctx = estimate_num_ctx(all_prompts, system)
97
+ total = sum(len(units) for _, units, _, _, _ in plans)
98
+ print(f"chapters={len(plans)} units={total} num_ctx={num_ctx} model={model}")
99
+
100
+ # 3) One extractor for the whole build (method probed/pinned once); enrich + accumulate.
101
+ if extractor is None:
102
+ extractor = OllamaExtractor(model, num_ctx=num_ctx)
103
+ all_rows: list[dict] = []
104
+ for cid, units, section_texts, card, principle in plans:
105
+ checkpoint = Path(build_dir) / f"ch{cid}" / "units.jsonl"
106
+ if not resume and checkpoint.exists():
107
+ checkpoint.unlink()
108
+ rows = enrich_units(units, section_texts, extractor, card=card, template=template,
109
+ system=system, checkpoint=checkpoint, max_workers=workers)
110
+ # The coarse principle is a chapter attribute from the taxonomy, not an LLM
111
+ # classification: overriding here keeps the facet a closed set (the 6 slugs or
112
+ # empty) and stops null-principle chapters from inventing their own slugs. Applied
113
+ # to read-back rows too, so a --resume regenerates the db correctly with no re-enrichment.
114
+ for row in rows:
115
+ row["principle"] = principle or ""
116
+ failed = sum(r["needs_enrich"] for r in rows)
117
+ print(f" ch{cid}: {len(rows)} units ({failed} failed) principle={principle or 'null'}")
118
+ all_rows += rows
119
+
120
+ failed = sum(r["needs_enrich"] for r in all_rows)
121
+ if failed:
122
+ print(f"WARNING: {failed}/{len(all_rows)} units failed enrichment — does model "
123
+ f"{model!r} support structured output?")
124
+ from .store import build_db
125
+ build_db(all_rows, Path(db))
126
+ print(f"built {len(all_rows)} units ({failed} enrichment failures) -> {db}")
127
+ return all_rows
docq/cli.py ADDED
@@ -0,0 +1,404 @@
1
+ """Command-line interface for docq.
2
+
3
+ ``retrieve`` is the query-time path and depends only on the stdlib store. ``build``
4
+ and ``eval`` lazily import the build-only modules so ``retrieve`` never pulls them in.
5
+ """
6
+ from __future__ import annotations
7
+ import argparse
8
+ from copy import deepcopy
9
+ import json
10
+ from pathlib import Path
11
+ import sys
12
+
13
+ from .config import ConfigError, LoadedConfig, load_config
14
+ from .rerank import OllamaUnavailable
15
+ from .store import get_unit, search
16
+
17
+
18
+ _DEFAULTS = {
19
+ "db": None,
20
+ "ollama-url": "http://localhost:11434",
21
+ "retrieve": {
22
+ "k": 5,
23
+ "mode": "auto",
24
+ "rerank": False,
25
+ "rerank-model": "gemma4:e2b",
26
+ "rerank-prompt": None,
27
+ },
28
+ "embed": {"model": "embeddinggemma:latest", "batch": 32},
29
+ "build": {
30
+ "instance": None,
31
+ "model": "minimax-m3:cloud",
32
+ "workers": 1,
33
+ "build-dir": "build",
34
+ },
35
+ "enrich-questions": {
36
+ "instance": None,
37
+ "model": "minimax-m3:cloud",
38
+ "workers": 1,
39
+ "build-dir": None,
40
+ },
41
+ "eval": {
42
+ "cases": None,
43
+ "k": 5,
44
+ "mode": "lexical",
45
+ "vs": None,
46
+ "rerank": False,
47
+ "rerank-model": "gemma4:e2b",
48
+ "rerank-prompt": None,
49
+ },
50
+ }
51
+
52
+
53
+ def _merge(target: dict, incoming: dict) -> None:
54
+ for key, value in incoming.items():
55
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
56
+ _merge(target[key], value)
57
+ else:
58
+ target[key] = value
59
+
60
+
61
+ def _effective(values: dict) -> dict:
62
+ result = deepcopy(_DEFAULTS)
63
+ _merge(result, values)
64
+ return result
65
+
66
+
67
+ def _extract_config(argv: list[str]) -> tuple[list[str], str | None]:
68
+ """Remove one --config option wherever it appears in argv."""
69
+ clean: list[str] = []
70
+ explicit = None
71
+ index = 0
72
+ while index < len(argv):
73
+ item = argv[index]
74
+ if item == "--config":
75
+ if explicit is not None:
76
+ raise ConfigError("--config may only be passed once")
77
+ if index + 1 == len(argv):
78
+ raise ConfigError("--config requires a path")
79
+ explicit = argv[index + 1]
80
+ index += 2
81
+ continue
82
+ if item.startswith("--config="):
83
+ if explicit is not None:
84
+ raise ConfigError("--config may only be passed once")
85
+ explicit = item.split("=", 1)[1]
86
+ if not explicit:
87
+ raise ConfigError("--config requires a path")
88
+ index += 1
89
+ continue
90
+ clean.append(item)
91
+ index += 1
92
+ return clean, explicit
93
+
94
+
95
+ def _header(hit: dict) -> str:
96
+ """Citation + type + channel tags, e.g. ``[deep-modules §4.6 p.45] (red_flag via lex#1+sem#1)``.
97
+
98
+ Hybrid hits carry per-channel ranks; showing them makes reliability visible:
99
+ ``via lex#2+sem#1`` = two independent signals agree, ``via sem#4`` = one
100
+ channel only — read the passage with more care.
101
+ """
102
+ citation = f"{hit['principle']} §{hit['section']} p.{hit['page']}"
103
+ via = ""
104
+ if hit.get("channels"):
105
+ via = " via " + "+".join(f"{name[:3]}#{rank}" for name, rank in hit["channels"].items())
106
+ return f"[{citation}] ({hit['type']}{via})"
107
+
108
+
109
+ def _format_hit(hit: dict) -> str:
110
+ """Render one search hit as a citation header + the verbatim passage."""
111
+ return f"{_header(hit)}\n{hit['text']}\n"
112
+
113
+
114
+ def _format_preview(hit: dict) -> str:
115
+ """Render a runner-up as one pointer line for --compact output.
116
+
117
+ Uses the generated ``context_line`` — a paraphrase, acceptable only because it
118
+ is labeled as a pointer, never presented as the passage. Expanding it into the
119
+ verbatim text is ``docq show <id>``.
120
+ """
121
+ line = " ".join((hit.get("context_line") or "").split())
122
+ if len(line) > 140:
123
+ line = line[:139].rstrip() + "…"
124
+ return f"more: id={hit['id']} {_header(hit)} — {line}"
125
+
126
+
127
+ def cmd_retrieve(args) -> None:
128
+ """Print passages matching a design situation (``--json`` for structured output)."""
129
+ # A reranker needs a few candidates to choose among, even at -k 1.
130
+ kk = max(args.k, 5) if args.rerank else args.k
131
+ kw = dict(k=kk, principles=args.principle, types=args.type)
132
+ if args.mode == "lexical":
133
+ hits = search(Path(args.db), args.query, **kw)
134
+ else:
135
+ from .vectors import VectorsUnavailable, search_auto, search_hybrid, search_semantic
136
+ fn = {"auto": search_auto, "hybrid": search_hybrid, "semantic": search_semantic}[args.mode]
137
+ try:
138
+ hits = fn(Path(args.db), args.query, base_url=args.ollama_url, **kw)
139
+ except VectorsUnavailable as e:
140
+ raise SystemExit(f"docq retrieve --mode {args.mode}: {e}")
141
+ if args.rerank:
142
+ from .rerank import rerank
143
+ template = Path(args.rerank_prompt).read_text() if args.rerank_prompt else None
144
+ hits = rerank(args.query, hits, model=args.rerank_model,
145
+ base_url=args.ollama_url, template=template)[:args.k]
146
+ if args.json:
147
+ print(json.dumps(hits, indent=2))
148
+ elif not hits:
149
+ print("(no matches)")
150
+ elif args.compact:
151
+ print("\n".join([_format_hit(hits[0])] + [_format_preview(h) for h in hits[1:]]))
152
+ else:
153
+ print("\n".join(_format_hit(h) for h in hits))
154
+
155
+
156
+ def cmd_show(args) -> None:
157
+ """Print one unit in full by id — expands a --compact preview line verbatim."""
158
+ unit = get_unit(Path(args.db), args.id)
159
+ if unit is None:
160
+ raise SystemExit(f"docq show: no unit with id={args.id} in {args.db}")
161
+ out = _format_hit(unit)
162
+ if unit.get("applies_when"):
163
+ out += f"applies when: {unit['applies_when']}\n"
164
+ print(out)
165
+
166
+
167
+ def cmd_build(args) -> None:
168
+ """Build the corpus db (lazy import: build-only deps stay off the retrieve path)."""
169
+ from .build import run_build
170
+ run_build(chapter=args.chapter, model=args.model, db=Path(args.db),
171
+ resume=args.resume, instance=Path(args.instance), workers=args.workers,
172
+ build_dir=Path(args.build_dir))
173
+
174
+
175
+ def cmd_enrich_questions(args) -> None:
176
+ """Top up checkpoint questions (lazy import: build-only deps stay off the retrieve path)."""
177
+ from .topup import run_topup
178
+ run_topup(model=args.model, build_dir=Path(args.build_dir), instance=Path(args.instance),
179
+ workers=args.workers)
180
+
181
+
182
+ def cmd_embed(args) -> None:
183
+ """Embed every unit into the db's vectors table (needs a running Ollama)."""
184
+ from .vectors import embed_corpus
185
+ info = embed_corpus(Path(args.db), model=args.model, base_url=args.ollama_url,
186
+ batch=args.batch)
187
+ print(f"embedded {info['units']} units -> {info['vectors']} vectors "
188
+ f"(dim={info['dim']}, model={info['model']}) in {args.db}")
189
+
190
+
191
+ def _search_fn_for(mode: str, ollama_url: str, rerank_model: str | None = None,
192
+ rerank_template: str | None = None):
193
+ """Resolve an eval retrieval mode to a score_cases-compatible search_fn.
194
+
195
+ ``rerank_model`` wraps the mode's results in an LLM rerank (top-5 candidates
196
+ in, top-k out) — applied to one leg only so a comparison isolates exactly
197
+ the reranker's contribution.
198
+ """
199
+ if mode == "lexical":
200
+ base = search
201
+ else:
202
+ from functools import partial
203
+ from .vectors import search_hybrid, search_semantic
204
+ base = partial(search_hybrid if mode == "hybrid" else search_semantic,
205
+ base_url=ollama_url)
206
+ if not rerank_model:
207
+ return base
208
+ from .rerank import rerank
209
+
210
+ def fn(db, query, k=5):
211
+ hits = base(db, query, k=max(k, 5))
212
+ return rerank(query, hits, model=rerank_model, base_url=ollama_url,
213
+ template=rerank_template)[:k]
214
+ return fn
215
+
216
+
217
+ def cmd_eval(args) -> None:
218
+ """Score retrieval against eval cases; --vs adds a paired significance test."""
219
+ from .evalrun import paired_sign_flip, run_eval, score_cases
220
+ primary_rr = args.rerank_model if args.rerank else None
221
+ template = Path(args.rerank_prompt).read_text() if getattr(args, "rerank_prompt", None) else None
222
+ if args.vs and (args.mode, primary_rr) == (args.vs, None):
223
+ raise SystemExit("docq eval: --vs compares two different configurations "
224
+ "(same mode needs --rerank on the primary leg)")
225
+ if not args.vs:
226
+ run_eval(Path(args.db), Path(args.cases), k=args.k, verbose=args.verbose,
227
+ search_fn=_search_fn_for(args.mode, args.ollama_url, primary_rr, template))
228
+ return
229
+ import yaml
230
+ cases = yaml.safe_load(Path(args.cases).read_text())["cases"]
231
+ results = {}
232
+ # --rerank applies to the primary --mode leg only, so `--mode hybrid --rerank
233
+ # --vs hybrid` isolates exactly the reranker's contribution.
234
+ for mode, rr in ((args.mode, primary_rr), (args.vs, None)):
235
+ label = f"{mode}+rr" if rr else mode
236
+ results[label] = score_cases(Path(args.db), cases, k=args.k,
237
+ search_fn=_search_fn_for(mode, args.ollama_url, rr, template))
238
+ r = results[label]
239
+ print(f"{label:10}: hit@{args.k}={r['hit_rate']:.2f} hit@1={r['hit1']:.2f} "
240
+ f"mrr={r['mrr']:.2f} n={r['n']}")
241
+ a, b = list(results)
242
+ delta, p = paired_sign_flip(results[a]["ranks"], results[b]["ranks"])
243
+ print(f"Δmrr={delta:+.3f} p={p:.4f} "
244
+ f"(paired sign-flip on per-case reciprocal rank, 10000 resamples)")
245
+
246
+
247
+ def cmd_config(args) -> None:
248
+ """Print effective values and the files considered to produce them."""
249
+ print(json.dumps({
250
+ "sources": [str(path) for path in args.loaded_config.sources],
251
+ "candidates": [str(path) for path in args.loaded_config.candidates],
252
+ "config": args.effective_config,
253
+ }, indent=2))
254
+
255
+
256
+ def _require_configured(parser: argparse.ArgumentParser, args, loaded: LoadedConfig) -> None:
257
+ requirements = {
258
+ "retrieve": (("db", "database", "--db"),),
259
+ "show": (("db", "database", "--db"),),
260
+ "embed": (("db", "database", "--db"),),
261
+ "build": (
262
+ ("db", "database", "--db"),
263
+ ("instance", "corpus instance", "--instance"),
264
+ ),
265
+ "enrich-questions": (
266
+ ("build_dir", "build directory", "--build-dir"),
267
+ ("instance", "corpus instance", "--instance"),
268
+ ),
269
+ "eval": (
270
+ ("db", "database", "--db"),
271
+ ("cases", "evaluation cases", "--cases"),
272
+ ),
273
+ }
274
+ for attribute, label, option in requirements.get(args.command, ()):
275
+ if getattr(args, attribute) is None:
276
+ inspected = ", ".join(str(path) for path in loaded.candidates)
277
+ parser.error(
278
+ f"{args.command}: no {label} configured; pass {option} or set it in "
279
+ f"a config file (inspected: {inspected})"
280
+ )
281
+
282
+
283
+ def main(argv: list[str] | None = None) -> None:
284
+ """Entry point for the ``docq`` console script."""
285
+ raw = list(sys.argv[1:] if argv is None else argv)
286
+ try:
287
+ clean, explicit = _extract_config(raw)
288
+ loaded = load_config(explicit=explicit)
289
+ except ConfigError as error:
290
+ raise SystemExit(f"docq: configuration error: {error}") from None
291
+ config = _effective(loaded.values)
292
+
293
+ parser = argparse.ArgumentParser(prog="docq")
294
+ parser.add_argument("--config", metavar="FILE",
295
+ help="use only FILE plus CLI flags (accepted before or after the command)")
296
+ sub = parser.add_subparsers(required=True, dest="command")
297
+
298
+ c = sub.add_parser("config", help="print loaded files and effective configuration")
299
+ c.set_defaults(func=cmd_config)
300
+
301
+ r = sub.add_parser("retrieve", help="retrieve passages for a design situation")
302
+ r.add_argument("query")
303
+ r.add_argument("--db", default=config["db"])
304
+ r.add_argument("-k", type=int, default=config["retrieve"]["k"])
305
+ r.add_argument("--principle", action="append")
306
+ r.add_argument("--type", action="append")
307
+ r.add_argument("--json", action="store_true")
308
+ r.add_argument("--compact", action="store_true",
309
+ help="text mode only: full passage for hit #1, one 'more: id=N …' "
310
+ "preview line per runner-up (expand with 'docq show'); "
311
+ "--json output is unaffected")
312
+ r.add_argument("--mode", choices=["auto", "lexical", "hybrid", "semantic"],
313
+ default=config["retrieve"]["mode"],
314
+ help="auto = hybrid when the db has vectors and the embedder is up, "
315
+ "else lexical (default); hybrid/semantic fail loudly instead of degrading")
316
+ r.add_argument("--ollama-url", default=config["ollama-url"],
317
+ help="Ollama base URL for query embedding (hybrid/semantic/auto modes)")
318
+ r.add_argument("--rerank", action=argparse.BooleanOptionalAction,
319
+ default=config["retrieve"]["rerank"],
320
+ help="LLM-rerank the top candidates when fusion's #1 isn't dual-backed "
321
+ "(one Ollama chat call); transport/setup failures are errors")
322
+ r.add_argument("--rerank-model", default=config["retrieve"]["rerank-model"])
323
+ r.add_argument("--rerank-prompt", metavar="FILE",
324
+ default=config["retrieve"]["rerank-prompt"],
325
+ help="corpus-specific rerank prompt template file with {query}, "
326
+ "{candidates}, {n} placeholders (default: built-in generic)")
327
+ r.set_defaults(func=cmd_retrieve)
328
+
329
+ s = sub.add_parser("show", help="print one unit in full by id (expands a --compact preview)")
330
+ s.add_argument("id", type=int)
331
+ s.add_argument("--db", default=config["db"])
332
+ s.set_defaults(func=cmd_show)
333
+
334
+ m = sub.add_parser("embed", help="precompute unit vectors into the db (semantic channel)")
335
+ m.add_argument("--db", default=config["db"])
336
+ m.add_argument("--model", default=config["embed"]["model"])
337
+ m.add_argument("--ollama-url", default=config["ollama-url"])
338
+ m.add_argument("--batch", type=int, default=config["embed"]["batch"],
339
+ help="texts per embedding request")
340
+ m.set_defaults(func=cmd_embed)
341
+
342
+ b = sub.add_parser("build", help="build the corpus db from the source document")
343
+ b.add_argument("--chapter")
344
+ b.add_argument("--model", default=config["build"]["model"])
345
+ b.add_argument("--db", default=config["db"])
346
+ b.add_argument("--instance", default=config["build"]["instance"],
347
+ help="corpus profile/taxonomy/prompt directory")
348
+ b.add_argument("--resume", action="store_true")
349
+ b.add_argument("--workers", type=int, default=config["build"]["workers"],
350
+ help="concurrent enrichment requests")
351
+ b.add_argument("--build-dir", default=config["build"]["build-dir"],
352
+ help="root for per-chapter JSONL checkpoints; use a distinct dir per "
353
+ "model (e.g. build/minimax) so builds don't clobber each other")
354
+ b.set_defaults(func=cmd_build)
355
+
356
+ q = sub.add_parser("enrich-questions",
357
+ help="append differently-angled retrieval questions to every enriched "
358
+ "checkpoint row (widens the semantic net; ship with a build "
359
+ "--resume + embed)")
360
+ q.add_argument("--build-dir", default=config["enrich-questions"]["build-dir"],
361
+ help="checkpoint root of the build to top up (e.g. build/minimax-v2)")
362
+ q.add_argument("--instance", default=config["enrich-questions"]["instance"],
363
+ help="corpus profile/taxonomy/prompt directory")
364
+ q.add_argument("--model", default=config["enrich-questions"]["model"])
365
+ q.add_argument("--workers", type=int, default=config["enrich-questions"]["workers"],
366
+ help="concurrent top-up requests")
367
+ q.set_defaults(func=cmd_enrich_questions)
368
+
369
+ e = sub.add_parser("eval", help="score retrieval against eval cases")
370
+ e.add_argument("--db", default=config["db"])
371
+ e.add_argument("--cases", default=config["eval"]["cases"])
372
+ e.add_argument("-k", type=int, default=config["eval"]["k"])
373
+ e.add_argument("-v", "--verbose", action="store_true",
374
+ help="print every case whose expected unit is not ranked #1")
375
+ e.add_argument("--mode", choices=["lexical", "hybrid", "semantic"],
376
+ default=config["eval"]["mode"],
377
+ help="retrieval mode to score (no auto: an eval must not silently degrade)")
378
+ e.add_argument("--vs", choices=["lexical", "hybrid", "semantic"],
379
+ default=config["eval"]["vs"],
380
+ help="second mode to compare against: prints both scores plus a paired "
381
+ "sign-flip p-value on per-case reciprocal rank")
382
+ e.add_argument("--ollama-url", default=config["ollama-url"])
383
+ e.add_argument("--rerank", action=argparse.BooleanOptionalAction,
384
+ default=config["eval"]["rerank"],
385
+ help="LLM-rerank the PRIMARY --mode leg only, so `--mode hybrid --rerank "
386
+ "--vs hybrid` isolates the reranker's contribution")
387
+ e.add_argument("--rerank-model", default=config["eval"]["rerank-model"])
388
+ e.add_argument("--rerank-prompt", metavar="FILE",
389
+ default=config["eval"]["rerank-prompt"],
390
+ help="corpus-specific rerank prompt template file (see retrieve --rerank-prompt)")
391
+ e.set_defaults(func=cmd_eval)
392
+
393
+ args = parser.parse_args(clean)
394
+ args.loaded_config = loaded
395
+ args.effective_config = config
396
+ _require_configured(parser, args, loaded)
397
+ try:
398
+ args.func(args)
399
+ except OllamaUnavailable as error:
400
+ parser.exit(1, f"docq: rerank failed: {error}\n")
401
+
402
+
403
+ if __name__ == "__main__":
404
+ main()