tracecite 0.0.2__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.
- tracecite/__init__.py +35 -0
- tracecite/__main__.py +3 -0
- tracecite/cli.py +656 -0
- tracecite/docs/__init__.py +56 -0
- tracecite/docs/build.py +381 -0
- tracecite/docs/config.py +114 -0
- tracecite/docs/contract.py +17 -0
- tracecite/docs/modes.py +240 -0
- tracecite/docs/stage.py +407 -0
- tracecite/docs/vectorize.py +457 -0
- tracecite/evidence/__init__.py +3 -0
- tracecite/evidence/chunking.py +214 -0
- tracecite/evidence/commands.py +552 -0
- tracecite/evidence/config.py +49 -0
- tracecite/evidence/embedding.py +59 -0
- tracecite/evidence/manifest.py +197 -0
- tracecite/evidence/page_extraction.py +200 -0
- tracecite/evidence/page_output.py +80 -0
- tracecite/evidence/page_selection.py +116 -0
- tracecite/evidence/parsers/__init__.py +1 -0
- tracecite/evidence/parsers/base.py +65 -0
- tracecite/evidence/parsers/markdown.py +142 -0
- tracecite/evidence/parsers/pdf.py +315 -0
- tracecite/evidence/parsers/workbook.py +334 -0
- tracecite/evidence/paths.py +94 -0
- tracecite/evidence/schema.py +570 -0
- tracecite/evidence/source_links.py +185 -0
- tracecite/evidence/sync.py +1434 -0
- tracecite/evidence/vector_backend.py +130 -0
- tracecite/evidence/verify.py +368 -0
- tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
- tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
- tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
- tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
- tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
- tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
- tracecite/skills/using-tracecite/SKILL.md +97 -0
- tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
- tracecite/skills/using-tracecite/references/architecture.md +68 -0
- tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
- tracecite/skills/using-tracecite/references/retrieval.md +45 -0
- tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
- tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
- tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
- tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
- tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
- tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
- tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
- tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
- tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
- tracecite/tables/__init__.py +41 -0
- tracecite/tables/_text.py +142 -0
- tracecite/tables/document.py +582 -0
- tracecite/tables/html.py +299 -0
- tracecite/tables/models.py +119 -0
- tracecite/tables/normalise.py +786 -0
- tracecite/tables/pandoc.py +503 -0
- tracecite/tables/publish.py +238 -0
- tracecite/tables/render.py +128 -0
- tracecite/tables/site.py +457 -0
- tracecite-0.0.2.dist-info/METADATA +320 -0
- tracecite-0.0.2.dist-info/RECORD +76 -0
- tracecite-0.0.2.dist-info/WHEEL +5 -0
- tracecite-0.0.2.dist-info/entry_points.txt +2 -0
- tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
- tracecite-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import config as config_module
|
|
11
|
+
from . import schema
|
|
12
|
+
|
|
13
|
+
RRF_K = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require(value, flag: str):
|
|
17
|
+
if value is None:
|
|
18
|
+
raise SystemExit(f"error: {flag} is required for this command")
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _connect(database: Path, *, read_only: bool):
|
|
23
|
+
try:
|
|
24
|
+
return schema.connect_existing(
|
|
25
|
+
_require(database, "--database"), read_only=read_only
|
|
26
|
+
)
|
|
27
|
+
except FileNotFoundError as exc:
|
|
28
|
+
print(str(exc), file=sys.stderr)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_runtime_args(args: argparse.Namespace) -> argparse.Namespace:
|
|
33
|
+
if args.config is None:
|
|
34
|
+
return args
|
|
35
|
+
profile = config_module.load_profile(args.config)
|
|
36
|
+
if args.root is None:
|
|
37
|
+
args.root = profile.root
|
|
38
|
+
if args.database is None:
|
|
39
|
+
args.database = profile.database
|
|
40
|
+
if args.model_cache_dir is None:
|
|
41
|
+
args.model_cache_dir = profile.model_cache_dir
|
|
42
|
+
args.manifests = [*profile.manifests, *args.manifests]
|
|
43
|
+
return args
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
47
|
+
from . import sync as sync_module
|
|
48
|
+
|
|
49
|
+
options = sync_module.SyncOptions(
|
|
50
|
+
max_chunk_chars=args.max_chunk_chars,
|
|
51
|
+
generate_assets=not args.no_assets,
|
|
52
|
+
full=args.full,
|
|
53
|
+
reembed=args.reembed,
|
|
54
|
+
)
|
|
55
|
+
only_paths = {Path(args.path).as_posix()} if args.path else None
|
|
56
|
+
report = sync_module.sync(
|
|
57
|
+
_require(args.root, "--root"),
|
|
58
|
+
args.manifests,
|
|
59
|
+
_require(args.database, "--database"),
|
|
60
|
+
options=options,
|
|
61
|
+
model_cache_dir=args.model_cache_dir,
|
|
62
|
+
only_relative_paths=only_paths,
|
|
63
|
+
)
|
|
64
|
+
print(f"status: {report.status}")
|
|
65
|
+
print(f"added: {report.sources_added}")
|
|
66
|
+
print(f"reparsed: {report.sources_reparsed}")
|
|
67
|
+
print(f"rechunked: {report.sources_rechunked}")
|
|
68
|
+
print(f"renamed: {report.sources_renamed}")
|
|
69
|
+
print(f"selected-missing: {report.selected_missing_paths}")
|
|
70
|
+
print(f"unmatched-globs: {report.unmatched_globs}")
|
|
71
|
+
print(f"indexed-but-unselected: {report.indexed_unselected_paths}")
|
|
72
|
+
print(f"cleanup-warnings: {report.cleanup_warnings}")
|
|
73
|
+
print(f"unchanged: {len(report.sources_unchanged)} source(s)")
|
|
74
|
+
print(
|
|
75
|
+
f"chunks added={report.chunks_added} updated={report.chunks_updated} "
|
|
76
|
+
f"deleted={report.chunks_deleted}"
|
|
77
|
+
)
|
|
78
|
+
print(f"embeddings generated: {report.embeddings_generated}")
|
|
79
|
+
return 0 if report.status == "ok" else 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fts_rows(conn, query, fts_limit, source_types=None):
|
|
83
|
+
type_filter = ""
|
|
84
|
+
type_params: list = []
|
|
85
|
+
if source_types:
|
|
86
|
+
placeholders = ",".join("?" for _ in source_types)
|
|
87
|
+
type_filter = f" AND sources.source_type IN ({placeholders})"
|
|
88
|
+
type_params = list(source_types)
|
|
89
|
+
|
|
90
|
+
statement = f"""
|
|
91
|
+
SELECT chunks.chunk_id AS chunk_id, bm25(chunks_fts) AS score
|
|
92
|
+
FROM chunks_fts
|
|
93
|
+
JOIN chunks ON chunks.rowid = chunks_fts.rowid
|
|
94
|
+
JOIN sources ON sources.source_pk = chunks.source_pk
|
|
95
|
+
WHERE chunks_fts MATCH ?{type_filter}
|
|
96
|
+
ORDER BY bm25(chunks_fts)
|
|
97
|
+
LIMIT ?
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
return conn.execute(statement, [query, *type_params, fts_limit]).fetchall()
|
|
101
|
+
except sqlite3.OperationalError as exc:
|
|
102
|
+
if not str(exc).startswith("fts5: syntax error"):
|
|
103
|
+
raise
|
|
104
|
+
fallback_query = f'"{query.replace(chr(34), chr(34) * 2)}"'
|
|
105
|
+
print(
|
|
106
|
+
"FTS5 lexical search rejected the query syntax; using a quoted lexical fallback. "
|
|
107
|
+
"Vector retrieval still uses the original query.",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
return conn.execute(
|
|
111
|
+
statement, [fallback_query, *type_params, fts_limit]
|
|
112
|
+
).fetchall()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def active_embedding_model(conn, model_cache_dir: Path):
|
|
116
|
+
"""Return the embedding model recorded as active in the database."""
|
|
117
|
+
from . import sync as sync_module
|
|
118
|
+
|
|
119
|
+
config = schema.load_config(conn)
|
|
120
|
+
return sync_module.EmbeddingModel(
|
|
121
|
+
config.embedding_model, config.embedding_revision, model_cache_dir
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def hybrid_search(
|
|
126
|
+
conn, query, limit, fts_limit, vector_limit, embedder, *, source_types=None
|
|
127
|
+
):
|
|
128
|
+
from . import vector_backend
|
|
129
|
+
|
|
130
|
+
fts_rows = _fts_rows(conn, query, fts_limit, source_types)
|
|
131
|
+
fts_rank = {row["chunk_id"]: rank for rank, row in enumerate(fts_rows, start=1)}
|
|
132
|
+
query_vector = embedder.embed([query])[0]
|
|
133
|
+
|
|
134
|
+
active_model_id = schema.load_config(conn).embedding_model_id
|
|
135
|
+
if source_types:
|
|
136
|
+
placeholders = ",".join("?" for _ in source_types)
|
|
137
|
+
allowed_rows = conn.execute(
|
|
138
|
+
f"""
|
|
139
|
+
SELECT DISTINCT chunk_embeddings.embedding_id
|
|
140
|
+
FROM chunk_embeddings
|
|
141
|
+
JOIN embeddings ON embeddings.embedding_id = chunk_embeddings.embedding_id
|
|
142
|
+
JOIN chunks ON chunks.chunk_id = chunk_embeddings.chunk_id
|
|
143
|
+
JOIN sources ON sources.source_pk = chunks.source_pk
|
|
144
|
+
WHERE embeddings.model_id = ? AND sources.source_type IN ({placeholders})
|
|
145
|
+
""",
|
|
146
|
+
[active_model_id, *source_types],
|
|
147
|
+
).fetchall()
|
|
148
|
+
else:
|
|
149
|
+
allowed_rows = conn.execute(
|
|
150
|
+
"SELECT embedding_id FROM embeddings WHERE model_id = ?", (active_model_id,)
|
|
151
|
+
).fetchall()
|
|
152
|
+
allowed_ids = [row["embedding_id"] for row in allowed_rows]
|
|
153
|
+
|
|
154
|
+
backend = vector_backend.SqliteVecBackend()
|
|
155
|
+
vector_matches = backend.search(
|
|
156
|
+
conn, query_vector, vector_limit, allowed_embedding_ids=allowed_ids
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
vector_rank: dict[str, int] = {}
|
|
160
|
+
if vector_matches:
|
|
161
|
+
embedding_ids = [match.embedding_id for match in vector_matches]
|
|
162
|
+
placeholders = ",".join("?" for _ in embedding_ids)
|
|
163
|
+
chunk_rows = conn.execute(
|
|
164
|
+
f"SELECT chunk_id, embedding_id FROM chunk_embeddings WHERE embedding_id IN ({placeholders})",
|
|
165
|
+
embedding_ids,
|
|
166
|
+
).fetchall()
|
|
167
|
+
embedding_to_chunks: dict[int, list[str]] = defaultdict(list)
|
|
168
|
+
for row in chunk_rows:
|
|
169
|
+
embedding_to_chunks[row["embedding_id"]].append(row["chunk_id"])
|
|
170
|
+
rank = 0
|
|
171
|
+
for match in vector_matches:
|
|
172
|
+
for chunk_id in embedding_to_chunks.get(match.embedding_id, []):
|
|
173
|
+
rank += 1
|
|
174
|
+
vector_rank.setdefault(chunk_id, rank)
|
|
175
|
+
|
|
176
|
+
fused_scores: dict[str, float] = defaultdict(float)
|
|
177
|
+
provenance: dict[str, set[str]] = defaultdict(set)
|
|
178
|
+
for retrieval, ranks in (("lexical", fts_rank), ("vector", vector_rank)):
|
|
179
|
+
for chunk_id, rank in ranks.items():
|
|
180
|
+
fused_scores[chunk_id] += 1.0 / (RRF_K + rank)
|
|
181
|
+
provenance[chunk_id].add(retrieval)
|
|
182
|
+
|
|
183
|
+
ranked_chunk_ids = sorted(fused_scores, key=fused_scores.get, reverse=True)[:limit]
|
|
184
|
+
|
|
185
|
+
results = []
|
|
186
|
+
for position, chunk_id in enumerate(ranked_chunk_ids, start=1):
|
|
187
|
+
chunk = conn.execute(
|
|
188
|
+
"SELECT c.*, s.path AS source_path, s.source_type, s.sha256 "
|
|
189
|
+
"FROM chunks c JOIN sources s USING (source_pk) WHERE c.chunk_id = ?",
|
|
190
|
+
(chunk_id,),
|
|
191
|
+
).fetchone()
|
|
192
|
+
entry = {
|
|
193
|
+
"rank": position,
|
|
194
|
+
"source_path": chunk["source_path"],
|
|
195
|
+
"source_type": chunk["source_type"],
|
|
196
|
+
"source_sha256": chunk["sha256"],
|
|
197
|
+
"heading_path": json.loads(chunk["heading_path"])
|
|
198
|
+
if chunk["heading_path"]
|
|
199
|
+
else [],
|
|
200
|
+
"passage": chunk["body"],
|
|
201
|
+
"physical_page": chunk["physical_page"],
|
|
202
|
+
"page_offsets": {
|
|
203
|
+
"start": chunk["page_start_offset"],
|
|
204
|
+
"end": chunk["page_end_offset"],
|
|
205
|
+
},
|
|
206
|
+
"page_range": {
|
|
207
|
+
"start": chunk["page_range_start"],
|
|
208
|
+
"end": chunk["page_range_end"],
|
|
209
|
+
},
|
|
210
|
+
"line_range": {"start": chunk["line_start"], "end": chunk["line_end"]},
|
|
211
|
+
"locator": json.loads(chunk["locator_json"])
|
|
212
|
+
if chunk["locator_json"]
|
|
213
|
+
else {},
|
|
214
|
+
"content_type": chunk["content_type"],
|
|
215
|
+
"provenance": sorted(provenance[chunk_id]),
|
|
216
|
+
"fused_score": fused_scores[chunk_id],
|
|
217
|
+
}
|
|
218
|
+
if chunk["source_type"] == "pdf" and chunk["physical_page"] is not None:
|
|
219
|
+
entry["pdf_link"] = f"{chunk['source_path']}#page={chunk['physical_page']}"
|
|
220
|
+
results.append(entry)
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
225
|
+
from .page_output import PageAssetMissingError, PageOutputError, page_assets_for
|
|
226
|
+
|
|
227
|
+
conn = _connect(args.database, read_only=True)
|
|
228
|
+
if conn is None:
|
|
229
|
+
return 2
|
|
230
|
+
try:
|
|
231
|
+
schema.ensure_schema(conn)
|
|
232
|
+
model_cache_dir = (
|
|
233
|
+
args.model_cache_dir or Path(args.database).resolve().parent / "model-cache"
|
|
234
|
+
)
|
|
235
|
+
results = hybrid_search(
|
|
236
|
+
conn,
|
|
237
|
+
args.query,
|
|
238
|
+
args.limit,
|
|
239
|
+
args.fts_limit,
|
|
240
|
+
args.vector_limit,
|
|
241
|
+
active_embedding_model(conn, model_cache_dir),
|
|
242
|
+
source_types=args.source_types,
|
|
243
|
+
)
|
|
244
|
+
source_pks = {
|
|
245
|
+
row["path"]: row["source_pk"]
|
|
246
|
+
for row in conn.execute("SELECT path, source_pk FROM sources")
|
|
247
|
+
}
|
|
248
|
+
for result in results:
|
|
249
|
+
page = result["physical_page"]
|
|
250
|
+
result["page_render"], result["figure_crops"] = (
|
|
251
|
+
page_assets_for(
|
|
252
|
+
conn, args.database, source_pks[result["source_path"]], page
|
|
253
|
+
)
|
|
254
|
+
if page is not None
|
|
255
|
+
else (None, [])
|
|
256
|
+
)
|
|
257
|
+
print(json.dumps(results, indent=2))
|
|
258
|
+
except PageAssetMissingError as exc:
|
|
259
|
+
print(str(exc), file=sys.stderr)
|
|
260
|
+
return 1
|
|
261
|
+
except PageOutputError as exc:
|
|
262
|
+
print(str(exc), file=sys.stderr)
|
|
263
|
+
return 2
|
|
264
|
+
finally:
|
|
265
|
+
conn.close()
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_page(args: argparse.Namespace) -> int:
|
|
270
|
+
from .page_output import PageAssetMissingError, PageOutputError, page_json_payload
|
|
271
|
+
from .page_selection import (
|
|
272
|
+
PageSelectionSyntaxError,
|
|
273
|
+
PageSelectionUnavailableError,
|
|
274
|
+
resolve_page_selection,
|
|
275
|
+
)
|
|
276
|
+
from .paths import PathAuthorityError, normalise_source_path, source_row_for_path
|
|
277
|
+
|
|
278
|
+
conn = _connect(args.database, read_only=True)
|
|
279
|
+
if conn is None:
|
|
280
|
+
return 2
|
|
281
|
+
try:
|
|
282
|
+
schema.ensure_schema(conn)
|
|
283
|
+
root = Path(args.root or Path.cwd()).resolve()
|
|
284
|
+
try:
|
|
285
|
+
source_path = normalise_source_path(root, args.source_path)
|
|
286
|
+
except PathAuthorityError as exc:
|
|
287
|
+
print(
|
|
288
|
+
json.dumps({"error": "path-error", "detail": str(exc)}), file=sys.stderr
|
|
289
|
+
)
|
|
290
|
+
return 2
|
|
291
|
+
source_row = source_row_for_path(conn, source_path)
|
|
292
|
+
if source_row is None:
|
|
293
|
+
print(f"not-found: {source_path} page 1", file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
if source_row["source_type"] == "workbook":
|
|
296
|
+
print(f"source is not a pdf: {source_path}", file=sys.stderr)
|
|
297
|
+
return 2
|
|
298
|
+
rows = conn.execute(
|
|
299
|
+
"SELECT * FROM pages WHERE source_pk = ? ORDER BY physical_page",
|
|
300
|
+
(source_row["source_pk"],),
|
|
301
|
+
).fetchall()
|
|
302
|
+
available_pages = [row["physical_page"] for row in rows]
|
|
303
|
+
try:
|
|
304
|
+
selected_pages = resolve_page_selection(args.page, available_pages)
|
|
305
|
+
except PageSelectionSyntaxError as exc:
|
|
306
|
+
print(f"tracecite: {exc}", file=sys.stderr)
|
|
307
|
+
return 2
|
|
308
|
+
except PageSelectionUnavailableError as exc:
|
|
309
|
+
print(f"not-found: {source_path} page {exc.page}", file=sys.stderr)
|
|
310
|
+
return 1
|
|
311
|
+
selected_rows = {
|
|
312
|
+
row["physical_page"]: row
|
|
313
|
+
for row in rows
|
|
314
|
+
if row["physical_page"] in selected_pages
|
|
315
|
+
}
|
|
316
|
+
if args.format == "json":
|
|
317
|
+
try:
|
|
318
|
+
payload = [
|
|
319
|
+
page_json_payload(
|
|
320
|
+
conn, args.database, source_row, selected_rows[page]
|
|
321
|
+
)
|
|
322
|
+
for page in selected_pages
|
|
323
|
+
]
|
|
324
|
+
except PageAssetMissingError as exc:
|
|
325
|
+
print(str(exc), file=sys.stderr)
|
|
326
|
+
return 1
|
|
327
|
+
except PageOutputError as exc:
|
|
328
|
+
print(str(exc), file=sys.stderr)
|
|
329
|
+
return 2
|
|
330
|
+
print(json.dumps(payload, indent=2))
|
|
331
|
+
elif len(selected_pages) == 1:
|
|
332
|
+
print(selected_rows[selected_pages[0]]["text"])
|
|
333
|
+
else:
|
|
334
|
+
for index, page in enumerate(selected_pages):
|
|
335
|
+
if index:
|
|
336
|
+
print()
|
|
337
|
+
print(f"--- physical page {page} ---")
|
|
338
|
+
print(selected_rows[page]["text"])
|
|
339
|
+
finally:
|
|
340
|
+
conn.close()
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_extract_pages(args: argparse.Namespace) -> int:
|
|
345
|
+
from .page_extraction import (
|
|
346
|
+
PageExtractionError,
|
|
347
|
+
PageExtractionMissingError,
|
|
348
|
+
extract_pages,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
conn = _connect(args.database, read_only=True)
|
|
352
|
+
if conn is None:
|
|
353
|
+
return 2
|
|
354
|
+
try:
|
|
355
|
+
schema.ensure_schema(conn)
|
|
356
|
+
root = Path(args.root or Path.cwd()).resolve()
|
|
357
|
+
try:
|
|
358
|
+
result = extract_pages(
|
|
359
|
+
conn, root, args.source_path, args.page, args.output_dir
|
|
360
|
+
)
|
|
361
|
+
except PageExtractionMissingError as exc:
|
|
362
|
+
print(str(exc), file=sys.stderr)
|
|
363
|
+
return 1
|
|
364
|
+
except PageExtractionError as exc:
|
|
365
|
+
print(str(exc), file=sys.stderr)
|
|
366
|
+
return 2
|
|
367
|
+
print(
|
|
368
|
+
json.dumps(
|
|
369
|
+
{
|
|
370
|
+
"pdf_path": str(result.pdf_path),
|
|
371
|
+
"manifest_path": str(result.manifest_path),
|
|
372
|
+
"normalized_pages": result.normalized_pages,
|
|
373
|
+
},
|
|
374
|
+
indent=2,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
finally:
|
|
378
|
+
conn.close()
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def cmd_verify_quote(args: argparse.Namespace) -> int:
|
|
383
|
+
from . import verify
|
|
384
|
+
from .paths import PathAuthorityError, normalise_source_path, source_row_for_path
|
|
385
|
+
|
|
386
|
+
conn = _connect(args.database, read_only=True)
|
|
387
|
+
if conn is None:
|
|
388
|
+
return 2
|
|
389
|
+
try:
|
|
390
|
+
schema.ensure_schema(conn)
|
|
391
|
+
root = Path(args.root or Path.cwd()).resolve()
|
|
392
|
+
try:
|
|
393
|
+
source_path = normalise_source_path(root, args.source_path)
|
|
394
|
+
except PathAuthorityError as exc:
|
|
395
|
+
print(
|
|
396
|
+
json.dumps({"error": "path-error", "detail": str(exc)}), file=sys.stderr
|
|
397
|
+
)
|
|
398
|
+
return 2
|
|
399
|
+
if source_row_for_path(conn, source_path) is None:
|
|
400
|
+
result = verify.QuoteVerification("not-found", args.page, args.quote)
|
|
401
|
+
else:
|
|
402
|
+
result = verify.verify_quote(conn, source_path, args.page, args.quote)
|
|
403
|
+
print(json.dumps(_quote_json(result)))
|
|
404
|
+
finally:
|
|
405
|
+
conn.close()
|
|
406
|
+
return 0 if result.status in {"exact", "normalised"} else 1
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _quote_json(result) -> dict:
|
|
410
|
+
return {
|
|
411
|
+
"status": result.status,
|
|
412
|
+
"page": result.physical_page,
|
|
413
|
+
"quote": result.quote,
|
|
414
|
+
"citation_key": result.citation_key,
|
|
415
|
+
"source_path": result.source_path,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_verify_report(args: argparse.Namespace) -> int:
|
|
420
|
+
from . import verify
|
|
421
|
+
|
|
422
|
+
conn = _connect(args.database, read_only=True)
|
|
423
|
+
if conn is None:
|
|
424
|
+
return 2
|
|
425
|
+
try:
|
|
426
|
+
schema.ensure_schema(conn)
|
|
427
|
+
result = verify.verify_report(
|
|
428
|
+
conn,
|
|
429
|
+
args.report_path,
|
|
430
|
+
args.root or Path.cwd(),
|
|
431
|
+
source_links_path=args.source_links,
|
|
432
|
+
source_links_root=args.source_links_root,
|
|
433
|
+
)
|
|
434
|
+
print(
|
|
435
|
+
json.dumps(
|
|
436
|
+
{
|
|
437
|
+
"citation_issues": [
|
|
438
|
+
issue.__dict__ for issue in result.citation_issues
|
|
439
|
+
],
|
|
440
|
+
"quote_results": [_quote_json(q) for q in result.quote_results],
|
|
441
|
+
"source_link_issues": result.source_link_issues,
|
|
442
|
+
"ok": result.ok,
|
|
443
|
+
},
|
|
444
|
+
indent=2,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
finally:
|
|
448
|
+
conn.close()
|
|
449
|
+
return 0 if result.ok else 1
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
453
|
+
from . import sync as sync_module
|
|
454
|
+
|
|
455
|
+
conn = _connect(args.database, read_only=True)
|
|
456
|
+
if conn is None:
|
|
457
|
+
return 2
|
|
458
|
+
try:
|
|
459
|
+
schema.ensure_schema(conn)
|
|
460
|
+
issues = sync_module.integrity_check(conn)
|
|
461
|
+
for issue in issues:
|
|
462
|
+
print(f"ISSUE: {issue}")
|
|
463
|
+
if not issues:
|
|
464
|
+
print("doctor: no issues found")
|
|
465
|
+
return 0 if not issues else 1
|
|
466
|
+
finally:
|
|
467
|
+
conn.close()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
471
|
+
from . import sync as sync_module
|
|
472
|
+
|
|
473
|
+
rules = sync_module.load_manifest_rules(args.manifests)
|
|
474
|
+
prune_all = args.prune_all
|
|
475
|
+
selects_any = bool(rules.explicit_paths or rules.include_globs)
|
|
476
|
+
if prune_all and (selects_any or rules.exclude_globs):
|
|
477
|
+
print(
|
|
478
|
+
"prune --all cannot be combined with source/include/exclude rules",
|
|
479
|
+
file=sys.stderr,
|
|
480
|
+
)
|
|
481
|
+
return 2
|
|
482
|
+
if not prune_all and not selects_any:
|
|
483
|
+
print(
|
|
484
|
+
"prune requires at least one positive source/include rule; excludes only refine it",
|
|
485
|
+
file=sys.stderr,
|
|
486
|
+
)
|
|
487
|
+
return 2
|
|
488
|
+
conn = _connect(args.database, read_only=True)
|
|
489
|
+
if conn is None:
|
|
490
|
+
return 2
|
|
491
|
+
try:
|
|
492
|
+
schema.ensure_schema(conn)
|
|
493
|
+
selected = set()
|
|
494
|
+
if not prune_all:
|
|
495
|
+
selected = {
|
|
496
|
+
row["path"]
|
|
497
|
+
for row in conn.execute("SELECT path FROM sources").fetchall()
|
|
498
|
+
if rules.selects_path(row["path"])
|
|
499
|
+
}
|
|
500
|
+
if not selected:
|
|
501
|
+
print(
|
|
502
|
+
"prune selection matched zero indexed sources; use --all explicitly to prune every source",
|
|
503
|
+
file=sys.stderr,
|
|
504
|
+
)
|
|
505
|
+
return 2
|
|
506
|
+
plan = sync_module.plan_prune(conn, selected)
|
|
507
|
+
sync_module.validate_prune_plan(conn, args.database, plan)
|
|
508
|
+
leaks = []
|
|
509
|
+
if args.apply:
|
|
510
|
+
conn.close()
|
|
511
|
+
conn = _connect(args.database, read_only=False)
|
|
512
|
+
if conn is None:
|
|
513
|
+
return 2
|
|
514
|
+
schema.ensure_schema(conn)
|
|
515
|
+
leaks = sync_module.apply_prune(conn, args.database, plan)
|
|
516
|
+
status = "preview"
|
|
517
|
+
if args.apply:
|
|
518
|
+
status = "applied-with-cleanup-warnings" if leaks else "applied"
|
|
519
|
+
print(
|
|
520
|
+
json.dumps(
|
|
521
|
+
{
|
|
522
|
+
"status": status,
|
|
523
|
+
"selected_count": len(selected),
|
|
524
|
+
"planned_count": len(plan.paths),
|
|
525
|
+
"paths": list(plan.paths),
|
|
526
|
+
"applied": bool(args.apply),
|
|
527
|
+
"database_committed": bool(args.apply),
|
|
528
|
+
"cleanup_warnings": leaks,
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return 3 if args.apply and leaks else 0
|
|
533
|
+
finally:
|
|
534
|
+
if conn is not None:
|
|
535
|
+
conn.close()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
539
|
+
args = _resolve_runtime_args(args)
|
|
540
|
+
if args.command == "verify":
|
|
541
|
+
return {"quote": cmd_verify_quote, "report": cmd_verify_report}[
|
|
542
|
+
args.verify_command
|
|
543
|
+
](args)
|
|
544
|
+
handlers = {
|
|
545
|
+
"sync": cmd_sync,
|
|
546
|
+
"search": cmd_search,
|
|
547
|
+
"page": cmd_page,
|
|
548
|
+
"extract-pages": cmd_extract_pages,
|
|
549
|
+
"prune": cmd_prune,
|
|
550
|
+
"doctor": cmd_doctor,
|
|
551
|
+
}
|
|
552
|
+
return handlers[args.command](args)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .manifest import ManifestError, ManifestRules, load_manifest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TraceCiteProfile:
|
|
16
|
+
root: Path
|
|
17
|
+
database: Path
|
|
18
|
+
model_cache_dir: Path
|
|
19
|
+
manifests: tuple[Path, ...]
|
|
20
|
+
rules: ManifestRules
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve(base: Path, value: str) -> Path:
|
|
24
|
+
path = Path(value)
|
|
25
|
+
return path if path.is_absolute() else (base / path).resolve()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_profile(path: Path) -> TraceCiteProfile:
|
|
29
|
+
profile_path = Path(path).resolve()
|
|
30
|
+
try:
|
|
31
|
+
with profile_path.open("rb") as handle:
|
|
32
|
+
data = tomllib.load(handle)
|
|
33
|
+
except tomllib.TOMLDecodeError as exc:
|
|
34
|
+
raise ConfigError(str(exc)) from exc
|
|
35
|
+
try:
|
|
36
|
+
rules = load_manifest(profile_path)
|
|
37
|
+
except ManifestError as exc:
|
|
38
|
+
raise ConfigError(str(exc)) from exc
|
|
39
|
+
for key in ("root", "database", "model_cache_dir"):
|
|
40
|
+
if not isinstance(data.get(key), str) or not data[key].strip():
|
|
41
|
+
raise ConfigError(f"TraceCite profile requires non-empty string {key}")
|
|
42
|
+
base = profile_path.parent
|
|
43
|
+
return TraceCiteProfile(
|
|
44
|
+
root=_resolve(base, data["root"]),
|
|
45
|
+
database=_resolve(base, data["database"]),
|
|
46
|
+
model_cache_dir=_resolve(base, data["model_cache_dir"]),
|
|
47
|
+
manifests=(profile_path,),
|
|
48
|
+
rules=rules,
|
|
49
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Embedder(Protocol):
|
|
10
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmbeddingModel:
|
|
14
|
+
"""Lazy wrapper around the configured sentence-transformers model."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
model_name: str,
|
|
19
|
+
revision: str,
|
|
20
|
+
cache_dir: Path,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.model_name = model_name
|
|
23
|
+
self.revision = revision
|
|
24
|
+
self.cache_dir = Path(cache_dir)
|
|
25
|
+
self._model = None
|
|
26
|
+
|
|
27
|
+
def _ensure_loaded(self):
|
|
28
|
+
if self._model is not None:
|
|
29
|
+
return self._model
|
|
30
|
+
from huggingface_hub.errors import LocalEntryNotFoundError
|
|
31
|
+
from sentence_transformers import SentenceTransformer
|
|
32
|
+
|
|
33
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", str(self.cache_dir))
|
|
35
|
+
unpacked_model = self.cache_dir / self.model_name
|
|
36
|
+
if (unpacked_model / "modules.json").is_file():
|
|
37
|
+
self._model = SentenceTransformer(
|
|
38
|
+
str(unpacked_model),
|
|
39
|
+
local_files_only=True,
|
|
40
|
+
)
|
|
41
|
+
return self._model
|
|
42
|
+
kwargs = {"revision": self.revision, "cache_folder": str(self.cache_dir)}
|
|
43
|
+
try:
|
|
44
|
+
self._model = SentenceTransformer(
|
|
45
|
+
self.model_name, local_files_only=True, **kwargs
|
|
46
|
+
)
|
|
47
|
+
except (LocalEntryNotFoundError, OSError):
|
|
48
|
+
self._model = SentenceTransformer(self.model_name, **kwargs)
|
|
49
|
+
return self._model
|
|
50
|
+
|
|
51
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
52
|
+
batch = list(texts)
|
|
53
|
+
if not batch:
|
|
54
|
+
return []
|
|
55
|
+
model = self._ensure_loaded()
|
|
56
|
+
vectors = model.encode(
|
|
57
|
+
batch, normalize_embeddings=True, show_progress_bar=False
|
|
58
|
+
)
|
|
59
|
+
return [[float(value) for value in vector] for vector in vectors]
|