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 +11 -0
- docq/build.py +127 -0
- docq/cli.py +404 -0
- docq/config.py +203 -0
- docq/enrich.py +149 -0
- docq/evalrun.py +84 -0
- docq/extract.py +86 -0
- docq/parse.py +127 -0
- docq/profile.py +43 -0
- docq/rerank.py +193 -0
- docq/segment.py +168 -0
- docq/store.py +114 -0
- docq/taxonomy.py +41 -0
- docq/topup.py +146 -0
- docq/vectors.py +312 -0
- docq-0.1.0.dist-info/METADATA +112 -0
- docq-0.1.0.dist-info/RECORD +20 -0
- docq-0.1.0.dist-info/WHEEL +4 -0
- docq-0.1.0.dist-info/entry_points.txt +2 -0
- docq-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|