"""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)