RICS / app /extraction /schemas.py
StormShadow308's picture
Ship production RAG hardening: citation extraction, full-library retrieval, auth.
865bc90
Raw
History Blame Contribute Delete
7.83 kB
"""Schema-constrained extraction types.
These Pydantic models are the contract the LLM extractor must satisfy. The model
may NEVER emit a value outside these constraints — condition ratings are a closed
enum, evidence is mandatory, and unsupported fields are represented explicitly
(``SupportLevel.NOT_FOUND``) rather than interpolated.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class ConditionRating(str, Enum):
"""RICS Home Survey condition rating — a closed set.
The extractor must map any source rating onto exactly one of these. It may
never invent an intermediate value, upgrade, or downgrade. ``NI`` / ``NA``
are explicit "no rating present" sentinels so a missing rating is never
silently coerced to a real one.
"""
CR1 = "1" # No repair currently needed.
CR2 = "2" # Defects requiring repair/replacement but not urgent.
CR3 = "3" # Defects that are serious and/or need urgent attention.
NI = "NI" # Not inspected.
NA = "NA" # Not applicable / no rating stated in source.
@classmethod
def coerce(cls, value: object) -> "ConditionRating":
"""Map a raw model/source value onto the enum without inventing a rating.
Anything that is not an exact, recognised rating becomes :attr:`NA`
(not a guessed CR1/CR2/CR3). This is deliberate: an unparseable rating
must degrade to "no rating", never to a fabricated severity.
"""
if isinstance(value, cls):
return value
s = str(value or "").strip().upper()
direct = {
"1": cls.CR1, "CR1": cls.CR1, "CONDITION RATING 1": cls.CR1,
"2": cls.CR2, "CR2": cls.CR2, "CONDITION RATING 2": cls.CR2,
"3": cls.CR3, "CR3": cls.CR3, "CONDITION RATING 3": cls.CR3,
"NI": cls.NI, "NOT INSPECTED": cls.NI,
"NA": cls.NA, "N/A": cls.NA, "": cls.NA, "NONE": cls.NA,
}
return direct.get(s, cls.NA)
class SupportLevel(str, Enum):
"""How well a claim is backed by cited source spans."""
SUPPORTED = "supported" # Every token traceable to a cited span.
PARTIAL = "partial" # Core claim supported; minor unverified detail.
NOT_FOUND = "not_found" # No supporting evidence — must be dropped.
class EvidenceSpan(BaseModel):
"""A literal span copied from a retrieved source chunk.
``text`` MUST be a verbatim (or near-verbatim) substring of the chunk
identified by ``chunk_id``. The citation validator rejects spans that do
not actually appear in their cited chunk.
"""
chunk_id: str = Field(..., description="ID of the retrieved chunk this span came from.")
doc_id: str | None = Field(default=None, description="Source document UUID.")
page: int | None = Field(default=None, description="1-based page number when known.")
section_label: str | None = Field(default=None, description="Section/page heading label.")
text: str = Field(..., min_length=1, description="Verbatim span from the cited chunk.")
@field_validator("text")
@classmethod
def _strip(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("evidence span text cannot be blank")
return v
class GroundedClaim(BaseModel):
"""An atomic claim and the evidence that supports it."""
claim: str = Field(..., min_length=1)
supporting_spans: list[EvidenceSpan] = Field(default_factory=list)
support: SupportLevel = Field(default=SupportLevel.NOT_FOUND)
page_refs: list[int] = Field(default_factory=list)
class SurveyFinding(BaseModel):
"""One condition finding for a building element, fully evidence-backed.
This is the structured-extraction unit from STEP 4 of the spec. ``finding``
is descriptive prose derived ONLY from the cited spans; ``condition_rating``
is a closed enum; ``evidence`` carries the literal spans; ``page_refs`` the
source pages.
"""
section: str = Field(..., description="Domain/section, e.g. 'Roofing', 'Drainage'.")
element: str = Field(..., description="Specific element, e.g. 'Main roof covering'.")
condition_rating: ConditionRating = Field(default=ConditionRating.NA)
finding: str = Field(..., min_length=1, description="Evidence-grounded description.")
evidence: list[EvidenceSpan] = Field(default_factory=list)
page_refs: list[int] = Field(default_factory=list)
support: SupportLevel = Field(default=SupportLevel.NOT_FOUND)
@field_validator("condition_rating", mode="before")
@classmethod
def _coerce_rating(cls, v: object) -> ConditionRating:
return ConditionRating.coerce(v)
class ContradictionKind(str, Enum):
OPERATIONAL = "operational_vs_non_operational"
CONDITION = "satisfactory_vs_defective"
RATING_CONFLICT = "inconsistent_condition_rating"
DUPLICATE = "duplicate_finding"
MUTUALLY_EXCLUSIVE = "mutually_exclusive_statement"
UNSUPPORTED_SUMMARY = "unsupported_summary"
class Contradiction(BaseModel):
"""A detected logical inconsistency between two findings (or within one)."""
kind: ContradictionKind
element: str
detail: str
finding_indices: list[int] = Field(default_factory=list)
resolution: str | None = Field(
default=None,
description="Which side was kept and why (evidence-supported version preserved).",
)
class ClaimType(str, Enum):
"""Closed set of atomic claim categories (no free-text types)."""
CONDITION_RATING = "condition_rating"
MEASUREMENT = "measurement"
MATERIAL = "material"
ENTITY = "entity" # named product/model/species/person/org
LOCATION = "location"
DATE = "date"
QUANTITY = "quantity"
OBSERVATION = "observation" # plain factual observation
OTHER = "other"
class ClaimEvidence(BaseModel):
"""Exact evidence binding for one atomic claim."""
text: str = Field(..., min_length=1, description="Verbatim span from the cited chunk.")
page: int = Field(default=0, description="1-based page number; 0 when unknown.")
section: str = Field(default="", description="Section/heading label of the source span.")
chunk_id: str = Field(..., min_length=1, description="ID of the cited source chunk.")
class ClaimVerification(BaseModel):
"""Verification outcome for one atomic claim."""
supported: bool = False
contradiction_detected: bool = False
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class AtomicClaim(BaseModel):
"""A single atomic factual claim with mandatory evidence binding.
This is the forensic unit required by the evidence-comparison contract:
one indivisible fact, the exact span that supports it, and the deterministic
verification result. Unsupported claims are dropped before output.
"""
claim: str = Field(..., min_length=1)
claim_type: ClaimType = Field(default=ClaimType.OTHER)
evidence: ClaimEvidence
verification: ClaimVerification = Field(default_factory=ClaimVerification)
class SectionExtraction(BaseModel):
"""The full schema-constrained extraction for one report section."""
section: str
findings: list[SurveyFinding] = Field(default_factory=list)
contradictions: list[Contradiction] = Field(default_factory=list)
dropped_claims: list[str] = Field(
default_factory=list,
description="Claims removed for lack of supporting evidence (audit trail).",
)
@property
def confidence(self) -> float:
"""Fraction of findings that are fully supported (0.0–1.0)."""
if not self.findings:
return 0.0
supported = sum(1 for f in self.findings if f.support == SupportLevel.SUPPORTED)
return round(supported / len(self.findings), 3)