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,570 @@
|
|
|
1
|
+
"""Versioned SQLite schema for a TraceCite evidence database.
|
|
2
|
+
|
|
3
|
+
One SQLite file holds ordinary relational tables, an FTS5 lexical index, and
|
|
4
|
+
a normalised (model_id, semantic_input_hash)-keyed embedding store backed by
|
|
5
|
+
sqlite-vec. PDF page renders and crops are written to a repo-relative
|
|
6
|
+
``imgs/`` tree next to the database; SQLite stores only their metadata.
|
|
7
|
+
|
|
8
|
+
Schemas are never migrated. A database with another schema version or with
|
|
9
|
+
tables this module did not create is rejected; rebuild it at a fresh path.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import vector_backend
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 3
|
|
23
|
+
|
|
24
|
+
# Descriptive, not literal DDL -- recorded so a future FTS tokenizer/config
|
|
25
|
+
# change can be detected and trigger a documented "rebuild FTS only" step.
|
|
26
|
+
FTS_CONFIG = "fts5-unicode61-v1"
|
|
27
|
+
|
|
28
|
+
PARSER_NAME_PDF = "pdf-pymupdf"
|
|
29
|
+
PARSER_VERSION_PDF = "1"
|
|
30
|
+
PARSER_NAME_MARKDOWN = "markdown-heading"
|
|
31
|
+
PARSER_VERSION_MARKDOWN = "1"
|
|
32
|
+
PARSER_NAME_WORKBOOK = "workbook-ooxml"
|
|
33
|
+
PARSER_VERSION_WORKBOOK = "1"
|
|
34
|
+
|
|
35
|
+
CHUNKER_NAME = "greedy-char-budget"
|
|
36
|
+
CHUNKER_VERSION = "2"
|
|
37
|
+
|
|
38
|
+
NORMALISATION_VERSION = "1"
|
|
39
|
+
|
|
40
|
+
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
41
|
+
EMBEDDING_REVISION = "1110a243fdf4706b3f48f1d95db1a4f5529b4d41"
|
|
42
|
+
EMBEDDING_DIMENSIONS = 384
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def embedding_model_id(
|
|
46
|
+
model: str = EMBEDDING_MODEL, revision: str = EMBEDDING_REVISION
|
|
47
|
+
) -> str:
|
|
48
|
+
return f"{model}@{revision}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def utc_now() -> str:
|
|
52
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class KnowledgeBaseConfig:
|
|
57
|
+
schema_version: int
|
|
58
|
+
fts_config: str
|
|
59
|
+
sqlite_vec_version: str
|
|
60
|
+
embedding_model: str
|
|
61
|
+
embedding_revision: str
|
|
62
|
+
embedding_dimensions: int
|
|
63
|
+
parser_versions: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
chunker_version: str = CHUNKER_VERSION
|
|
65
|
+
normalisation_version: str = NORMALISATION_VERSION
|
|
66
|
+
created_at_utc: str = ""
|
|
67
|
+
updated_at_utc: str = ""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def embedding_model_id(self) -> str:
|
|
71
|
+
return embedding_model_id(self.embedding_model, self.embedding_revision)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ORDINARY_TABLES = (
|
|
75
|
+
"kb_config",
|
|
76
|
+
"sources",
|
|
77
|
+
"pages",
|
|
78
|
+
"chunks",
|
|
79
|
+
"embeddings",
|
|
80
|
+
"chunk_embeddings",
|
|
81
|
+
"assets",
|
|
82
|
+
)
|
|
83
|
+
_KNOWN_TABLES = {*ORDINARY_TABLES, "sqlite_sequence"}
|
|
84
|
+
_KNOWN_TABLE_PREFIXES = ("chunks_fts", "embedding_vectors")
|
|
85
|
+
|
|
86
|
+
_REQUIRED_COLUMNS = {
|
|
87
|
+
"kb_config": {
|
|
88
|
+
"id",
|
|
89
|
+
"schema_version",
|
|
90
|
+
"fts_config",
|
|
91
|
+
"sqlite_vec_version",
|
|
92
|
+
"embedding_model",
|
|
93
|
+
"embedding_revision",
|
|
94
|
+
"embedding_dimensions",
|
|
95
|
+
"parser_versions",
|
|
96
|
+
"chunker_version",
|
|
97
|
+
"normalisation_version",
|
|
98
|
+
"created_at_utc",
|
|
99
|
+
"updated_at_utc",
|
|
100
|
+
},
|
|
101
|
+
"sources": {
|
|
102
|
+
"source_pk",
|
|
103
|
+
"path",
|
|
104
|
+
"source_type",
|
|
105
|
+
"sha256",
|
|
106
|
+
"size_bytes",
|
|
107
|
+
"mtime_ns",
|
|
108
|
+
"parser_name",
|
|
109
|
+
"parser_version",
|
|
110
|
+
},
|
|
111
|
+
"pages": {"page_id", "source_pk", "physical_page", "text"},
|
|
112
|
+
"chunks": {"chunk_id", "source_pk", "logical_key", "body", "semantic_input_hash"},
|
|
113
|
+
"embeddings": {"embedding_id", "model_id", "semantic_input_hash"},
|
|
114
|
+
"chunk_embeddings": {"chunk_id", "embedding_id"},
|
|
115
|
+
"assets": {"asset_id", "source_pk", "asset_path", "sha256"},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_REQUIRED_OBJECTS = {
|
|
119
|
+
"tables": set(ORDINARY_TABLES),
|
|
120
|
+
"virtual_tables": {"chunks_fts", "embedding_vectors"},
|
|
121
|
+
"indexes": {
|
|
122
|
+
"chunks_sourcepk_idx",
|
|
123
|
+
"chunks_source_logical_idx",
|
|
124
|
+
"chunks_semantic_hash_idx",
|
|
125
|
+
"chunk_embeddings_embedding_idx",
|
|
126
|
+
"assets_sourcepk_page_idx",
|
|
127
|
+
},
|
|
128
|
+
"triggers": {"chunks_ai", "chunks_ad", "chunks_au"},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class IncompatibleDatabaseError(RuntimeError):
|
|
133
|
+
"""Raised when a database path already holds data this tool did not create."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def connect(database_path: Path) -> sqlite3.Connection:
|
|
137
|
+
"""Open a connection with sqlite-vec loaded and foreign keys enforced."""
|
|
138
|
+
|
|
139
|
+
database_path = Path(database_path)
|
|
140
|
+
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
# True autocommit: every statement outside an explicit BEGIN commits
|
|
142
|
+
# immediately, and nothing implicitly opens a transaction. sync.py relies
|
|
143
|
+
# on this so its single explicit ``BEGIN IMMEDIATE`` block is the only
|
|
144
|
+
# write transaction in the whole synchronisation lifecycle.
|
|
145
|
+
conn = sqlite3.connect(database_path, isolation_level=None)
|
|
146
|
+
conn.row_factory = sqlite3.Row
|
|
147
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
148
|
+
vector_backend.ensure_loaded(conn)
|
|
149
|
+
return conn
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def connect_existing(database_path: Path, *, read_only: bool) -> sqlite3.Connection:
|
|
153
|
+
"""Open an existing database without creating parents, files, or schema."""
|
|
154
|
+
|
|
155
|
+
database_path = Path(database_path)
|
|
156
|
+
if not database_path.is_file():
|
|
157
|
+
raise FileNotFoundError(f"database does not exist: {database_path}")
|
|
158
|
+
mode = "ro" if read_only else "rw"
|
|
159
|
+
uri = database_path.resolve().as_uri() + f"?mode={mode}"
|
|
160
|
+
conn = sqlite3.connect(uri, uri=True, isolation_level=None)
|
|
161
|
+
conn.row_factory = sqlite3.Row
|
|
162
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
163
|
+
vector_backend.ensure_loaded(conn)
|
|
164
|
+
return conn
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ensure_schema(conn: sqlite3.Connection) -> KnowledgeBaseConfig:
|
|
168
|
+
"""Create the schema if the database is empty, or load its config.
|
|
169
|
+
|
|
170
|
+
Raises ``IncompatibleDatabaseError`` if the database already has tables
|
|
171
|
+
that are not part of this schema.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
existing = {
|
|
175
|
+
row["name"]
|
|
176
|
+
for row in conn.execute(
|
|
177
|
+
"SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
if "kb_config" in existing:
|
|
181
|
+
_validate_schema_objects(conn)
|
|
182
|
+
config = load_config(conn)
|
|
183
|
+
if config.schema_version != SCHEMA_VERSION:
|
|
184
|
+
raise IncompatibleDatabaseError(
|
|
185
|
+
f"database schema version {config.schema_version} is incompatible; "
|
|
186
|
+
f"expected version {SCHEMA_VERSION}. Select a fresh --database path "
|
|
187
|
+
"and re-run sync to rebuild the database; automatic migration is "
|
|
188
|
+
"not supported"
|
|
189
|
+
)
|
|
190
|
+
return config
|
|
191
|
+
|
|
192
|
+
unknown = {
|
|
193
|
+
name
|
|
194
|
+
for name in existing
|
|
195
|
+
if name not in _KNOWN_TABLES and not name.startswith(_KNOWN_TABLE_PREFIXES)
|
|
196
|
+
}
|
|
197
|
+
if unknown:
|
|
198
|
+
raise IncompatibleDatabaseError(
|
|
199
|
+
"database already contains unrecognised tables "
|
|
200
|
+
f"({sorted(unknown)}); this tool never migrates or reuses an "
|
|
201
|
+
"unrelated database -- choose a fresh --database path for this corpus"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
_create_schema(conn)
|
|
205
|
+
_validate_schema_objects(conn)
|
|
206
|
+
return load_config(conn)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _object_names(conn: sqlite3.Connection, object_type: str) -> set[str]:
|
|
210
|
+
return {
|
|
211
|
+
row["name"]
|
|
212
|
+
for row in conn.execute(
|
|
213
|
+
"SELECT name FROM sqlite_master WHERE type = ?", (object_type,)
|
|
214
|
+
).fetchall()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_schema_objects(conn: sqlite3.Connection) -> None:
|
|
219
|
+
tables = _object_names(conn, "table") | _object_names(conn, "view")
|
|
220
|
+
for table in sorted(
|
|
221
|
+
_REQUIRED_OBJECTS["tables"] | _REQUIRED_OBJECTS["virtual_tables"]
|
|
222
|
+
):
|
|
223
|
+
if table not in tables:
|
|
224
|
+
raise IncompatibleDatabaseError(
|
|
225
|
+
f"schema v{SCHEMA_VERSION} is missing required object {table}"
|
|
226
|
+
)
|
|
227
|
+
indexes = _object_names(conn, "index")
|
|
228
|
+
for index in sorted(_REQUIRED_OBJECTS["indexes"]):
|
|
229
|
+
if index not in indexes:
|
|
230
|
+
raise IncompatibleDatabaseError(
|
|
231
|
+
f"schema v{SCHEMA_VERSION} is missing required index {index}"
|
|
232
|
+
)
|
|
233
|
+
triggers = _object_names(conn, "trigger")
|
|
234
|
+
for trigger in sorted(_REQUIRED_OBJECTS["triggers"]):
|
|
235
|
+
if trigger not in triggers:
|
|
236
|
+
raise IncompatibleDatabaseError(
|
|
237
|
+
f"schema v{SCHEMA_VERSION} is missing required trigger {trigger}"
|
|
238
|
+
)
|
|
239
|
+
for table, required_columns in sorted(_REQUIRED_COLUMNS.items()):
|
|
240
|
+
columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
241
|
+
missing = required_columns - columns
|
|
242
|
+
if missing:
|
|
243
|
+
raise IncompatibleDatabaseError(
|
|
244
|
+
f"schema v{SCHEMA_VERSION} table {table} is missing required column(s) {', '.join(sorted(missing))}"
|
|
245
|
+
)
|
|
246
|
+
absolute = conn.execute(
|
|
247
|
+
"SELECT asset_path FROM assets WHERE asset_path GLOB '/*' LIMIT 1"
|
|
248
|
+
).fetchone()
|
|
249
|
+
if absolute is not None:
|
|
250
|
+
raise IncompatibleDatabaseError(
|
|
251
|
+
"schema v3 database contains absolute asset paths and must be rebuilt"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _create_schema(conn: sqlite3.Connection) -> None:
|
|
256
|
+
sqlite_vec_version = vector_backend.version(conn)
|
|
257
|
+
now = utc_now()
|
|
258
|
+
parser_versions = {
|
|
259
|
+
"pdf": f"{PARSER_NAME_PDF}@{PARSER_VERSION_PDF}",
|
|
260
|
+
"markdown": f"{PARSER_NAME_MARKDOWN}@{PARSER_VERSION_MARKDOWN}",
|
|
261
|
+
"workbook": f"{PARSER_NAME_WORKBOOK}@{PARSER_VERSION_WORKBOOK}",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
265
|
+
try:
|
|
266
|
+
conn.execute(
|
|
267
|
+
"""
|
|
268
|
+
CREATE TABLE kb_config (
|
|
269
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
270
|
+
schema_version INTEGER NOT NULL,
|
|
271
|
+
fts_config TEXT NOT NULL,
|
|
272
|
+
sqlite_vec_version TEXT NOT NULL,
|
|
273
|
+
embedding_model TEXT NOT NULL,
|
|
274
|
+
embedding_revision TEXT NOT NULL,
|
|
275
|
+
embedding_dimensions INTEGER NOT NULL,
|
|
276
|
+
parser_versions TEXT NOT NULL,
|
|
277
|
+
chunker_version TEXT NOT NULL,
|
|
278
|
+
normalisation_version TEXT NOT NULL,
|
|
279
|
+
created_at_utc TEXT NOT NULL,
|
|
280
|
+
updated_at_utc TEXT NOT NULL
|
|
281
|
+
)
|
|
282
|
+
"""
|
|
283
|
+
)
|
|
284
|
+
conn.execute(
|
|
285
|
+
"""
|
|
286
|
+
INSERT INTO kb_config (
|
|
287
|
+
id, schema_version, fts_config, sqlite_vec_version,
|
|
288
|
+
embedding_model, embedding_revision, embedding_dimensions,
|
|
289
|
+
parser_versions, chunker_version, normalisation_version,
|
|
290
|
+
created_at_utc, updated_at_utc
|
|
291
|
+
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
292
|
+
""",
|
|
293
|
+
(
|
|
294
|
+
SCHEMA_VERSION,
|
|
295
|
+
FTS_CONFIG,
|
|
296
|
+
sqlite_vec_version,
|
|
297
|
+
EMBEDDING_MODEL,
|
|
298
|
+
EMBEDDING_REVISION,
|
|
299
|
+
EMBEDDING_DIMENSIONS,
|
|
300
|
+
json.dumps(parser_versions, sort_keys=True),
|
|
301
|
+
CHUNKER_VERSION,
|
|
302
|
+
NORMALISATION_VERSION,
|
|
303
|
+
now,
|
|
304
|
+
now,
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
conn.execute(
|
|
309
|
+
"""
|
|
310
|
+
CREATE TABLE sources (
|
|
311
|
+
source_pk INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
312
|
+
path TEXT NOT NULL UNIQUE,
|
|
313
|
+
source_type TEXT NOT NULL,
|
|
314
|
+
language TEXT,
|
|
315
|
+
canonical_url TEXT,
|
|
316
|
+
capture_manifest_ref TEXT,
|
|
317
|
+
sha256 TEXT NOT NULL,
|
|
318
|
+
size_bytes INTEGER NOT NULL,
|
|
319
|
+
mtime_ns INTEGER NOT NULL,
|
|
320
|
+
parser_name TEXT NOT NULL,
|
|
321
|
+
parser_version TEXT NOT NULL,
|
|
322
|
+
parser_config TEXT NOT NULL,
|
|
323
|
+
chunker_name TEXT NOT NULL,
|
|
324
|
+
chunker_version TEXT NOT NULL,
|
|
325
|
+
chunker_config TEXT NOT NULL,
|
|
326
|
+
normalisation_version TEXT NOT NULL,
|
|
327
|
+
normalisation_config TEXT NOT NULL,
|
|
328
|
+
indexed_at_utc TEXT,
|
|
329
|
+
index_status TEXT NOT NULL
|
|
330
|
+
)
|
|
331
|
+
"""
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
conn.execute(
|
|
335
|
+
"""
|
|
336
|
+
CREATE TABLE pages (
|
|
337
|
+
page_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
338
|
+
source_pk INTEGER NOT NULL REFERENCES sources(source_pk) ON DELETE CASCADE,
|
|
339
|
+
physical_page INTEGER NOT NULL,
|
|
340
|
+
printed_label TEXT,
|
|
341
|
+
text TEXT NOT NULL,
|
|
342
|
+
extraction_method TEXT NOT NULL,
|
|
343
|
+
extraction_status TEXT NOT NULL,
|
|
344
|
+
section_candidates TEXT,
|
|
345
|
+
layout_json TEXT,
|
|
346
|
+
UNIQUE(source_pk, physical_page)
|
|
347
|
+
)
|
|
348
|
+
"""
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
conn.execute(
|
|
352
|
+
"""
|
|
353
|
+
CREATE TABLE chunks (
|
|
354
|
+
chunk_id TEXT PRIMARY KEY,
|
|
355
|
+
source_pk INTEGER NOT NULL REFERENCES sources(source_pk) ON DELETE CASCADE,
|
|
356
|
+
logical_key TEXT NOT NULL,
|
|
357
|
+
ordinal INTEGER NOT NULL,
|
|
358
|
+
heading_path TEXT,
|
|
359
|
+
symbol TEXT,
|
|
360
|
+
body TEXT NOT NULL,
|
|
361
|
+
semantic_input_hash TEXT NOT NULL,
|
|
362
|
+
lexical_hash TEXT NOT NULL,
|
|
363
|
+
content_type TEXT NOT NULL,
|
|
364
|
+
physical_page INTEGER,
|
|
365
|
+
page_start_offset INTEGER,
|
|
366
|
+
page_end_offset INTEGER,
|
|
367
|
+
page_range_start INTEGER,
|
|
368
|
+
page_range_end INTEGER,
|
|
369
|
+
line_start INTEGER,
|
|
370
|
+
line_end INTEGER,
|
|
371
|
+
locator_json TEXT
|
|
372
|
+
)
|
|
373
|
+
"""
|
|
374
|
+
)
|
|
375
|
+
conn.execute("CREATE INDEX chunks_sourcepk_idx ON chunks(source_pk)")
|
|
376
|
+
conn.execute(
|
|
377
|
+
"CREATE INDEX chunks_source_logical_idx ON chunks(source_pk, logical_key)"
|
|
378
|
+
)
|
|
379
|
+
conn.execute(
|
|
380
|
+
"CREATE INDEX chunks_semantic_hash_idx ON chunks(semantic_input_hash)"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
conn.execute(
|
|
384
|
+
"""
|
|
385
|
+
CREATE VIRTUAL TABLE chunks_fts USING fts5(
|
|
386
|
+
body,
|
|
387
|
+
heading_path,
|
|
388
|
+
symbol,
|
|
389
|
+
content='chunks',
|
|
390
|
+
content_rowid='rowid',
|
|
391
|
+
tokenize='unicode61'
|
|
392
|
+
)
|
|
393
|
+
"""
|
|
394
|
+
)
|
|
395
|
+
conn.execute(
|
|
396
|
+
"""
|
|
397
|
+
CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN
|
|
398
|
+
INSERT INTO chunks_fts(rowid, body, heading_path, symbol)
|
|
399
|
+
VALUES (new.rowid, new.body, new.heading_path, new.symbol);
|
|
400
|
+
END
|
|
401
|
+
"""
|
|
402
|
+
)
|
|
403
|
+
conn.execute(
|
|
404
|
+
"""
|
|
405
|
+
CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN
|
|
406
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, body, heading_path, symbol)
|
|
407
|
+
VALUES ('delete', old.rowid, old.body, old.heading_path, old.symbol);
|
|
408
|
+
END
|
|
409
|
+
"""
|
|
410
|
+
)
|
|
411
|
+
conn.execute(
|
|
412
|
+
"""
|
|
413
|
+
CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN
|
|
414
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, body, heading_path, symbol)
|
|
415
|
+
VALUES ('delete', old.rowid, old.body, old.heading_path, old.symbol);
|
|
416
|
+
INSERT INTO chunks_fts(rowid, body, heading_path, symbol)
|
|
417
|
+
VALUES (new.rowid, new.body, new.heading_path, new.symbol);
|
|
418
|
+
END
|
|
419
|
+
"""
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
conn.execute(
|
|
423
|
+
"""
|
|
424
|
+
CREATE TABLE embeddings (
|
|
425
|
+
embedding_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
426
|
+
model_id TEXT NOT NULL,
|
|
427
|
+
semantic_input_hash TEXT NOT NULL,
|
|
428
|
+
created_at_utc TEXT NOT NULL,
|
|
429
|
+
UNIQUE(model_id, semantic_input_hash)
|
|
430
|
+
)
|
|
431
|
+
"""
|
|
432
|
+
)
|
|
433
|
+
conn.execute(
|
|
434
|
+
f"""
|
|
435
|
+
CREATE VIRTUAL TABLE embedding_vectors USING vec0(
|
|
436
|
+
embedding_id INTEGER PRIMARY KEY,
|
|
437
|
+
shard_id TEXT PARTITION KEY,
|
|
438
|
+
vector FLOAT[{EMBEDDING_DIMENSIONS}]
|
|
439
|
+
)
|
|
440
|
+
"""
|
|
441
|
+
)
|
|
442
|
+
conn.execute(
|
|
443
|
+
"""
|
|
444
|
+
CREATE TABLE chunk_embeddings (
|
|
445
|
+
chunk_id TEXT NOT NULL REFERENCES chunks(chunk_id) ON DELETE CASCADE,
|
|
446
|
+
embedding_id INTEGER NOT NULL REFERENCES embeddings(embedding_id) ON DELETE CASCADE,
|
|
447
|
+
PRIMARY KEY (chunk_id, embedding_id)
|
|
448
|
+
)
|
|
449
|
+
"""
|
|
450
|
+
)
|
|
451
|
+
conn.execute(
|
|
452
|
+
"CREATE INDEX chunk_embeddings_embedding_idx ON chunk_embeddings(embedding_id)"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
conn.execute(
|
|
456
|
+
"""
|
|
457
|
+
CREATE TABLE assets (
|
|
458
|
+
asset_id TEXT PRIMARY KEY,
|
|
459
|
+
source_pk INTEGER NOT NULL REFERENCES sources(source_pk) ON DELETE CASCADE,
|
|
460
|
+
physical_page INTEGER NOT NULL,
|
|
461
|
+
asset_path TEXT NOT NULL,
|
|
462
|
+
sha256 TEXT NOT NULL,
|
|
463
|
+
width INTEGER,
|
|
464
|
+
height INTEGER,
|
|
465
|
+
asset_type TEXT NOT NULL,
|
|
466
|
+
label TEXT,
|
|
467
|
+
caption TEXT,
|
|
468
|
+
nearby_text TEXT,
|
|
469
|
+
ocr_text TEXT,
|
|
470
|
+
visual_description TEXT,
|
|
471
|
+
bbox_json TEXT
|
|
472
|
+
)
|
|
473
|
+
"""
|
|
474
|
+
)
|
|
475
|
+
conn.execute(
|
|
476
|
+
"CREATE INDEX assets_sourcepk_page_idx ON assets(source_pk, physical_page)"
|
|
477
|
+
)
|
|
478
|
+
conn.execute("COMMIT")
|
|
479
|
+
except Exception:
|
|
480
|
+
if conn.in_transaction:
|
|
481
|
+
conn.execute("ROLLBACK")
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def load_config(conn: sqlite3.Connection) -> KnowledgeBaseConfig:
|
|
486
|
+
row = conn.execute("SELECT * FROM kb_config WHERE id = 1").fetchone()
|
|
487
|
+
if row is None:
|
|
488
|
+
raise IncompatibleDatabaseError("kb_config table exists but has no row")
|
|
489
|
+
return KnowledgeBaseConfig(
|
|
490
|
+
schema_version=row["schema_version"],
|
|
491
|
+
fts_config=row["fts_config"],
|
|
492
|
+
sqlite_vec_version=row["sqlite_vec_version"],
|
|
493
|
+
embedding_model=row["embedding_model"],
|
|
494
|
+
embedding_revision=row["embedding_revision"],
|
|
495
|
+
embedding_dimensions=row["embedding_dimensions"],
|
|
496
|
+
parser_versions=json.loads(row["parser_versions"]),
|
|
497
|
+
chunker_version=row["chunker_version"],
|
|
498
|
+
normalisation_version=row["normalisation_version"],
|
|
499
|
+
created_at_utc=row["created_at_utc"],
|
|
500
|
+
updated_at_utc=row["updated_at_utc"],
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def touch_config(conn: sqlite3.Connection) -> None:
|
|
505
|
+
conn.execute("UPDATE kb_config SET updated_at_utc = ? WHERE id = 1", (utc_now(),))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def activate_embedding_config(
|
|
509
|
+
conn: sqlite3.Connection,
|
|
510
|
+
*,
|
|
511
|
+
embedding_model: str,
|
|
512
|
+
embedding_revision: str,
|
|
513
|
+
embedding_dimensions: int,
|
|
514
|
+
) -> None:
|
|
515
|
+
conn.execute(
|
|
516
|
+
"""
|
|
517
|
+
UPDATE kb_config
|
|
518
|
+
SET embedding_model = ?, embedding_revision = ?, embedding_dimensions = ?, updated_at_utc = ?
|
|
519
|
+
WHERE id = 1
|
|
520
|
+
""",
|
|
521
|
+
(embedding_model, embedding_revision, embedding_dimensions, utc_now()),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def imgs_dir(database_path: Path) -> Path:
|
|
526
|
+
"""The generated-assets tree, kept next to the database (outside SQLite)."""
|
|
527
|
+
|
|
528
|
+
return Path(database_path).resolve().parent / "imgs"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def resolve_asset_path(database_path: Path, asset_identifier: str) -> Path:
|
|
532
|
+
"""Validate and resolve one stored asset identifier.
|
|
533
|
+
|
|
534
|
+
An identifier is a POSIX path relative to the database parent that begins
|
|
535
|
+
with ``imgs/generations/``. The returned path is a validation snapshot:
|
|
536
|
+
consumers must call this function again immediately before using it when
|
|
537
|
+
an operation lock is held.
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
if not isinstance(asset_identifier, str) or not asset_identifier:
|
|
541
|
+
raise ValueError("asset identifier must be a non-empty string")
|
|
542
|
+
if "\x00" in asset_identifier or "\\" in asset_identifier:
|
|
543
|
+
raise ValueError(f"asset identifier must be a POSIX path: {asset_identifier!r}")
|
|
544
|
+
database_parent = Path(database_path).resolve().parent
|
|
545
|
+
root = database_parent / "imgs"
|
|
546
|
+
root_resolved = root.resolve(strict=False)
|
|
547
|
+
if root.is_symlink():
|
|
548
|
+
raise ValueError(f"asset image root is a symlink: {root}")
|
|
549
|
+
|
|
550
|
+
parts = asset_identifier.split("/")
|
|
551
|
+
if len(parts) < 3 or parts[:2] != ["imgs", "generations"]:
|
|
552
|
+
raise ValueError(f"asset identifier has the wrong root: {asset_identifier!r}")
|
|
553
|
+
if any(not part or part in (".", "..") for part in parts):
|
|
554
|
+
raise ValueError(f"asset identifier is not normalized: {asset_identifier!r}")
|
|
555
|
+
candidate = database_parent.joinpath(*parts)
|
|
556
|
+
|
|
557
|
+
current = database_parent
|
|
558
|
+
for component in candidate.relative_to(database_parent).parts:
|
|
559
|
+
current /= component
|
|
560
|
+
if current.is_symlink():
|
|
561
|
+
raise ValueError(f"asset path contains a symlink: {current}")
|
|
562
|
+
|
|
563
|
+
resolved = candidate.resolve(strict=False)
|
|
564
|
+
try:
|
|
565
|
+
resolved.relative_to(root_resolved)
|
|
566
|
+
except ValueError as exc:
|
|
567
|
+
raise ValueError(
|
|
568
|
+
f"asset path escapes image root: {asset_identifier!r}"
|
|
569
|
+
) from exc
|
|
570
|
+
return candidate
|