File size: 8,880 Bytes
024c30a ab5ea78 024c30a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | """Pydantic contracts for the knowledge-extraction pipeline.
Three invariants are encoded here rather than described in prose, because every
one of them is a control that a later change could quietly remove:
1. **All content fields are Optional.** A model that cannot answer null will
fabricate one. Abstention is correct behaviour, never an error.
2. **`subdomain_tags` is an enum.** Classification, not generation.
3. **`Provenance.span` is mandatory and verbatim-checked.** It is the primary
anti-hallucination control and the thing that makes expert review
finishable β the reviewer checks a quote against a page, not a claim
against their memory.
`Chunk` here is the pipeline's **internal** unit, deliberately narrower than the
parsed-document artifact being agreed with Sofhia (the seam). Stages depend only
on this subset; `adapter.py` maps the seam type onto it, so seam churn lands in
one file instead of seven. See KNOWLEDGE_PIPELINE_TODO.md Β§3.
"""
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field
Branch = Literal["glossary", "rule", "formula", "summary"]
ExtractionStatus = Literal["ok", "no_definition_found", "escalated"]
DiffStatus = Literal["new", "duplicate", "conflicting"]
class SubdomainEnum(str, Enum):
"""Classification target. Extend deliberately β a new member changes what
the model is allowed to answer, which is a prompt change, not a data one."""
production = "production"
maintenance = "maintenance"
hauling = "hauling"
loading = "loading"
drilling_blasting = "drilling_blasting"
equipment = "equipment"
safety = "safety"
quality = "quality"
planning = "planning"
cost = "cost"
geology = "geology"
other = "other"
# ββ Stage 1: the chunk (internal view of the seam artifact) βββββββββββββ
class Chunk(BaseModel):
"""One unit of a parsed document, as the extraction stages need it.
`text` must stay **verbatim** from the source document. Span validation
locates LLM-quoted spans literally inside this text; if it is ever reflowed
or whitespace-normalised the lookup fails and the field is silently set to
null. The failure presents as a bad model, but the cause would be here.
"""
chunk_id: str
doc_id: str
text: str
page_start: int
page_end: int
ordinal: int = 0
# Structural context. Both Optional β many documents carry no numbering.
section_no: str | None = None
heading: str | None = None
# Cheap downstream filters / ranking signals
has_formula: bool = False
is_tabular: bool = False
bold_spans: list[str] = Field(default_factory=list)
class ParsedDoc(BaseModel):
"""A document's chunks plus the identity needed to version and cache them."""
doc_id: str
source_ref: str
content_hash: str
n_pages: int
chunks: list[Chunk]
parser_name: str = "unknown"
parser_version: str = ""
used_heading_split: bool = False
# ββ Stage 2: filters ββββββββββββββββββββββββββββββββββββββββββββββββββββ
class Mention(BaseModel):
"""One occurrence of a candidate term inside a chunk."""
surface: str
chunk_id: str
char_start: int
char_end: int
label: str = ""
score: float = 0.0
hit_span_cap: bool = False
class RuleCandidate(BaseModel):
"""A passage a discourse cue marks as possibly stating a rule of thumb."""
chunk_id: str
cue: str
char_start: int
char_end: int
snippet: str
class AbbrevPair(BaseModel):
"""`PA` β `Physical Availability`, harvested from a legend block.
Legend extraction must run before clustering: without these, an
abbreviation and its expansion cluster as two unrelated terms.
"""
abbrev: str
expansion: str
chunk_id: str
class FilterResult(BaseModel):
doc_id: str
mentions: list[Mention] = Field(default_factory=list)
rule_candidates: list[RuleCandidate] = Field(default_factory=list)
abbrev_pairs: list[AbbrevPair] = Field(default_factory=list)
# ββ Stage 3: clusters βββββββββββββββββββββββββββββββββββββββββββββββββββ
class TermCluster(BaseModel):
"""All mentions of one term. **The LLM call unit is the cluster**, not the
chunk and not the mention β that is what cuts expert review burden, and it
is also the only reason conflicting definitions can be detected at all
(they must arrive in the same call to be compared)."""
cluster_id: str
canonical: str
variants: list[str] = Field(default_factory=list)
mentions: list[Mention] = Field(default_factory=list)
mention_count: int = 0
merge_reasons: list[str] = Field(default_factory=list)
# Ranked best-first. The FULL list is kept, not just the top K β
# escalation consumes the tail.
evidence_chunk_ids: list[str] = Field(default_factory=list)
evidence_scores: list[float] = Field(default_factory=list)
class ClusterResult(BaseModel):
doc_id: str
clusters: list[TermCluster] = Field(default_factory=list)
n_mentions: int = 0
n_clusters: int = 0
compression_ratio: float = 0.0
# ββ Stage 4+: extracted entries βββββββββββββββββββββββββββββββββββββββββ
class Provenance(BaseModel):
"""Where a claim came from. `span` is mandatory and must appear verbatim in
the evidence text; a field whose span cannot be located is rejected, never
repaired. A repaired span is an unfalsifiable claim."""
doc_id: str
span: str
page: int | None = None
section_no: str | None = None
chunk_id: str | None = None
class GlossaryEntry(BaseModel):
term: str
full_name: str | None = None
# The literal wording as the document writes it, un-normalised. The BUMA
# standard heads its section "Physical of Availability (PA)" while the
# legend says "Physical Availability"; the discrepancy is surfaced to the
# expert rather than silently corrected.
source_wording: str | None = None
definition: str | None = None
formula_latex: str | None = None
interpretation: str | None = None
subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
domain: str | None = None
company: str | None = None
language: str | None = None
mention_count: int = 0
provenance: Provenance
extraction_status: ExtractionStatus = "ok"
diff_status: DiffStatus | None = None
definition_conflict: bool = False
conflict_variants: list[str] = Field(default_factory=list)
class RuleEntry(BaseModel):
"""A rule of thumb / operational convention stated by the document."""
rule_id: str
statement: str | None = None
condition: str | None = None
consequence: str | None = None
applies_to: str | None = None
subdomain_tags: list[SubdomainEnum] = Field(default_factory=list)
language: str | None = None
provenance: Provenance
extraction_status: ExtractionStatus = "ok"
class FormulaVariable(BaseModel):
symbol: str
meaning: str | None = None
class FormulaEntry(BaseModel):
name: str | None = None
formula_latex: str | None = None
variables: list[FormulaVariable] = Field(default_factory=list)
unit: str | None = None
provenance: Provenance
extraction_status: ExtractionStatus = "ok"
class BriefContext(BaseModel):
"""Whole-document summary. The only branch that cannot be span-checked β
a plausible summary is indistinguishable from a correct one, which is why
it belongs on the larger model tier when one is available."""
title: str | None = None
purpose: str | None = None
scope: str | None = None
key_parameters: list[str] = Field(default_factory=list)
summary_md: str | None = None
provenance: Provenance
class CallUsage(BaseModel):
"""Per-call accounting. `cached_tokens` comes from the API and is never
modelled: caching does not engage below 1024 prompt tokens, so assuming it
would understate cost by ~10x on the input side."""
branch: Branch
deployment: str
tier: str = "nano"
prompt_tokens: int = 0
cached_tokens: int = 0
completion_tokens: int = 0
latency_s: float = 0.0
retries: int = 0
structured_output_mode: str = ""
simulated: bool = False
class RejectedField(BaseModel):
"""Audit row for a field the span check refused. Kept so a reviewer can see
what the control caught rather than only what it let through."""
entry_term: str
field: str
offending_value: str
reason: str
branch: Branch
|