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.
Files changed (76) hide show
  1. tracecite/__init__.py +35 -0
  2. tracecite/__main__.py +3 -0
  3. tracecite/cli.py +656 -0
  4. tracecite/docs/__init__.py +56 -0
  5. tracecite/docs/build.py +381 -0
  6. tracecite/docs/config.py +114 -0
  7. tracecite/docs/contract.py +17 -0
  8. tracecite/docs/modes.py +240 -0
  9. tracecite/docs/stage.py +407 -0
  10. tracecite/docs/vectorize.py +457 -0
  11. tracecite/evidence/__init__.py +3 -0
  12. tracecite/evidence/chunking.py +214 -0
  13. tracecite/evidence/commands.py +552 -0
  14. tracecite/evidence/config.py +49 -0
  15. tracecite/evidence/embedding.py +59 -0
  16. tracecite/evidence/manifest.py +197 -0
  17. tracecite/evidence/page_extraction.py +200 -0
  18. tracecite/evidence/page_output.py +80 -0
  19. tracecite/evidence/page_selection.py +116 -0
  20. tracecite/evidence/parsers/__init__.py +1 -0
  21. tracecite/evidence/parsers/base.py +65 -0
  22. tracecite/evidence/parsers/markdown.py +142 -0
  23. tracecite/evidence/parsers/pdf.py +315 -0
  24. tracecite/evidence/parsers/workbook.py +334 -0
  25. tracecite/evidence/paths.py +94 -0
  26. tracecite/evidence/schema.py +570 -0
  27. tracecite/evidence/source_links.py +185 -0
  28. tracecite/evidence/sync.py +1434 -0
  29. tracecite/evidence/vector_backend.py +130 -0
  30. tracecite/evidence/verify.py +368 -0
  31. tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
  32. tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
  33. tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
  34. tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
  35. tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
  36. tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
  37. tracecite/skills/using-tracecite/SKILL.md +97 -0
  38. tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
  39. tracecite/skills/using-tracecite/references/architecture.md +68 -0
  40. tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
  41. tracecite/skills/using-tracecite/references/retrieval.md +45 -0
  42. tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
  43. tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
  44. tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
  45. tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
  46. tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
  47. tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
  48. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
  49. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
  50. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
  51. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
  52. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
  53. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
  54. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
  55. tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
  56. tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
  57. tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
  58. tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
  59. tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
  60. tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
  61. tracecite/tables/__init__.py +41 -0
  62. tracecite/tables/_text.py +142 -0
  63. tracecite/tables/document.py +582 -0
  64. tracecite/tables/html.py +299 -0
  65. tracecite/tables/models.py +119 -0
  66. tracecite/tables/normalise.py +786 -0
  67. tracecite/tables/pandoc.py +503 -0
  68. tracecite/tables/publish.py +238 -0
  69. tracecite/tables/render.py +128 -0
  70. tracecite/tables/site.py +457 -0
  71. tracecite-0.0.2.dist-info/METADATA +320 -0
  72. tracecite-0.0.2.dist-info/RECORD +76 -0
  73. tracecite-0.0.2.dist-info/WHEEL +5 -0
  74. tracecite-0.0.2.dist-info/entry_points.txt +2 -0
  75. tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
  76. 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]