[NOTICKET] feat(knowledge): DocumentBrief split + cross-document measure identity
Browse filesTwo changes that belong together, both made BEFORE the authorised extraction run
so the fresh rows land in the right shape and nothing needs re-keying after.
V4-4 - THE DOCUMENT BRIEF SPLIT. The per-document object is now `DocumentBrief`,
`kind='document'`, prefix `b_`, and `domain` is reserved for the scope-level
semantic model composed in src/knowledge_domain/.
Prefix history, and it is not churn: b_ (BriefContext) -> d_ (DomainContext,
2026-09-01) -> b_ again (DocumentBrief, 2026-09-08). The middle move renamed
the object without changing what it held - a per-document card. v4 builds the
real domain context and gives d_ to it, so this one goes back to the name that
describes it. Safe only because nothing is approved against it: an id prefix
change after an expert has ruled is a migration that loses their approvals.
WHAT A DOMAIN IS THE UNIT OF (Rifqi's question, answered in the plan section 3.0).
Per SCOPE - the company - composed across all its documents and runs.
Per ingest run is wrong: a run is an operational event, not a semantic
boundary. Re-running a document would mint a new domain.
Per subdomain is wrong AT THIS TAG GRANULARITY: the single BUMA standard
already spans six subdomains, so keying on them shatters one standard into six
thin domains. Subdomain is a per-term FACET, not a partition - it survives as
a filter on the Tier-1 card, which is progressive disclosure applied twice.
Shaped as (scope_id, domain_key) with domain_key defaulting to "default", so a
company that genuinely needs separable domains later is a value change rather
than a re-key.
CROSS-DOCUMENT MEASURE IDENTITY, which that decision immediately requires.
`term_id` is document-scoped by design - right for review, wrong for a domain -
so two documents defining PA produced two measures: the duplication just fixed
INSIDE one document, reappearing ACROSS documents. Measures now merge on their
canonical surface using the SAME `AbbrevIndex.canonical_key` that clusters
mentions, so there is one normalisation rule applied at both scales.
A DISAGREEMENT IS SURFACED, NEVER RESOLVED. When two documents define a term
differently the measure keeps both accounts with their source documents named
and sets `contested`. Picking a winner is an expert judgement, and the whole
pipeline exists to put that judgement in front of a human rather than make it
for them. Agreeing documents merge silently and do not set the flag.
Everything else unions rather than overwrites: term_ids, source_docs, surface
forms, governing rules and mention counts. The shortest name wins for display,
because "PA" reads better in a card than "Physical of Availability (PA)".
Verification: ruff clean on touched paths; `import main` OK; suite 617 passed /
7 skipped (612 + 5 new cross-document merge tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- docs/knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md +34 -0
- src/knowledge_domain/compose.py +71 -8
- src/knowledge_domain/models.py +27 -1
- src/knowledge_extraction/__init__.py +2 -2
- src/knowledge_extraction/cli.py +4 -2
- src/knowledge_extraction/extract/branches.py +5 -5
- src/knowledge_extraction/ids.py +13 -9
- src/knowledge_extraction/models.py +3 -3
- src/knowledge_extraction/queue/review_queue.py +1 -1
- src/knowledge_extraction/service.py +10 -10
- src/knowledge_ingest/knowledge_store.py +3 -1
- src/knowledge_ingest/pipeline.py +3 -3
|
@@ -109,6 +109,40 @@ DomainContext(scope_id, version)
|
|
| 109 |
as_of, approved_by, coverage ← see §3.3
|
| 110 |
```
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
### 3.1 · `boundary` — the field that makes refusal first-class
|
| 113 |
|
| 114 |
An explicit statement of what the domain does *not* cover. The planner's honest-data-gap path (Q2)
|
|
|
|
| 109 |
as_of, approved_by, coverage ← see §3.3
|
| 110 |
```
|
| 111 |
|
| 112 |
+
### 3.0 · What a domain IS the unit of (decided 2026-09-08)
|
| 113 |
+
|
| 114 |
+
Three candidates were on the table. **The domain is per `scope_id` — the company —
|
| 115 |
+
composed from every approved entry across all its documents and runs.**
|
| 116 |
+
|
| 117 |
+
| Candidate | Verdict |
|
| 118 |
+
|---|---|
|
| 119 |
+
| **Per ingest run** | ✗ A run is an operational event, not a semantic boundary. Re-running a document would mint a new domain, and two batches of one company's documents would produce two domains of the same thing. Runs are already recorded in `knowledge_extraction_runs`; the domain must be stable across them |
|
| 120 |
+
| **Per subdomain** | ✗ **Not at this tag granularity.** The single BUMA standard already spans six subdomains (`production`, `equipment`, `other`, `planning`, `hauling`, `loading`), so keying on them shatters one standard into six thin domains. Subdomain is a per-term **facet**, not a partition |
|
| 121 |
+
| **Per scope, composed across documents** | ✅ Stable, matches how a company thinks about "our knowledge", and matches the MCP product — a client connects to *their* domain |
|
| 122 |
+
|
| 123 |
+
**Subdomain tags survive as a filter, not a key.** A Tier-1 card can be narrowed to the subdomains a
|
| 124 |
+
question touches, which is progressive disclosure applied a second time and the natural answer to
|
| 125 |
+
corpus growth.
|
| 126 |
+
|
| 127 |
+
**Designed so it can subdivide later without a migration.** A company may genuinely have separable
|
| 128 |
+
domains eventually — mining operations and HR policy share no vocabulary. The identity is therefore
|
| 129 |
+
shaped as `(scope_id, domain_key)` with `domain_key` defaulting to `"default"`, so splitting is a
|
| 130 |
+
value change rather than a re-key.
|
| 131 |
+
|
| 132 |
+
### 3.0b · Cross-document measure identity
|
| 133 |
+
|
| 134 |
+
`term_id` is **document-scoped by design** — that is right for review, and wrong for a domain
|
| 135 |
+
context. Two documents both defining PA produce two `term_id`s and therefore two measures: the exact
|
| 136 |
+
duplication fixed inside one document on 2026-09-08, reappearing across documents.
|
| 137 |
+
|
| 138 |
+
**Measures are therefore merged at composition time on their canonical surface**, reusing the same
|
| 139 |
+
`AbbrevIndex.canonical_key` that clusters mentions — one normalisation rule, applied at both scales.
|
| 140 |
+
|
| 141 |
+
**A disagreement is surfaced, never silently resolved.** When two documents define the same term
|
| 142 |
+
differently the measure keeps both, marked, with its sources named. Picking a winner is an expert
|
| 143 |
+
judgement, and the whole pipeline exists to put that judgement in front of a human rather than make
|
| 144 |
+
it for them.
|
| 145 |
+
|
| 146 |
### 3.1 · `boundary` — the field that makes refusal first-class
|
| 147 |
|
| 148 |
An explicit statement of what the domain does *not* cover. The planner's honest-data-gap path (Q2)
|
|
@@ -20,12 +20,14 @@ from src.knowledge_domain.models import (
|
|
| 20 |
Authority,
|
| 21 |
Conventions,
|
| 22 |
Coverage,
|
|
|
|
| 23 |
DomainContext,
|
| 24 |
Identity,
|
| 25 |
Measure,
|
| 26 |
SourceRef,
|
| 27 |
Unit,
|
| 28 |
)
|
|
|
|
| 29 |
from src.knowledge_ingest.knowledge_store import KnowledgeStore
|
| 30 |
from src.middlewares.logging import get_logger
|
| 31 |
|
|
@@ -135,7 +137,17 @@ class DomainComposer:
|
|
| 135 |
else:
|
| 136 |
policies.append(row.entity_id)
|
| 137 |
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
for row in _usable(glossary):
|
| 140 |
payload = _payload(row)
|
| 141 |
formula_id = payload.get("defining_formula_id")
|
|
@@ -143,12 +155,25 @@ class DomainComposer:
|
|
| 143 |
if not any(payload.get(k) for k in _MIN_USEFUL) and not formula:
|
| 144 |
continue
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
surface_forms=_surface_forms(payload),
|
| 151 |
-
definition=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
unit=(formula or {}).get("unit"),
|
| 153 |
formula_id=formula_id,
|
| 154 |
formula_latex=(formula or {}).get("formula_latex"),
|
|
@@ -157,12 +182,50 @@ class DomainComposer:
|
|
| 157 |
for v in ((formula or {}).get("variables") or [])
|
| 158 |
if v.get("symbol")
|
| 159 |
],
|
| 160 |
-
governed_by=governs.get(row.entity_id, []),
|
| 161 |
catalog_binding=None,
|
| 162 |
mention_count=row.mention_count or 0,
|
| 163 |
-
approved=
|
| 164 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
# Most-established first: a term the corpus keeps returning to is the one
|
| 168 |
# a reader should meet first, and it is the sensible truncation order
|
|
|
|
| 20 |
Authority,
|
| 21 |
Conventions,
|
| 22 |
Coverage,
|
| 23 |
+
Definition,
|
| 24 |
DomainContext,
|
| 25 |
Identity,
|
| 26 |
Measure,
|
| 27 |
SourceRef,
|
| 28 |
Unit,
|
| 29 |
)
|
| 30 |
+
from src.knowledge_extraction.cluster.normalize import AbbrevIndex, normalize
|
| 31 |
from src.knowledge_ingest.knowledge_store import KnowledgeStore
|
| 32 |
from src.middlewares.logging import get_logger
|
| 33 |
|
|
|
|
| 137 |
else:
|
| 138 |
policies.append(row.entity_id)
|
| 139 |
|
| 140 |
+
# `term_id` is document-scoped by design - right for review, wrong for a
|
| 141 |
+
# domain. Two documents defining PA would otherwise be two measures, the
|
| 142 |
+
# same duplication fixed inside one document on 2026-09-08 reappearing
|
| 143 |
+
# across documents. Merge on the canonical surface, using the SAME
|
| 144 |
+
# normalisation that clusters mentions: one rule at both scales.
|
| 145 |
+
index = AbbrevIndex([])
|
| 146 |
+
index.learn_inline(
|
| 147 |
+
form for row in _usable(glossary) for form in _surface_forms(_payload(row))
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
merged: dict[str, Measure] = {}
|
| 151 |
for row in _usable(glossary):
|
| 152 |
payload = _payload(row)
|
| 153 |
formula_id = payload.get("defining_formula_id")
|
|
|
|
| 155 |
if not any(payload.get(k) for k in _MIN_USEFUL) and not formula:
|
| 156 |
continue
|
| 157 |
|
| 158 |
+
name = payload.get("term") or row.label or row.entity_id
|
| 159 |
+
key = index.canonical_key(name) or normalize(name) or row.entity_id
|
| 160 |
+
approved = row.entity_id in approved_ids
|
| 161 |
+
definition = payload.get("definition")
|
| 162 |
+
|
| 163 |
+
existing = merged.get(key)
|
| 164 |
+
if existing is None:
|
| 165 |
+
merged[key] = Measure(
|
| 166 |
+
key=key,
|
| 167 |
+
term_ids=[row.entity_id],
|
| 168 |
+
name=name,
|
| 169 |
surface_forms=_surface_forms(payload),
|
| 170 |
+
definition=definition,
|
| 171 |
+
definitions=(
|
| 172 |
+
[Definition(text=definition, doc_id=row.doc_id,
|
| 173 |
+
term_id=row.entity_id, approved=approved)]
|
| 174 |
+
if definition else []
|
| 175 |
+
),
|
| 176 |
+
source_docs=[row.doc_id],
|
| 177 |
unit=(formula or {}).get("unit"),
|
| 178 |
formula_id=formula_id,
|
| 179 |
formula_latex=(formula or {}).get("formula_latex"),
|
|
|
|
| 182 |
for v in ((formula or {}).get("variables") or [])
|
| 183 |
if v.get("symbol")
|
| 184 |
],
|
| 185 |
+
governed_by=list(governs.get(row.entity_id, [])),
|
| 186 |
catalog_binding=None,
|
| 187 |
mention_count=row.mention_count or 0,
|
| 188 |
+
approved=approved,
|
| 189 |
)
|
| 190 |
+
continue
|
| 191 |
+
|
| 192 |
+
# Second document, same measure. Union everything; never overwrite.
|
| 193 |
+
existing.term_ids.append(row.entity_id)
|
| 194 |
+
if row.doc_id not in existing.source_docs:
|
| 195 |
+
existing.source_docs.append(row.doc_id)
|
| 196 |
+
for form in _surface_forms(payload):
|
| 197 |
+
if form not in existing.surface_forms:
|
| 198 |
+
existing.surface_forms.append(form)
|
| 199 |
+
existing.governed_by.extend(
|
| 200 |
+
r for r in governs.get(row.entity_id, []) if r not in existing.governed_by
|
| 201 |
+
)
|
| 202 |
+
existing.mention_count += row.mention_count or 0
|
| 203 |
+
existing.approved = existing.approved or approved
|
| 204 |
+
# Prefer the shortest name - "PA" reads better in a card than
|
| 205 |
+
# "Physical of Availability (PA)".
|
| 206 |
+
if len(name) < len(existing.name):
|
| 207 |
+
existing.name = name
|
| 208 |
+
existing.formula_id = existing.formula_id or formula_id
|
| 209 |
+
existing.formula_latex = existing.formula_latex or (formula or {}).get(
|
| 210 |
+
"formula_latex"
|
| 211 |
)
|
| 212 |
+
existing.unit = existing.unit or (formula or {}).get("unit")
|
| 213 |
+
|
| 214 |
+
if definition:
|
| 215 |
+
existing.definitions.append(
|
| 216 |
+
Definition(text=definition, doc_id=row.doc_id,
|
| 217 |
+
term_id=row.entity_id, approved=approved)
|
| 218 |
+
)
|
| 219 |
+
# A DISAGREEMENT is the finding, not a problem to resolve. Two
|
| 220 |
+
# documents saying different things about one measure is exactly
|
| 221 |
+
# what an expert must rule on, so both are kept and the measure
|
| 222 |
+
# is marked rather than one silently winning.
|
| 223 |
+
distinct = {d.text.strip() for d in existing.definitions}
|
| 224 |
+
existing.contested = len(distinct) > 1
|
| 225 |
+
if existing.definition is None:
|
| 226 |
+
existing.definition = definition
|
| 227 |
+
|
| 228 |
+
measures = list(merged.values())
|
| 229 |
|
| 230 |
# Most-established first: a term the corpus keeps returning to is the one
|
| 231 |
# a reader should meet first, and it is the sensible truncation order
|
|
@@ -30,6 +30,20 @@ from datetime import datetime
|
|
| 30 |
from pydantic import BaseModel, Field
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
class Measure(BaseModel):
|
| 34 |
"""One thing the business counts, assembled from three entry kinds.
|
| 35 |
|
|
@@ -39,7 +53,13 @@ class Measure(BaseModel):
|
|
| 39 |
they are something a planner can act on.
|
| 40 |
"""
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
name: str
|
| 44 |
# Every way the documents write it, including the ones that disagree with
|
| 45 |
# each other - the BUMA standard heads a section "Physical *of* Availability
|
|
@@ -47,6 +67,12 @@ class Measure(BaseModel):
|
|
| 47 |
# disagreement is a locked product decision, so both forms travel together.
|
| 48 |
surface_forms: list[str] = Field(default_factory=list)
|
| 49 |
definition: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
unit: str | None = None
|
| 51 |
grain: str | None = None
|
| 52 |
formula_id: str | None = None
|
|
|
|
| 30 |
from pydantic import BaseModel, Field
|
| 31 |
|
| 32 |
|
| 33 |
+
class Definition(BaseModel):
|
| 34 |
+
"""One document's account of a measure, with the document that said it.
|
| 35 |
+
|
| 36 |
+
A list of these, rather than a single winning string, because when two
|
| 37 |
+
documents disagree the disagreement IS the finding. Picking a winner is an
|
| 38 |
+
expert judgement and the pipeline exists to put that in front of a human.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
text: str
|
| 42 |
+
doc_id: str
|
| 43 |
+
term_id: str
|
| 44 |
+
approved: bool = False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
class Measure(BaseModel):
|
| 48 |
"""One thing the business counts, assembled from three entry kinds.
|
| 49 |
|
|
|
|
| 53 |
they are something a planner can act on.
|
| 54 |
"""
|
| 55 |
|
| 56 |
+
# Stable across documents: the canonical surface, normalised by the same
|
| 57 |
+
# rule that clusters mentions inside one document. `term_id` is
|
| 58 |
+
# document-scoped by design, which is right for review and wrong here - two
|
| 59 |
+
# documents defining PA would otherwise be two measures.
|
| 60 |
+
key: str
|
| 61 |
+
# Every document-scoped id that contributed, for provenance back to review.
|
| 62 |
+
term_ids: list[str] = Field(default_factory=list)
|
| 63 |
name: str
|
| 64 |
# Every way the documents write it, including the ones that disagree with
|
| 65 |
# each other - the BUMA standard heads a section "Physical *of* Availability
|
|
|
|
| 67 |
# disagreement is a locked product decision, so both forms travel together.
|
| 68 |
surface_forms: list[str] = Field(default_factory=list)
|
| 69 |
definition: str | None = None
|
| 70 |
+
# Populated only when documents DISAGREE. One entry means one account and
|
| 71 |
+
# `definition` carries it; more than one means the measure is contested and
|
| 72 |
+
# a consumer must not pick silently.
|
| 73 |
+
definitions: list[Definition] = Field(default_factory=list)
|
| 74 |
+
contested: bool = False
|
| 75 |
+
source_docs: list[str] = Field(default_factory=list)
|
| 76 |
unit: str | None = None
|
| 77 |
grain: str | None = None
|
| 78 |
formula_id: str | None = None
|
|
@@ -25,7 +25,7 @@ from .models import (
|
|
| 25 |
CallUsage,
|
| 26 |
Chunk,
|
| 27 |
ClusterResult,
|
| 28 |
-
|
| 29 |
FilterResult,
|
| 30 |
FormulaEntry,
|
| 31 |
GlossaryEntry,
|
|
@@ -41,7 +41,7 @@ from .service import ExtractionResult, build_clusters, estimate_cost, extract_al
|
|
| 41 |
|
| 42 |
__all__ = [
|
| 43 |
"AbbrevPair",
|
| 44 |
-
"
|
| 45 |
"CallUsage",
|
| 46 |
"Chunk",
|
| 47 |
"ClusterResult",
|
|
|
|
| 25 |
CallUsage,
|
| 26 |
Chunk,
|
| 27 |
ClusterResult,
|
| 28 |
+
DocumentBrief,
|
| 29 |
FilterResult,
|
| 30 |
FormulaEntry,
|
| 31 |
GlossaryEntry,
|
|
|
|
| 41 |
|
| 42 |
__all__ = [
|
| 43 |
"AbbrevPair",
|
| 44 |
+
"DocumentBrief",
|
| 45 |
"CallUsage",
|
| 46 |
"Chunk",
|
| 47 |
"ClusterResult",
|
|
@@ -224,8 +224,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 224 |
_dump(args.out_dir / "formulas.json", result.formulas)
|
| 225 |
_dump(args.out_dir / "review_queue.json", result.review_queue)
|
| 226 |
_dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
|
| 227 |
-
if result.
|
| 228 |
-
|
|
|
|
|
|
|
| 229 |
_dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
|
| 230 |
print(f"[write ] {args.out_dir}")
|
| 231 |
return 0
|
|
|
|
| 224 |
_dump(args.out_dir / "formulas.json", result.formulas)
|
| 225 |
_dump(args.out_dir / "review_queue.json", result.review_queue)
|
| 226 |
_dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
|
| 227 |
+
if result.brief:
|
| 228 |
+
# Named for what it holds: a per-document brief. The scope-level
|
| 229 |
+
# DomainContext is composed elsewhere (src/knowledge_domain/).
|
| 230 |
+
_dump(args.out_dir / "document_brief.json", result.brief)
|
| 231 |
_dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
|
| 232 |
print(f"[write ] {args.out_dir}")
|
| 233 |
return 0
|
|
@@ -13,11 +13,11 @@ response must not abort a corpus-scale run that has already paid for parsing.
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
from ...middlewares.logging import get_logger
|
| 16 |
-
from ..ids import
|
| 17 |
from ..models import (
|
| 18 |
CallUsage,
|
| 19 |
Chunk,
|
| 20 |
-
|
| 21 |
FormulaEntry,
|
| 22 |
FormulaVariable,
|
| 23 |
GlossaryEntry,
|
|
@@ -208,7 +208,7 @@ def extract_formula(
|
|
| 208 |
|
| 209 |
def extract_summary(
|
| 210 |
chunks: list[Chunk], extractor, doc_id: str
|
| 211 |
-
) -> tuple[
|
| 212 |
"""Whole-document summary — the quiet cost risk. Few calls, but a large
|
| 213 |
share of all input tokens, because summarisation cannot be filtered: it
|
| 214 |
needs the whole document.
|
|
@@ -228,8 +228,8 @@ def extract_summary(
|
|
| 228 |
return None, result.usage
|
| 229 |
|
| 230 |
return (
|
| 231 |
-
|
| 232 |
-
|
| 233 |
title=draft.title,
|
| 234 |
purpose_verbatim=draft.purpose_verbatim,
|
| 235 |
# `outline` is derived from the chunks by the caller, not asked of
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
from ...middlewares.logging import get_logger
|
| 16 |
+
from ..ids import brief_id, formula_id, rule_id, term_id
|
| 17 |
from ..models import (
|
| 18 |
CallUsage,
|
| 19 |
Chunk,
|
| 20 |
+
DocumentBrief,
|
| 21 |
FormulaEntry,
|
| 22 |
FormulaVariable,
|
| 23 |
GlossaryEntry,
|
|
|
|
| 208 |
|
| 209 |
def extract_summary(
|
| 210 |
chunks: list[Chunk], extractor, doc_id: str
|
| 211 |
+
) -> tuple[DocumentBrief | None, CallUsage]:
|
| 212 |
"""Whole-document summary — the quiet cost risk. Few calls, but a large
|
| 213 |
share of all input tokens, because summarisation cannot be filtered: it
|
| 214 |
needs the whole document.
|
|
|
|
| 228 |
return None, result.usage
|
| 229 |
|
| 230 |
return (
|
| 231 |
+
DocumentBrief(
|
| 232 |
+
brief_id=brief_id(doc_id),
|
| 233 |
title=draft.title,
|
| 234 |
purpose_verbatim=draft.purpose_verbatim,
|
| 235 |
# `outline` is derived from the chunks by the caller, not asked of
|
|
@@ -71,13 +71,17 @@ def rule_id(doc_id: str, chunk_id: str, char_start: int) -> str:
|
|
| 71 |
return f"r_{_digest(doc_id, chunk_id, str(char_start))}"
|
| 72 |
|
| 73 |
|
| 74 |
-
def
|
| 75 |
-
"""
|
| 76 |
-
|
| 77 |
-
Prefix
|
| 78 |
-
(2026-09-01)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
"""
|
| 83 |
-
return f"
|
|
|
|
| 71 |
return f"r_{_digest(doc_id, chunk_id, str(char_start))}"
|
| 72 |
|
| 73 |
|
| 74 |
+
def brief_id(doc_id: str) -> str:
|
| 75 |
+
"""Identity of one document's brief, so the document identifies it.
|
| 76 |
+
|
| 77 |
+
Prefix history, and it is not churn: `b_` (BriefContext) -> `d_`
|
| 78 |
+
(DomainContext, 2026-09-01) -> `b_` again (DocumentBrief, 2026-09-08). The
|
| 79 |
+
middle move renamed the object without changing what it held - a per-document
|
| 80 |
+
card. v4 builds the real scope-level `DomainContext` and gives `d_` to it, so
|
| 81 |
+
this one goes back to the name and prefix that describe it.
|
| 82 |
+
|
| 83 |
+
Safe to move only because nothing is approved against it: an id prefix change
|
| 84 |
+
after an expert has ruled is a migration that loses their approvals, which is
|
| 85 |
+
the exact failure this whole scheme exists to prevent.
|
| 86 |
"""
|
| 87 |
+
return f"b_{_digest(doc_id)}"
|
|
@@ -92,7 +92,7 @@ class Chunk(BaseModel):
|
|
| 92 |
# headings yields none, and fixtures written before this crossed the seam
|
| 93 |
# carry nothing.
|
| 94 |
#
|
| 95 |
-
# The `
|
| 96 |
# reason they now cross: the document's own structure is the one piece of
|
| 97 |
# domain knowledge that needs no model at all, and extraction was dropping
|
| 98 |
# it on the floor at the adapter — the same way it once dropped `latex`.
|
|
@@ -355,7 +355,7 @@ class KeyParameter(BaseModel):
|
|
| 355 |
term_id: str | None = None
|
| 356 |
|
| 357 |
|
| 358 |
-
class
|
| 359 |
"""Whole-document domain context. The only branch that cannot be
|
| 360 |
span-checked — a plausible summary is indistinguishable from a correct one,
|
| 361 |
which is why it belongs on the larger model tier when one is available.
|
|
@@ -373,7 +373,7 @@ class DomainContext(BaseModel):
|
|
| 373 |
and `scope` overlapped `purpose`. What remains is what a reviewer needs to
|
| 374 |
orient before working the queue."""
|
| 375 |
|
| 376 |
-
|
| 377 |
title: str | None = None
|
| 378 |
|
| 379 |
# The document's OWN statement of purpose, quoted verbatim - not a summary
|
|
|
|
| 92 |
# headings yields none, and fixtures written before this crossed the seam
|
| 93 |
# carry nothing.
|
| 94 |
#
|
| 95 |
+
# The `DocumentBrief.outline` is derived from these, which is the whole
|
| 96 |
# reason they now cross: the document's own structure is the one piece of
|
| 97 |
# domain knowledge that needs no model at all, and extraction was dropping
|
| 98 |
# it on the floor at the adapter — the same way it once dropped `latex`.
|
|
|
|
| 355 |
term_id: str | None = None
|
| 356 |
|
| 357 |
|
| 358 |
+
class DocumentBrief(BaseModel):
|
| 359 |
"""Whole-document domain context. The only branch that cannot be
|
| 360 |
span-checked — a plausible summary is indistinguishable from a correct one,
|
| 361 |
which is why it belongs on the larger model tier when one is available.
|
|
|
|
| 373 |
and `scope` overlapped `purpose`. What remains is what a reviewer needs to
|
| 374 |
orient before working the queue."""
|
| 375 |
|
| 376 |
+
brief_id: str
|
| 377 |
title: str | None = None
|
| 378 |
|
| 379 |
# The document's OWN statement of purpose, quoted verbatim - not a summary
|
|
@@ -94,7 +94,7 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
|
|
| 94 |
# The decision attaches to the domain entry plus the surface —
|
| 95 |
# there is no term_id, which is the entire point of the row.
|
| 96 |
"term_id": None,
|
| 97 |
-
"
|
| 98 |
"term": parameter.get("surface"),
|
| 99 |
"definition": None,
|
| 100 |
"source_wording": None,
|
|
|
|
| 94 |
# The decision attaches to the domain entry plus the surface —
|
| 95 |
# there is no term_id, which is the entire point of the row.
|
| 96 |
"term_id": None,
|
| 97 |
+
"brief_id": (domain or {}).get("brief_id"),
|
| 98 |
"term": parameter.get("surface"),
|
| 99 |
"definition": None,
|
| 100 |
"source_wording": None,
|
|
@@ -53,7 +53,7 @@ class ExtractionResult:
|
|
| 53 |
self.glossary: list[dict] = []
|
| 54 |
self.rules: list[dict] = []
|
| 55 |
self.formulas: list[dict] = []
|
| 56 |
-
self.
|
| 57 |
self.review_queue: list[dict] = []
|
| 58 |
self.rejected: list[RejectedField] = []
|
| 59 |
self.usages: list[CallUsage] = []
|
|
@@ -131,7 +131,7 @@ def extract_all(
|
|
| 131 |
|
| 132 |
# Links resolve only once every branch has produced entries — a reference
|
| 133 |
# cannot be made to something that does not exist yet.
|
| 134 |
-
out.links = link_all(out.glossary, out.rules, out.formulas, clustered, out.
|
| 135 |
|
| 136 |
out.glossary = diff_glossary(out.glossary, active_glossary or [])
|
| 137 |
|
|
@@ -142,7 +142,7 @@ def extract_all(
|
|
| 142 |
# property anything guarantees.
|
| 143 |
_finalise_domain(out)
|
| 144 |
|
| 145 |
-
out.review_queue = build_queue(out.glossary, out.
|
| 146 |
|
| 147 |
prompt, cached, completion = out.total_tokens
|
| 148 |
logger.info(
|
|
@@ -263,7 +263,7 @@ def _finalise_domain(out: ExtractionResult) -> None:
|
|
| 263 |
run, or may have failed schema validation, and the other three artifacts
|
| 264 |
stand on their own.
|
| 265 |
"""
|
| 266 |
-
if out.
|
| 267 |
return
|
| 268 |
|
| 269 |
counts: dict[str, int] = {}
|
|
@@ -271,12 +271,12 @@ def _finalise_domain(out: ExtractionResult) -> None:
|
|
| 271 |
for tag in entry.get("subdomain_tags") or []:
|
| 272 |
counts[tag] = counts.get(tag, 0) + 1
|
| 273 |
|
| 274 |
-
out.
|
| 275 |
tag for tag, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
| 276 |
]
|
| 277 |
-
out.
|
| 278 |
-
out.
|
| 279 |
-
out.
|
| 280 |
|
| 281 |
|
| 282 |
def _document_outline(chunks) -> list[str]:
|
|
@@ -320,7 +320,7 @@ def _run_summary(doc, extractor, out) -> None:
|
|
| 320 |
# The haystack is the WHOLE document: unlike the other branches this one is
|
| 321 |
# given every chunk, so a quote may legitimately come from anywhere in it.
|
| 322 |
source = evidence_text([c.chunk_id for c in doc.chunks], doc.chunks)
|
| 323 |
-
entry, rejections = validate_entry(entry, "summary", source, entry.
|
| 324 |
out.rejected.extend(rejections)
|
| 325 |
|
| 326 |
-
out.
|
|
|
|
| 53 |
self.glossary: list[dict] = []
|
| 54 |
self.rules: list[dict] = []
|
| 55 |
self.formulas: list[dict] = []
|
| 56 |
+
self.brief: dict | None = None
|
| 57 |
self.review_queue: list[dict] = []
|
| 58 |
self.rejected: list[RejectedField] = []
|
| 59 |
self.usages: list[CallUsage] = []
|
|
|
|
| 131 |
|
| 132 |
# Links resolve only once every branch has produced entries — a reference
|
| 133 |
# cannot be made to something that does not exist yet.
|
| 134 |
+
out.links = link_all(out.glossary, out.rules, out.formulas, clustered, out.brief)
|
| 135 |
|
| 136 |
out.glossary = diff_glossary(out.glossary, active_glossary or [])
|
| 137 |
|
|
|
|
| 142 |
# property anything guarantees.
|
| 143 |
_finalise_domain(out)
|
| 144 |
|
| 145 |
+
out.review_queue = build_queue(out.glossary, out.brief)
|
| 146 |
|
| 147 |
prompt, cached, completion = out.total_tokens
|
| 148 |
logger.info(
|
|
|
|
| 263 |
run, or may have failed schema validation, and the other three artifacts
|
| 264 |
stand on their own.
|
| 265 |
"""
|
| 266 |
+
if out.brief is None:
|
| 267 |
return
|
| 268 |
|
| 269 |
counts: dict[str, int] = {}
|
|
|
|
| 271 |
for tag in entry.get("subdomain_tags") or []:
|
| 272 |
counts[tag] = counts.get(tag, 0) + 1
|
| 273 |
|
| 274 |
+
out.brief["subdomains"] = [
|
| 275 |
tag for tag, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
| 276 |
]
|
| 277 |
+
out.brief["n_terms"] = len(out.glossary)
|
| 278 |
+
out.brief["n_formulas"] = len(out.formulas)
|
| 279 |
+
out.brief["n_rules"] = len(out.rules)
|
| 280 |
|
| 281 |
|
| 282 |
def _document_outline(chunks) -> list[str]:
|
|
|
|
| 320 |
# The haystack is the WHOLE document: unlike the other branches this one is
|
| 321 |
# given every chunk, so a quote may legitimately come from anywhere in it.
|
| 322 |
source = evidence_text([c.chunk_id for c in doc.chunks], doc.chunks)
|
| 323 |
+
entry, rejections = validate_entry(entry, "summary", source, entry.brief_id)
|
| 324 |
out.rejected.extend(rejections)
|
| 325 |
|
| 326 |
+
out.brief = entry.model_dump(mode="json")
|
|
@@ -54,7 +54,9 @@ _ENTRY_KINDS: dict[str, tuple[str, str]] = {
|
|
| 54 |
"glossary": ("term_id", "term"),
|
| 55 |
"rule": ("rule_id", "statement"),
|
| 56 |
"formula": ("formula_id", "name"),
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
|
|
|
|
| 54 |
"glossary": ("term_id", "term"),
|
| 55 |
"rule": ("rule_id", "statement"),
|
| 56 |
"formula": ("formula_id", "name"),
|
| 57 |
+
# The per-document brief. `domain` is reserved for the scope-level
|
| 58 |
+
# semantic model composed in `src/knowledge_domain/` (v4).
|
| 59 |
+
"document": ("brief_id", "title"),
|
| 60 |
}
|
| 61 |
|
| 62 |
|
|
@@ -68,9 +68,9 @@ _EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge")
|
|
| 68 |
# the run row so a later comparison knows what was actually ATTEMPTED rather
|
| 69 |
# than inferring it from what happened to appear.
|
| 70 |
#
|
| 71 |
-
# Note "summary" is the branch that produces the
|
| 72 |
# its original name through the v3 rename, while the entity and the entry `kind`
|
| 73 |
-
# are both "
|
| 74 |
BRANCHES = ("glossary", "rule", "formula", "summary")
|
| 75 |
|
| 76 |
|
|
@@ -270,7 +270,7 @@ class KnowledgePipeline:
|
|
| 270 |
"glossary": out.glossary,
|
| 271 |
"rule": out.rules,
|
| 272 |
"formula": out.formulas,
|
| 273 |
-
"
|
| 274 |
}
|
| 275 |
n = await self._store.save_entries(
|
| 276 |
run_id=run.id,
|
|
|
|
| 68 |
# the run row so a later comparison knows what was actually ATTEMPTED rather
|
| 69 |
# than inferring it from what happened to appear.
|
| 70 |
#
|
| 71 |
+
# Note "summary" is the branch that produces the DocumentBrief - the branch kept
|
| 72 |
# its original name through the v3 rename, while the entity and the entry `kind`
|
| 73 |
+
# are both "document". Passing "document" here silently extracts nothing.
|
| 74 |
BRANCHES = ("glossary", "rule", "formula", "summary")
|
| 75 |
|
| 76 |
|
|
|
|
| 270 |
"glossary": out.glossary,
|
| 271 |
"rule": out.rules,
|
| 272 |
"formula": out.formulas,
|
| 273 |
+
"document": [out.brief] if out.brief else [],
|
| 274 |
}
|
| 275 |
n = await self._store.save_entries(
|
| 276 |
run_id=run.id,
|