1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""Deterministic mock provider: no network, no keys. Produces schema-valid answers from the packet text.34The mock parses the evidence packet JSON embedded in the user prompt, prices the subject at the5median price-per-m2 of the comparables (or the roll ratio), and perturbs it with a role-and-property6seeded log-normal error so that different "agents" disagree. Critiques are generic but well-formed.7This is for pipeline tests only; it carries no information beyond the deterministic comparables rule.8"""9from __future__ import annotations1011import hashlib12import json13import math14import random15import re16from typing import Any1718from pydantic import BaseModel1920from .base import LLMProvider212223def _seed(*parts: str) -> int:24 return int(hashlib.sha256("|".join(parts).encode()).hexdigest()[:12], 16)252627class MockProvider(LLMProvider):28 name = "mock"2930 def __init__(self, noise_sd: float = 0.12, **kw):31 super().__init__(**kw)32 self.noise_sd = noise_sd3334 async def _raw_complete(self, *, system: str, user: str, schema: type[BaseModel], model: str, effort: str, max_tokens: int) -> tuple[str, dict[str, Any]]:35 packet = _find_packet(user)36 role = _find_role(system)37 name = schema.__name__38 out: dict[str, Any]39 if name == "AgentEstimate":40 out = self._estimate(packet, role, user)41 elif name == "CritiqueSet":42 out = self._critiques(packet, role, user)43 else:44 out = {}45 text = json.dumps(out)46 n_in = max(len(system) + len(user), 1) // 447 n_out = max(len(text), 1) // 448 return text, {"input_tokens": n_in, "output_tokens": n_out, "cached_tokens": 0, "request_id": "mock"}4950 def _estimate(self, packet: dict[str, Any], role: str, user: str) -> dict[str, Any]:51 subj = packet.get("subject", {})52 comps = packet.get("comparables", [])53 tx_id = subj.get("tx_id", "x")54 rng = random.Random(_seed(role, tx_id, "estimate"))55 area = subj.get("floor_area_m2") or None56 roll = subj.get("roll_total_value") or None57 base = None58 ids_used = ["P"]59 ppm2 = [c["price"] / c["floor_area_m2"] for c in comps if c.get("floor_area_m2") and c.get("price")]60 if area and ppm2:61 ppm2.sort()62 base = ppm2[len(ppm2) // 2] * area63 ids_used += [c["evidence_id"] for c in comps[:6]]64 elif roll:65 ratios = [c["assessment_ratio"] for c in comps if c.get("assessment_ratio")]66 ratio = sorted(ratios)[len(ratios) // 2] if ratios else (packet.get("market", {}).get("local_median_assessment_ratio_12m") or 1.2)67 base = roll * ratio68 ids_used += ["M1"]69 elif comps:70 base = sorted(c["price"] for c in comps)[len(comps) // 2]71 ids_used += [c["evidence_id"] for c in comps[:6]]72 else:73 base = 350_000.074 # revision prompts carry the committee's previous estimates: shrink toward their median75 m = re.search(r'"committee_median":\s*([0-9.]+)', user)76 if m:77 base = math.exp(0.5 * math.log(base) + 0.5 * math.log(float(m.group(1))))78 noise = rng.gauss(0, self.noise_sd)79 point = base * math.exp(noise)80 sd = 0.10 + 0.05 * rng.random()81 q = lambda z: point * math.exp(z * sd) # noqa: E73182 return {83 "point_estimate": round(point, -2),84 "q05": round(q(-1.645), -2), "q25": round(q(-0.674), -2), "q50": round(point, -2), "q75": round(q(0.674), -2), "q95": round(q(1.645), -2),85 "evidence_ids_used": ids_used,86 "adjustments": [{"comparable_id": c["evidence_id"], "adjusted_price": round(c["price"] * (1 + rng.uniform(-0.05, 0.05)), -2), "reason": "mock time/size adjustment"} for c in comps[:3]],87 "key_assumptions": [f"mock {role}: median price-per-m2 of comparables applied to subject area"],88 "missing_data": subj.get("missing_fields", []),89 "confidence_rationale": "mock: dispersion of comparables",90 "counterarguments": ["mock: comparables may differ in condition (unobserved)"],91 "reasoning_summary": f"Mock {role} estimate from {len(comps)} comparables.",92 }9394 def _critiques(self, packet: dict[str, Any], role: str, user: str) -> dict[str, Any]:95 targets = re.findall(r'"role":\s*"([a-z_0-9]+)"', user)96 rng = random.Random(_seed(role, packet.get("subject", {}).get("tx_id", "x"), "critique"))97 crits = []98 for t in dict.fromkeys(targets):99 if t == role:100 continue101 crits.append({"target_role": t, "agree": rng.random() < 0.6, "points": ["mock: check the time adjustment of C01"], "evidence_ids_cited": ["C01"], "suggested_value": None})102 return {"critiques": crits[:11]}103104105def _find_packet(user: str) -> dict[str, Any]:106 m = re.search(r"<evidence_packet>\s*(\{.*?\})\s*</evidence_packet>", user, re.S)107 if not m:108 return {}109 try:110 return json.loads(m.group(1))111 except json.JSONDecodeError:112 return {}113114115def _find_role(system: str) -> str:116 m = re.search(r"ROLE_ID:\s*([a-z_0-9]+)", system)117 return m.group(1) if m else "generalist"118