SPB Git

spb/wp26_uqo

Public
7.9 KB · 207 lines python
1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""Typed contracts shared by the data layer, the agents, the debate protocols and the evaluation.34Conventions5-----------6* Money in CAD, areas in m2, distances in km, dates ISO (YYYY-MM-DD).7* Every piece of evidence shown to an agent carries an ``evidence_id`` (``P`` = subject property,8  ``C01..`` = comparables, ``M*`` = market context, ``G*`` = geospatial, ``R*`` = roll/assessment).9  Agents must cite these ids; the evaluator checks that cited ids exist in the packet.10* The subject's transaction price is NEVER part of an evidence packet.11"""12from __future__ import annotations1314import hashlib15import json16from datetime import date17from typing import Any, Literal1819from pydantic import BaseModel, Field, field_validator, model_validator2021PropertyClass = Literal["house", "condo", "plex"]22Subtype = Literal["detached", "semi", "row", "integrated", "unknown"]232425class SubjectProperty(BaseModel):26    """What is known about the subject *as of the valuation date* (no price)."""2728    evidence_id: str = "P"29    tx_id: str30    valuation_date: date31    information_cutoff: date = Field(description="Agents may only use evidence dated on or before this date")32    property_class: PropertyClass33    subtype: Subtype = "unknown"34    region: str35    municipality: str36    borough: str | None = None37    lat: float = Field(description="rounded to 3 decimals (~100 m) for privacy")38    lng: float39    floor_area_m2: float | None = None40    land_area_m2: float | None = None41    year_built: int | None = None42    storeys: float | None = None43    n_units: int | None = None44    roll_land_value: float | None = None45    roll_building_value: float | None = None46    roll_total_value: float | None = None47    roll_reference_date: str | None = Field(default=None, description="market reference date of the roll in force (e.g. 2022-07-01)")48    prior_sale_price: float | None = Field(default=None, description="most recent *earlier* sale of the same parcel, if any (strictly before cutoff)")49    prior_sale_date: date | None = None50    new_construction: bool | None = None51    missing_fields: list[str] = Field(default_factory=list)525354class Comparable(BaseModel):55    evidence_id: str = Field(pattern=r"^C\d{2}$")56    sale_date: date57    price: float58    distance_km: float59    property_class: PropertyClass60    subtype: Subtype = "unknown"61    municipality: str62    floor_area_m2: float | None = None63    land_area_m2: float | None = None64    year_built: int | None = None65    storeys: float | None = None66    n_units: int | None = None67    roll_total_value: float | None = None68    price_per_m2: float | None = None69    assessment_ratio: float | None = Field(default=None, description="price / roll_total_value")70    months_before_valuation: float717273class MarketContext(BaseModel):74    evidence_id: str = "M1"75    as_of: date76    municipality_median_price_12m: float | None = None77    municipality_median_ppm2_12m: float | None = None78    municipality_n_sales_12m: int | None = None79    municipality_price_index: list[dict[str, Any]] = Field(default_factory=list, description="[{month, index}] up to as_of (base = first month of sample)")80    region_median_price_12m: float | None = None81    region_yoy_change: float | None = None82    local_liquidity_n_sales_3km_12m: int | None = None83    local_median_assessment_ratio_12m: float | None = Field(default=None, description="median price/roll ratio within 3 km over the last 12 months")84    mortgage_rate_5y_fixed: float | None = Field(default=None, description="not available in this snapshot unless loaded from an external series")858687class GeoContext(BaseModel):88    evidence_id: str = "G1"89    in_flood_zone: bool | None = None90    transit_stops_within_1km: int | None = None91    da_population_density: float | None = None92    da_median_income: float | None = None93    distance_to_cbd_km: float | None = None94    notes: list[str] = Field(default_factory=list)959697class EvidencePacket(BaseModel):98    packet_id: str99    subject: SubjectProperty100    comparables: list[Comparable]101    market: MarketContext102    geo: GeoContext | None = None103    role_visible_sections: list[str] = Field(default_factory=lambda: ["subject", "comparables", "market", "geo"])104    notes: list[str] = Field(default_factory=list)105106    def evidence_ids(self) -> set[str]:107        ids = {self.subject.evidence_id, self.market.evidence_id}108        ids |= {c.evidence_id for c in self.comparables}109        if self.geo is not None:110            ids.add(self.geo.evidence_id)111        return ids112113    def content_hash(self) -> str:114        return hashlib.sha256(self.model_dump_json(exclude={"packet_id"}).encode()).hexdigest()[:16]115116117class Adjustment(BaseModel):118    comparable_id: str119    adjusted_price: float = Field(description="comparable price after all adjustments, CAD")120    reason: str121122123class AgentEstimate(BaseModel):124    """Structured output every agent must return (the JSON contract)."""125126    point_estimate: float = Field(description="most likely market value in CAD as of the valuation date")127    q05: float128    q25: float129    q50: float130    q75: float131    q95: float132    evidence_ids_used: list[str] = Field(description="ids from the packet actually relied upon")133    adjustments: list[Adjustment] = Field(default_factory=list)134    key_assumptions: list[str] = Field(default_factory=list)135    missing_data: list[str] = Field(default_factory=list)136    confidence_rationale: str = ""137    counterarguments: list[str] = Field(default_factory=list)138    reasoning_summary: str = Field(default="", description="<= 120 words")139140    @model_validator(mode="after")141    def _check_quantiles(self) -> "AgentEstimate":142        qs = [self.q05, self.q25, self.q50, self.q75, self.q95]143        if any(q <= 0 for q in qs) or self.point_estimate <= 0:144            raise ValueError("estimates must be positive")145        if any(b < a for a, b in zip(qs, qs[1:])):146            raise ValueError("quantiles must be non-decreasing")147        if not (self.q05 <= self.point_estimate <= self.q95):148            raise ValueError("point estimate must lie within [q05, q95]")149        return self150151152class Critique(BaseModel):153    target_role: str154    agree: bool155    points: list[str]156    evidence_ids_cited: list[str] = Field(default_factory=list)157    suggested_value: float | None = None158159160class CritiqueSet(BaseModel):161    critiques: list[Critique]162163164class AgentTurn(BaseModel):165    role: str166    round: int167    kind: Literal["estimate", "critique", "revision"]168    model: str169    provider: str170    estimate: AgentEstimate | None = None171    critiques: CritiqueSet | None = None172    input_tokens: int = 0173    output_tokens: int = 0174    cached_tokens: int = 0175    cost_usd: float = 0.0176    latency_s: float = 0.0177    prompt_hash: str = ""178    request_id: str | None = None179    validation_retries: int = 0180    error: str | None = None181182183class DebateTranscript(BaseModel):184    tx_id: str185    arm: str186    committee: str187    protocol: str188    rounds: int189    packet_hash: str190    turns: list[AgentTurn]191    final_estimates: dict[str, float] = Field(default_factory=dict, description="role -> final point estimate")192    final_quantiles: dict[str, list[float]] = Field(default_factory=dict, description="role -> [q05,q25,q50,q75,q95]")193    aggregated: dict[str, float] = Field(default_factory=dict, description="aggregation rule -> value")194    aggregated_interval: dict[str, list[float]] = Field(default_factory=dict, description="rule -> [lo90, hi90]")195    total_cost_usd: float = 0.0196    total_latency_s: float = 0.0197    total_tokens: int = 0198    assignment: dict[str, str] = Field(default_factory=dict, description="role -> model id (committee assignment, recorded per property)")199    notes: list[str] = Field(default_factory=list, description="protocol events: stopped_early_at=<r>, verification flags, blinded skeptic, ...")200201    def to_json(self) -> str:202        return self.model_dump_json(indent=1)203204205def canonical_json(obj: Any) -> str:206    return json.dumps(obj, sort_keys=True, ensure_ascii=False, default=str)207
Files · b780d9effbc0d688f848faf14e38219f6088143b
Menu

Theme