"""Engine for the Calgary Suite Plan Review Space (WNTR_GIS architecture). Deterministic layer (free): knowledge discovery, PDF page rendering + text extraction, in-memory SQLite database build. LLM layer (user's key): review pipeline (route -> vision review) and a provider-agnostic chat that queries the SQLite DB through a JSON tool protocol (SELECT-only SQL, knowledge search, page text) — the same "tools over token dumps" idea as WNTR_GIS. """ from __future__ import annotations import base64 import json import os import re import sqlite3 from dataclasses import dataclass, field from datetime import date, datetime from pathlib import Path import fitz # PyMuPDF from jurisdiction import Jurisdiction, load_jurisdiction # noqa: E402 from providers import llm_call, supports_vision TEXT_EXT = {".md", ".txt"} PDF_EXT = {".pdf"} MAX_SOURCE_CHARS = 60_000 KNOWLEDGE_BUDGET_CHARS = 120_000 # ────────────────────────────────────────────────────────── knowledge layer ── @dataclass class Source: skill: str rel: str content: str origin: str # "skill" | "drop-folder" | "session-upload" @property def key(self) -> str: return f"{self.skill}/{self.rel}" def head(self, n: int = 200) -> str: override = getattr(self, "_head_override", None) if override: return override[:n] body = re.sub(r"^---\n.*?\n---\n", "", self.content, flags=re.S).strip() return " ".join(body.split())[:n] @dataclass class Skill: name: str skill_md: str description: str sources: list[Source] = field(default_factory=list) track: str = "suites" track_label: str = "" track_scope: str = "" jurisdiction: str = "" def _read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8", errors="replace")[:MAX_SOURCE_CHARS] except Exception as exc: # noqa: BLE001 return f"[unreadable: {exc}]" HARD_PDF_CHARS = 4_000_000 # absolute extraction ceiling (~1000+ pages) _EXTRACT_CACHE: dict = {} # (path, size, mtime) -> text def _pdf_text_file(path_or_bytes) -> str: """Extract FULL text of a PDF (up to HARD_PDF_CHARS); cached for on-disk files.""" cache_key = None try: if isinstance(path_or_bytes, (str, Path)): p = Path(path_or_bytes) stat = p.stat() cache_key = (str(p), stat.st_size, stat.st_mtime) if cache_key in _EXTRACT_CACHE: return _EXTRACT_CACHE[cache_key] doc = fitz.open(p) else: doc = fitz.open(stream=path_or_bytes, filetype="pdf") parts = [] for i, page in enumerate(doc): parts.append(f"\n--- page {i + 1} ---\n{page.get_text('text')}") if sum(len(p_) for p_ in parts) > HARD_PDF_CHARS: parts.append("\n[truncated at extraction ceiling]") break doc.close() text = "".join(parts)[:HARD_PDF_CHARS] if cache_key: _EXTRACT_CACHE[cache_key] = text return text except Exception as exc: # noqa: BLE001 return f"[pdf extraction failed: {exc}]" _SECTION_RE = re.compile(r"\b(\d{1,2}\.\d{1,2}\.\d{1,3}(?:\.\d{1,3})?\.?)\s") def _chunk_head(chunk: str) -> str: """Preview for a chunk: page span + section numbers spotted, so the router can pick.""" pages = re.findall(r"--- page (\d+) ---", chunk) span = f"pp.{pages[0]}-{pages[-1]}" if pages else "" # Collapse clause numbers (9.37.2.1) to subsection level (9.37) so the whole # chunk's coverage fits in a short preview the router can scan. subs = list(dict.fromkeys(".".join(m.split(".")[:2]) for m in _SECTION_RE.findall(chunk))) sec_note = (" sections " + ", ".join(subs[:20])) if subs else "" first = " ".join(chunk.split())[:100] return f"[{span}{sec_note}] {first}" def make_sources(skill: str, rel: str, origin: str, text: str) -> list[Source]: """One Source if small; otherwise split into router-selectable chunks. Large documents (e.g., a full NBC Alberta Part 9 PDF) become name.pdf#chunkN sources with page-span + section-number previews, so the knowledge router can load only the relevant sections instead of a truncated head of the whole book. """ if len(text) <= MAX_SOURCE_CHARS: return [Source(skill, rel, text, origin)] # Split on page markers, pack into ~MAX_SOURCE_CHARS chunks pieces = re.split(r"(?=\n--- page \d+ ---)", text) chunks, buf = [], "" for piece in pieces: if buf and len(buf) + len(piece) > MAX_SOURCE_CHARS: chunks.append(buf); buf = piece else: buf += piece if buf: chunks.append(buf) out = [] for n, chunk in enumerate(chunks, 1): src = Source(skill, f"{rel}#chunk{n}", chunk, origin) src._head_override = _chunk_head(chunk) # type: ignore[attr-defined] out.append(src) return out def _frontmatter_desc(md: str) -> str: m = re.match(r"^---\n(.*?)\n---", md, flags=re.S) if m: d = re.search(r'description:\s*"?(.*?)"?\s*(?:\n[a-z_]+:|\Z)', m.group(1), flags=re.S) if d: return " ".join(d.group(1).split())[:600] return "" def load_knowledge(base_dir, uploaded=None, jurisdiction: str = "") -> tuple[list[Skill], list[Source]]: """Load skills + drop-folder knowledge. uploaded: list of (filename, bytes) from Streamlit uploads. jurisdiction: when set, only load skills whose SKILL.md frontmatter declares a matching `jurisdiction:` (comma-separated list allowed), plus skills that declare none (treated as universal). This lets one deployment hold several municipalities' knowledge without a Calgary review pulling in Houston rules. """ base = Path(base_dir) want = jurisdiction.strip().lower() skills: list[Skill] = [] for skill_dir in sorted((base / "skills").glob("*")): smd = skill_dir / "SKILL.md" if not skill_dir.is_dir() or not smd.exists(): continue md = _read_text(smd) fm = re.match(r"^---\n(.*?)\n---", md, flags=re.S) fm_text = fm.group(1) if fm else "" def _fm_key(key: str, default: str = "") -> str: m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm_text, flags=re.M) return m.group(1).strip() if m else default skill_jur = _fm_key("jurisdiction") if want and skill_jur: allowed = {j.strip().lower() for j in skill_jur.split(",") if j.strip()} if want not in allowed: continue # belongs to a different municipality sk = Skill(skill_dir.name, md, _frontmatter_desc(md), track=_fm_key("track", "suites"), track_label=_fm_key("track_label"), track_scope=_fm_key("track_scope"), jurisdiction=skill_jur) seen_keys: set[str] = set() for ref in sorted(skill_dir.rglob("*")): if not ref.is_file() or ref.name.lower() == "skill.md": continue # Case-duplicate guard (e.g. CLAUDE.md vs Claude.md uploaded twice): # first occurrence wins, case-insensitively. dedupe_key = str(ref.relative_to(skill_dir)).lower() if dedupe_key in seen_keys: continue seen_keys.add(dedupe_key) if ref.suffix.lower() in TEXT_EXT: sk.sources += make_sources(sk.name, str(ref.relative_to(skill_dir)), "skill", _read_text(ref)) elif ref.suffix.lower() in PDF_EXT: sk.sources += make_sources(sk.name, str(ref.relative_to(skill_dir)), "skill", _pdf_text_file(ref)) skills.append(sk) loose: list[Source] = [] drop = base / "knowledge" if drop.exists(): for f in sorted(drop.rglob("*")): if f.is_file() and f.suffix.lower() in TEXT_EXT: loose += make_sources("knowledge", str(f.relative_to(drop)), "drop-folder", _read_text(f)) elif f.is_file() and f.suffix.lower() in PDF_EXT: loose += make_sources("knowledge", str(f.relative_to(drop)), "drop-folder", _pdf_text_file(f)) for name, data in (uploaded or []): suffix = Path(name).suffix.lower() if suffix in TEXT_EXT: try: txt = data.decode("utf-8", errors="replace") except Exception: # noqa: BLE001 txt = "[decode failed]" loose += make_sources("knowledge", name, "session-upload", txt) elif suffix in PDF_EXT: loose += make_sources("knowledge", name, "session-upload", _pdf_text_file(data)) return skills, loose DEFAULT_TRACK_META = { "suites": { "label": "secondary/backyard suite", "scope": "a secondary or backyard suite application (basement suite, garden/laneway/garage suite) on a low-density residential parcel", }, } def discover_tracks(skills: list[Skill]) -> dict: """Group skills into review tracks declared in SKILL.md frontmatter. Adding a review type = dropping a skill folder whose frontmatter declares `track`, `track_label`, and `track_scope` — no code changes. """ tracks: dict[str, dict] = {} for sk in skills: t = sk.track or "suites" entry = tracks.setdefault(t, {"label": "", "scope": "", "skills": []}) entry["skills"].append(sk) if sk.track_label and not entry["label"]: entry["label"] = sk.track_label if sk.track_scope and not entry["scope"]: entry["scope"] = sk.track_scope for t, entry in tracks.items(): meta = DEFAULT_TRACK_META.get(t, {}) entry["label"] = entry["label"] or meta.get("label", t.replace("-", " ")) entry["scope"] = entry["scope"] or meta.get("scope", f"a {entry['label']} application") return tracks def knowledge_manifest(skills: list[Skill], loose: list[Source]) -> str: lines = [] for sk in skills: lines.append(f"SKILL {sk.name}: {sk.description}") lines += [f" - {s.key} [{len(s.content):,} chars] {s.head(120)}" for s in sk.sources] if loose: lines.append("LOOSE KNOWLEDGE (drop folder / session uploads):") lines += [f" - {s.key} [{len(s.content):,} chars; {s.origin}] {s.head(120)}" for s in loose] return "\n".join(lines) # ─────────────────────────────────────────────────────────────── pdf layer ── def pdf_to_pages(pdf_bytes: bytes, max_pages: int = 10, long_edge: int = 1568, jpeg_quality: int = 80): """Render plan pages → base64 JPEGs + embedded text. Returns (b64s, texts, total, dims).""" doc = fitz.open(stream=pdf_bytes, filetype="pdf") total = doc.page_count b64s, texts, dims = [], [], [] for i in range(min(total, max_pages)): page = doc.load_page(i) rect = page.rect zoom = long_edge / max(rect.width, rect.height) pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False) b64s.append(base64.standard_b64encode(pix.tobytes("jpeg", jpg_quality=jpeg_quality)).decode()) texts.append(page.get_text("text")[:4000]) dims.append((round(rect.width), round(rect.height))) doc.close() return b64s, texts, total, dims def dxf_to_pages(dxf_bytes: bytes, max_pages: int = 50, long_edge: int = 1568, jpeg_quality: int = 85): """Render a DXF CAD drawing → base64 JPEGs + entity text, one 'page' per layout (model space + each paper-space layout). Returns (b64s, texts, total, dims). Text extraction (TEXT/MTEXT/dimension/attribute entities) is reliable; the raster render is best-effort — real DXFs may use xrefs, SHX fonts, or custom styles the pip-only renderer can't fully resolve, so a layout that fails to render still contributes its extracted text. """ import io import tempfile import ezdxf from ezdxf.addons.drawing import RenderContext, Frontend from ezdxf.addons.drawing.matplotlib import MatplotlibBackend import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as tf: tf.write(dxf_bytes) path = tf.name try: try: doc = ezdxf.readfile(path) except ezdxf.DXFStructureError as e: raise RuntimeError( "This DXF could not be parsed (corrupt or unsupported structure): " f"{e}. Try re-exporting as DXF (AutoCAD 2010+ / ASCII) or as PDF." ) from e # Order layouts: model space first, then paper-space layouts. layout_names = ["Model"] + [n for n in doc.layout_names_in_taborder() if n != "Model"] b64s, texts, dims = [], [], [] total = len(layout_names) for name in layout_names[:max_pages]: layout = doc.layout(name) if name != "Model" else doc.modelspace() # --- text (reliable) --- chunks = [] for e in layout: t = e.dxftype() if t == "TEXT": chunks.append(e.dxf.text) elif t == "MTEXT": chunks.append(e.text.replace("\\P", "\n")) elif t in ("ATTRIB", "ATTDEF"): chunks.append(getattr(e.dxf, "text", "")) elif t == "DIMENSION": m = getattr(e.dxf, "text", "") if m and m != "<>": chunks.append(m) page_text = f"[DXF layout: {name}]\n" + "\n".join(c for c in chunks if c) texts.append(page_text[:4000]) # --- render (best-effort) --- img_b64 = "" try: fig = plt.figure(dpi=150) ax = fig.add_axes([0, 0, 1, 1]); ax.set_axis_off() Frontend(RenderContext(doc), MatplotlibBackend(ax)).draw_layout( layout, finalize=True) buf = io.BytesIO() fig.savefig(buf, format="jpeg", bbox_inches="tight", pil_kwargs={"quality": jpeg_quality}) plt.close(fig) img_b64 = base64.standard_b64encode(buf.getvalue()).decode() w, h = fig.get_size_inches() * fig.dpi dims.append((round(float(w)), round(float(h)))) except Exception: # noqa: BLE001 — render failure must not lose the text plt.close("all") dims.append((0, 0)) b64s.append(img_b64) return b64s, texts, total, dims finally: try: os.unlink(path) except OSError: pass def file_to_pages(filename: str, data: bytes, max_pages: int = 50): """Dispatch a plan-set upload to the right extractor by extension. PDF → rasterize + embedded text (highest fidelity). DXF → CAD vector render + entity text (pip-only, best-effort raster). DWG → not parseable in this environment (proprietary binary); guide the user. """ ext = os.path.splitext(filename)[1].lower() if ext == ".pdf": return pdf_to_pages(data, max_pages=max_pages) if ext == ".dxf": return dxf_to_pages(data, max_pages=max_pages) if ext == ".dwg": raise RuntimeError( "DWG is AutoCAD's proprietary binary format and can't be read in this " "hosted environment (it needs the ODA File Converter, which isn't " "available on the Space). Please export the drawing from your CAD " "software as **PDF** (best fidelity for review) or **DXF** (AutoCAD " "2010+/ASCII) and upload that instead." ) raise RuntimeError(f"Unsupported plan-set type '{ext}'. Upload a PDF or DXF.") def guess_sheet_id(text: str) -> str: m = re.search(r"\b([ASMEC]-?\d{1,3}(?:\.\d+)?)\b", text or "") return m.group(1) if m else "" # ──────────────────────────────────────────────────────────── sqlite layer ── def build_review_db(page_texts: list[str], dims, total_pages: int, skills: list[Skill], loose: list[Source], meta: dict) -> sqlite3.Connection: """In-memory SQLite the AI queries via tools (WNTR_GIS pattern).""" con = sqlite3.connect(":memory:", check_same_thread=False) cur = con.cursor() cur.executescript(""" CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE pages (page INTEGER PRIMARY KEY, sheet_guess TEXT, width INTEGER, height INTEGER, text TEXT); CREATE TABLE knowledge_sources (key TEXT PRIMARY KEY, skill TEXT, rel TEXT, origin TEXT, chars INTEGER, head TEXT); CREATE TABLE runs (run_id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, provider TEXT, model TEXT, pages_reviewed INTEGER, verdict TEXT, must_fix INTEGER, clarify INTEGER, advisory INTEGER, transition_note TEXT, routing TEXT); CREATE TABLE findings (fid TEXT, run_id INTEGER, page INTEGER, sheet_id TEXT, discipline TEXT, category TEXT, severity TEXT, confidence TEXT, description TEXT, citation TEXT); """) for k, v in meta.items(): cur.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", (k, str(v))) cur.execute("INSERT OR REPLACE INTO meta VALUES ('total_pages',?)", (str(total_pages),)) for i, t in enumerate(page_texts): w, h = dims[i] if i < len(dims) else (0, 0) cur.execute("INSERT INTO pages VALUES (?,?,?,?,?)", (i + 1, guess_sheet_id(t), w, h, t)) for s in [x for sk in skills for x in sk.sources] + loose: cur.execute("INSERT OR REPLACE INTO knowledge_sources VALUES (?,?,?,?,?,?)", (s.key, s.skill, s.rel, s.origin, len(s.content), s.head(160))) con.commit() return con def record_run(con: sqlite3.Connection, provider: str, model: str, pages_reviewed: int, routing: str, result: dict) -> int: s = result.get("summary", {}) cur = con.cursor() cur.execute("INSERT INTO runs (ts,provider,model,pages_reviewed,verdict,must_fix," "clarify,advisory,transition_note,routing) VALUES (?,?,?,?,?,?,?,?,?,?)", (datetime.now().isoformat(timespec="seconds"), provider, model, pages_reviewed, s.get("verdict", ""), int(s.get("must_fix", 0) or 0), int(s.get("clarify", 0) or 0), int(s.get("advisory", 0) or 0), s.get("transition_note", ""), routing)) run_id = cur.lastrowid for f in result.get("findings", []): cur.execute("INSERT INTO findings VALUES (?,?,?,?,?,?,?,?,?,?)", (f.get("id", ""), run_id, f.get("page"), f.get("sheet_id", ""), f.get("discipline", ""), f.get("category", ""), f.get("severity", ""), f.get("confidence", ""), f.get("description", ""), f.get("citation", ""))) con.commit() return run_id DB_SCHEMA_DOC = """SQLite tables available: - meta(key, value) — project details, suite_type, application_date, total_pages - pages(page, sheet_guess, width, height, text) — embedded text per plan page - knowledge_sources(key, skill, rel, origin, chars, head) — loaded knowledge catalog - runs(run_id, ts, provider, model, pages_reviewed, verdict, must_fix, clarify, advisory, transition_note, routing) - findings(fid, run_id, page, sheet_id, discipline, category, severity, confidence, description, citation)""" # ──────────────────────────────────────────────────────────────── prompts ── CRITICAL_RULES_TMPL = """CRITICAL RULES ({jur_place} jurisdiction — review track: {track_label}): - SCOPE GATE (Phase 0, before any findings): identify the ACTUAL project from title blocks, project data tables, and drawings (project name, use, zoning/district, applicant). This review track covers ONLY {track_scope}. If the submission does not match this track (wrong building type, use, or application kind), set submission_check.matches_declared_scope=false, and if another available review track fits, name it in submission_check.suggested_track, explain what the submission actually is, produce ZERO track-specific findings, and set the verdict to "out of scope — wrong review track". Reviewing a mismatched submission against this track's criteria produces false corrections and is worse than no review. - VIOLATION IS NOT MISMATCH (critical): a submission that IS this track's project type but BREAKS one of its rules is IN SCOPE — the violation is a FINDING, never a scope rejection. For the suites track specifically: a proposal for a secondary suite, a backyard suite, or BOTH is a suite application and is in scope. The rule that a secondary suite and a backyard suite may not share one parcel is a substantive land-use requirement — when a submission proposes both on one parcel, that is a MUST-FIX land-use finding (cite the same-parcel density rule), NOT grounds to declare out of scope. Never bounce a genuine suite because it is non-compliant; reviewing non-compliant suites is the entire purpose. Set matches_declared_scope=true and raise the finding. - SUGGESTED TRACK MUST EXIST: submission_check.suggested_track may ONLY name a track from AVAILABLE TRACKS listed below. If no listed track fits better, leave suggested_track empty. Never invent a track name. - CONTENT, NOT SHELL: a submission matches this track only if it actually CONTAINS the defining content of {track_scope} — not merely a structure that could later host it. A bare accessory building (detached garage, shed, carport) shown as a single-storey vehicle structure with only overhead/man doors, no habitable rooms, no egress-window bedrooms, no kitchen/bathroom, and no dwelling-unit separation, is NOT a suite — it is an accessory building. In that case set matches_declared_scope=false and, in the note, distinguish the two real possibilities: (a) this is a garage/accessory-building-only permit → out of scope for suite review; or (b) a suite is intended but the suite-defining drawings (floor plan with habitable rooms, egress windows, fire/sound separations, plumbing) are ABSENT from this set → incomplete submission. Do NOT infer suite intent from garage dimensions alone, and never pass an accessory-building-only set as "in scope" with zero findings — that falsely implies a compliant suite. - REVIEW ORDER (follow the municipality's own sequence): (1) read the SITE ADDRESS from the title block/site plan and report it; (2) determine the LAND USE DISTRICT for that address — from the drawings if stated, otherwise from the district supplied to you in the project context; (3) look the district up in the district-standards reference and apply ITS standards; (4) only then assess the submission and write findings. If a district-specific standard is not populated in the knowledge (marked [VERIFY]), say the district standard could not be confirmed and record an information gap — never substitute another district's numbers or invent one. - USE TYPE WORDING: never report use_type as "not determinable without the district" when a district has been supplied or is shown on the plans — that is self-contradictory. If the district is known but its permitted/discretionary use list is not in the loaded knowledge, say so naming the district. Reserve "without the district" for the case where the district itself is genuinely unknown. - IDENTIFY THE LAND USE DISTRICT FIRST. Every municipal review states the district and the Use Type (Permitted / Discretionary) in its header, because district determines which standards apply. Populate submission_check.land_use_district and use_type. If the drawings do not state the district, set it to "not shown on the submitted plans" and treat EVERY district-dependent conclusion as unconfirmed, saying so — do not assume a district. - BYLAW DISCREPANCY FORM (land-use findings): every land-use finding must additionally carry `regulation`, `standard`, and `provided`, taken from the regulation index in the knowledge. `regulation` is the City's heading (e.g. "412 Parcel Coverage"); `standard` is that regulation's Standard wording; `provided` states what the plans actually show, in the City's register, with the numeric delta where measurable (e.g. "Plans indicate a rear setback of 1.02m (-0.48m)." / "Plans do not indicate a designated private amenity space for the Backyard Suite."). Safety-codes and reviewer-items leave these three fields empty. - RUN EVERY MANDATORY CHECK. The regulation index lists checks that must be assessed on EVERY suite review — parcel coverage, parking per dwelling unit, suite parking, rear setback, façade separation, amenity space, and same-parcel suite density. If the submitted set does not contain enough information to measure one of them, still raise it, with `provided` stating plainly that the plans do not show it. Silently omitting a mandatory check is a review defect. - AVAILABLE TRACKS: {available_tracks} - CONVERT UNITS BEFORE JUDGING A DIMENSION. Drawings are often imperial while the code is metric (1 in = 25.4 mm). Never assert that a dimension fails a metric minimum without converting it first and stating the converted value in the finding. A nominal window/door callout (e.g. 48"x32") is a FRAME size, not an unobstructed opening — the correct finding is that operation type and clear-opening dimensions are not stated, not that the unit is undersized. - GROUNDED CITATIONS ONLY: every cited clause/section number must appear VERBATIM in the KNOWLEDGE below. Before writing any citation, confirm that exact number string is present in the knowledge text. If the rule is real but its number is not in the knowledge (e.g. a {jur_safety_short} article you recall but that is not quoted below), DO NOT write the number — cite the knowledge reference by name (e.g. a skill reference file) or record the gap under information_gaps. Never emit a clause number you cannot see in the knowledge; a plausible-but-unverified number (e.g. guessing a foundation article) is a citation error. - ENGINEERED-DESIGN ITEMS STAY REVIEWER-ITEMS: new exterior stairwell foundations, retaining walls, beam/structural modifications, and frost-cover/footing-depth adequacy are engineer-of-record scope. Raise them as category "reviewer-item" with a [REVIEWER: ...] blank and severity per judgment — do NOT reclassify them as safety-codes with a specific code citation, and do NOT attach a clause number you cannot verify in the knowledge. - Every finding MUST carry a specific citation: a {jur_safety_short} provision / municipal building advisory (safety-codes items) OR a {jur_landuse} section / permit condition (land-use items). No citation -> DROP the finding. No false positives. - BE EXHAUSTIVE: report EVERY discrepancy you can substantiate from the drawings and the appended per-page text — not a representative sample. A non-compliant plan commonly contains 10+ distinct issues across land-use and safety-codes; do not stop after the first several. Read each page's note block line by line (ceiling height, window sizes, door specs, stair widths, separation build-ups, alarms, sound, backflow, electrical branch circuits, amenity space) and raise each stated deficiency as its own finding. - GAP CLASSIFICATION: separate what the SUBMISSION lacks from what the LOADED KNOWLEDGE lacks. A missing dimension on a drawing is a submission gap; an unpopulated [VERIFY] district standard is a knowledge gap. Never write a knowledge gap as though the applicant failed to provide something. - VERDICT CONSISTENCY: summary.verdict must agree with the scope decision. Use an "out of scope" verdict ONLY when submission_check.matches_declared_scope is false. When the submission is in scope, the verdict states the outcome (e.g., "revisions required — N must-fix items" or "supportable subject to conditions"), never "out of scope". - Tag every finding "safety-codes" or "land-use" — never blend them; their variance/appeal paths differ. {jur_transition_rule}- Structural, engineering, and professional-judgment items get category "reviewer-item" with a [REVIEWER: ...] placeholder instead of invented specifications. - Mark visual reads you are unsure of with confidence "low" and prefix the description with [VERIFY]. - Use only the knowledge provided; if a needed criterion is not in the knowledge, record it as an information gap rather than inventing a number.""" ROUTER_SYSTEM_TMPL = """You are the knowledge router for a {jur_domain}. You will see: (a) each skill's SKILL.md decision-tree router, (b) a manifest of every loadable knowledge source. Pick the sources needed for THIS project. Follow the routers; include loose knowledge files whose titles/heads are relevant. Respond with ONLY a JSON object: {{"selected": ["/", ...], "reasoning": ""}} — no markdown fences, no extra text.""" REVIEW_SYSTEM_TMPL = """You are CrossBeam, a {track_label} plan reviewer for {jur_place}. You review uploaded architectural plan pages against the knowledge provided and produce draft correction findings. {critical_rules} KNOWLEDGE (authoritative for this review): {knowledge} OUTPUT: respond with ONLY a JSON object, no markdown fences: {{ "submission_check": {{"detected_project": "...", "detected_use_or_district": "...", "land_use_district": "", "use_type": " use list not in loaded knowledge'; only write 'not determinable without the district' when the district itself is unknown>", "matches_declared_scope": true, "suggested_track": "", "note": "..."}}, "sheet_manifest": [{{"page": 1, "sheet_id": "A1", "title": "..."}}], "findings": [ {{"id": "F-01", "page": 2, "sheet_id": "A2", "discipline": "arch|site|structural|mep|planning", "category": "safety-codes|land-use|reviewer-item", "description": "...", "citation": "...", "severity": "must-fix|clarify|advisory", "confidence": "high|medium|low", "regulation": "", "standard": "", "provided": ""}} ], "summary": {{"verdict": "...", "must_fix": 0, "clarify": 0, "advisory": 0, "transition_note": "...", "information_gaps_submission": [""], "information_gaps_knowledge": [""]}} }}""" DEFAULT_CHAT_SYSTEM_TMPL = """You are a {jur_persona} ({jur_safety} + {jur_landuse}). HOW TO REASON (follow this every time): 1. PLAN: briefly decide which tool calls you need. 2. GATHER: call the tools — query the findings/pages/knowledge database rather than guessing. Never hand-compute what a query returns. 3. VERIFY: sanity-check numbers (counts sum, severities match the run row) before presenting. 4. ANSWER: lead with the engineering conclusion, then supporting specifics with citations ({jur_safety_short} vs {jur_landuse_short}), then a clear recommendation. KEY PRINCIPLES: - Safety-codes items and land-use items follow different appeal paths — keep them distinct. {jur_chat_transition}- If it isn't in the database or knowledge, say so plainly rather than inventing it.""" # Module-level default so callers that don't pass a jurisdiction still work # (the Calgary reference implementation). App/engine callers pass an explicit one. _DEFAULT_JUR = load_jurisdiction(".") def _jur_fields(jur: Jurisdiction) -> dict: """Common jurisdiction substitutions shared by every prompt template.""" trule = jur.transition_rule() return { "jur_place": jur.place, "jur_safety": jur.safety_framework, "jur_safety_short": jur.safety_short, "jur_landuse": jur.landuse_framework, "jur_landuse_short": jur.landuse_short, "jur_persona": jur.reviewer_persona, "jur_domain": jur.review_domain, # transition lines are whole bullets that vanish when disabled "jur_transition_rule": (f"- {trule}\n" if trule else ""), "jur_chat_transition": ( f"- District/time-dependent answers must state the application date relative to " f"{jur.transition_label}.\n" if jur.transition_label else ""), } def build_critical_rules(track_label: str, track_scope: str, available_tracks: str, jur: Jurisdiction) -> str: return CRITICAL_RULES_TMPL.format(track_label=track_label, track_scope=track_scope, available_tracks=available_tracks, **_jur_fields(jur)) def build_router_system(jur: Jurisdiction) -> str: return ROUTER_SYSTEM_TMPL.format(**_jur_fields(jur)) def build_chat_system(jur: Jurisdiction) -> str: return DEFAULT_CHAT_SYSTEM_TMPL.format(**_jur_fields(jur)) CHAT_TOOL_PROTOCOL = """ TOOLS — to use one, respond with ONLY a JSON object (no other text): {"tool": "sql", "query": "SELECT ..."} -- SELECT-only queries against the schema below {"tool": "knowledge_search", "q": "search terms"} -- full-text search across all loaded knowledge {"tool": "page_text", "page": 2} -- embedded text of a plan page You may call tools across multiple turns. When you have enough, answer in plain language (not JSON). """ + DB_SCHEMA_DOC def _parse_json(text: str) -> dict: text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.S) start, end = text.find("{"), text.rfind("}") if start == -1: raise ValueError("no JSON object in model output") candidate = text[start:end + 1] if end > start else text[start:] try: return json.loads(candidate) except json.JSONDecodeError: pass # Recovery path: models that hit the token cap emit a truncated final finding # (e.g. "Expecting ',' delimiter"). Rather than lose the whole review, salvage # the well-formed findings and the summary/submission_check we can still read. return _salvage_json(text[start:]) def _salvage_json(blob: str) -> dict: """Best-effort recovery of a truncated findings JSON object.""" out: dict = {} # submission_check: grab the first balanced {...} after the key for key in ("submission_check", "summary"): obj = _extract_object(blob, key) if obj is not None: out[key] = obj # findings: collect every complete top-level object inside the findings array fmatch = re.search(r'"findings"\s*:\s*\[', blob) if fmatch: i = fmatch.end() findings = [] depth = 0 buf = "" while i < len(blob): c = blob[i] if c == "]" and depth == 0: break if c == "{": if depth == 0: buf = "" depth += 1 if depth > 0: buf += c if c == "}": depth -= 1 if depth == 0: try: findings.append(json.loads(buf)) except json.JSONDecodeError: pass # drop the truncated last one i += 1 out["findings"] = findings out.setdefault("findings", []) summ = out.setdefault("summary", {}) if "must_fix" not in summ: summ["must_fix"] = sum(1 for f in out["findings"] if str(f.get("severity", "")).lower() == "must-fix") summ.setdefault("clarify", 0) summ.setdefault("advisory", 0) summ.setdefault("verdict", f"revisions required — {summ['must_fix']} must-fix item(s) " f"(recovered from truncated model output)") out["_recovered"] = True return out def _extract_object(blob: str, key: str): m = re.search(rf'"{key}"\s*:\s*{{', blob) if not m: return None i = m.end() - 1 depth = 0 buf = "" while i < len(blob): c = blob[i] if c == "{": depth += 1 buf += c if c == "}": depth -= 1 if depth == 0: try: return json.loads(buf) except json.JSONDecodeError: return None i += 1 return None # ─────────────────────────────────────────────────────────── review pipeline ── def route(provider: str, model: str, api_key: str, skills: list[Skill], loose: list[Source], project_desc: str, base_url: str = "", jur: Jurisdiction | None = None) -> tuple[list[Source], str]: jur = jur or _DEFAULT_JUR all_sources = {s.key: s for sk in skills for s in sk.sources} | {s.key: s for s in loose} if sum(len(s.content) for s in all_sources.values()) <= KNOWLEDGE_BUDGET_CHARS // 2: return list(all_sources.values()), "small knowledge base — loaded everything" routers = "\n\n".join(f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills) try: raw = llm_call(provider, model, api_key, build_router_system(jur), f"PROJECT:\n{project_desc}\n\nROUTERS:\n{routers}\n\nMANIFEST:\n" f"{knowledge_manifest(skills, loose)}", max_tokens=1200, base_url=base_url) data = _parse_json(raw) picked = [all_sources[k] for k in data.get("selected", []) if k in all_sources] if not picked: raise ValueError("router selected nothing recognizable") out, used = [], 0 for s in picked: if used + len(s.content) > KNOWLEDGE_BUDGET_CHARS: break out.append(s); used += len(s.content) return out, data.get("reasoning", "routed") except Exception as exc: # noqa: BLE001 out, used = [], 0 for s in all_sources.values(): if used + len(s.content) > KNOWLEDGE_BUDGET_CHARS: break out.append(s); used += len(s.content) return out, f"router fallback ({exc})" def run_review(provider: str, model: str, api_key: str, skills: list[Skill], selected: list[Source], project_desc: str, images_b64: list[str], page_texts: list[str], system_extra: str = "", track: dict | None = None, available_tracks: list[str] | None = None, base_url: str = "", jur: Jurisdiction | None = None) -> dict: jur = jur or _DEFAULT_JUR track = track or {"label": DEFAULT_TRACK_META["suites"]["label"], "scope": DEFAULT_TRACK_META["suites"]["scope"]} knowledge_blob = "\n\n".join( [f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills] + [f"=== {s.key} ===\n{s.content}" for s in selected])[:KNOWLEDGE_BUDGET_CHARS + 40_000] avail = ", ".join(available_tracks) if available_tracks else track["label"] rules = build_critical_rules(track["label"], track["scope"], avail, jur) system = REVIEW_SYSTEM_TMPL.format(track_label=track["label"], jur_place=jur.place, critical_rules=rules, knowledge=knowledge_blob) if system_extra.strip(): system = system_extra.strip() + "\n\n" + system vision = supports_vision(provider, model) user_text = (f"PROJECT DETAILS:\n{project_desc}\n\nToday's date: {date.today().isoformat()}.\n" + ("Plan pages follow as images (page 1..n); " if vision else "NOTE: this model has no vision — review from embedded text only; " "mark all geometric/visual checks as information gaps; ") + "embedded text extracted per page is appended.") user_text += "\n" + "\n".join(f"--- embedded text, page {i + 1} ---\n{t}" for i, t in enumerate(page_texts)) raw = llm_call(provider, model, api_key, system, user_text, images_b64=images_b64 if vision else None, max_tokens=8000, base_url=base_url) result = _parse_json(raw) # Deterministic reconciliation: the verdict string must not contradict the # scope decision or the findings. The model sometimes keeps an "out of scope" # verdict out of habit even when it correctly kept the suite in scope and # raised the same-parcel issue as a finding. try: sc = result.get("submission_check", {}) or {} summ = result.setdefault("summary", {}) # Recount severities from the findings themselves. Models miscount their own # output, and a review whose header disagrees with its own list is not usable. _sev = {"must-fix": 0, "clarify": 0, "advisory": 0} for _f in (result.get("findings") or []): _k = _f.get("severity") if _k in _sev: _sev[_k] += 1 summ["must_fix"] = _sev["must-fix"] summ["clarify"] = _sev["clarify"] summ["advisory"] = _sev["advisory"] # The counters above are authoritative, but the verdict STRING is written # free-hand by the model and routinely carries a different number (it tends # to echo the safety-codes bucket size). A header that contradicts its own # counters two lines later is not usable, so any count embedded in the # verdict is rewritten in place — phrasing preserved, arithmetic corrected. _v = str(summ.get("verdict", "") or "") if _v: _fixed = re.sub(r"\b\d+\s+(?=must[-\s]?fix)", f"{_sev['must-fix']} ", _v, flags=re.IGNORECASE) if _fixed != _v: summ["verdict"] = _fixed summ["_verdict_recount"] = ( f"verdict count corrected to {_sev['must-fix']} from the findings list") in_scope = sc.get("matches_declared_scope", True) n_find = len(result.get("findings", []) or []) verdict = str(summ.get("verdict", "") or "") # If the model produced findings, it reviewed the submission in scope. # A leftover matches_declared_scope=false is a habit-echo (e.g. reading a # same-parcel violation as a scope call) — reconcile it so the OUT OF # SCOPE banner does not contradict the findings. if n_find > 0 and not in_scope: sc["matches_declared_scope"] = True in_scope = True note = str(sc.get("note", "") or "") flag = " (in scope — the issue noted here is raised as a finding, not a scope rejection)" if "raised as a finding" not in note: sc["note"] = (note + flag).strip() result["submission_check"] = sc # Weak-model guard: some smaller models declare a REAL suite out of scope # AND emit zero findings (e.g. reading the same-parcel density rule as a # scope call). We cannot recover their findings, but we must not present a # false "out of scope — 0 findings" on a genuine suite. Detect the # fingerprint: out-of-scope + zero findings, but the model's OWN note # identifies the submission as a suite. Flag it as an incomplete/low-quality # run rather than a clean rejection, so the user re-runs on a stronger model. else: note_l = (str(sc.get("detected_project", "")) + " " + str(sc.get("note", ""))).lower() looks_like_suite = "suite" in note_l genuine_nonsuite = any(w in note_l for w in ("garage", "accessory building", "school", "apartment", "commercial", "industrial", "institutional", "not a suite")) if (not in_scope and n_find == 0 and looks_like_suite and not genuine_nonsuite): summ["verdict"] = ("inconclusive — this appears to be a suite submission but the " "selected model returned no findings and mis-flagged scope. " "Re-run on a stronger model (Claude, GPT-4.1, or Gemini) for a " "reliable review.") sc["note"] = (str(sc.get("note", "")).strip() + " [Auto-flag: same-parcel or other suite-rule violations are FINDINGS, " "not a scope rejection — this run under-performed; re-run recommended.]").strip() result["submission_check"] = sc if (in_scope or n_find > 0) and "out of scope" in verdict.lower(): mf = summ.get("must_fix", n_find) summ["verdict"] = (f"revisions required — {mf} must-fix item(s)" if n_find else "in scope — see submission check") except Exception: # noqa: BLE001 — never let reconciliation break a review pass return result # ── information gaps: submission vs knowledge base ────────────────────────── # A gap in the SUBMISSION is actionable by the applicant and belongs in a # City-facing letter. A gap in the LOADED KNOWLEDGE is a statement about this # tool's own coverage — true, worth recording internally, and corrosive if it # reaches an external document under "provided as a courtesy". _KB_GAP_MARKERS = ( "loaded knowledge", "knowledge base", "knowledge is not", "not fully populated", "not populated", "[verify]", "skill", "reference file", "not configured", "no skills", "knowledge does not", "not in the knowledge", ) def split_information_gaps(gaps: list) -> tuple[list, list]: """Return (submission_gaps, knowledge_gaps). Heuristic fallback for models that emit a single flat `information_gaps` array. The schema now asks for the split explicitly; this keeps older or weaker model output from leaking tooling state into the letter. """ sub, kb = [], [] for g in gaps or []: t = str(g).lower() (kb if any(m in t for m in _KB_GAP_MARKERS) else sub).append(g) return sub, kb def resolved_gaps(summary: dict) -> tuple[list, list]: """Prefer explicit model-supplied arrays, else split the flat one.""" sub = summary.get("information_gaps_submission") kb = summary.get("information_gaps_knowledge") if sub is not None or kb is not None: return list(sub or []), list(kb or []) return split_information_gaps(summary.get("information_gaps") or []) # ───────────────────────────────────────────────────────────── chat with DB ── def _run_tool(con: sqlite3.Connection, call: dict, all_sources: list[Source]) -> str: tool = call.get("tool") if tool == "sql": q = (call.get("query") or "").strip().rstrip(";") if not re.match(r"(?is)^\s*(select|with)\b", q) or re.search( r"(?i)\b(insert|update|delete|drop|alter|create|attach|pragma)\b", q): return "ERROR: only read-only SELECT/WITH queries are allowed." try: cur = con.execute(q) cols = [d[0] for d in cur.description] rows = cur.fetchmany(60) return json.dumps({"columns": cols, "rows": rows, "row_count": len(rows)}) except Exception as exc: # noqa: BLE001 return f"SQL ERROR: {exc}" if tool == "knowledge_search": q = (call.get("q") or "").lower() hits = [] for s in all_sources: idx = s.content.lower().find(q) if q and idx >= 0: hits.append({"source": s.key, "excerpt": s.content[max(0, idx - 200):idx + 500]}) if len(hits) >= 5: break return json.dumps(hits or [{"note": "no matches"}]) if tool == "page_text": try: row = con.execute("SELECT text FROM pages WHERE page=?", (int(call.get("page", 0)),)).fetchone() return row[0] if row else "ERROR: page not found" except Exception as exc: # noqa: BLE001 return f"ERROR: {exc}" return f"ERROR: unknown tool {tool!r}" def chat_turn(provider: str, model: str, api_key: str, system_prompt: str, con: sqlite3.Connection, skills: list[Skill], loose: list[Source], history: list[dict], user_msg: str, doc_context: str = "", max_iters: int = 4, base_url: str = "") -> tuple[str, list[dict]]: """Provider-agnostic tool loop. Returns (answer, tool_trace).""" all_sources = [x for sk in skills for x in sk.sources] + loose system = system_prompt.strip() + "\n" + CHAT_TOOL_PROTOCOL if doc_context: system += "\n\n=== REFERENCE DOCUMENTS ===\n" + doc_context[:20_000] convo = list(history) pending = user_msg trace = [] for _ in range(max_iters): reply = llm_call(provider, model, api_key, system, pending, history=convo, max_tokens=2500, base_url=base_url) try: call = _parse_json(reply) if not isinstance(call, dict) or "tool" not in call: raise ValueError except Exception: # noqa: BLE001 return reply, trace result = _run_tool(con, call, all_sources) trace.append({"call": call, "result": result[:400]}) convo = convo + [{"role": "user", "content": pending}, {"role": "assistant", "content": reply}] pending = f"TOOL RESULT:\n{result[:6000]}\n\nContinue (call another tool as JSON, or answer in plain language)." return reply if isinstance(reply, str) else "Tool budget exhausted.", trace # ──────────────────────────────────────────────────────────────── reporting ── def render_report(result: dict, project_desc: str, routed_note: str, selected: list[Source], pages_reviewed: int, total_pages: int, provider: str, model: str, jur: Jurisdiction | None = None) -> str: jur = jur or _DEFAULT_JUR s = result.get("summary", {}) lines = [ f"# {jur.report_title}", "", f"*Generated {date.today().isoformat()} · {provider} / {model} · " f"pages reviewed: {pages_reviewed}/{total_pages} · knowledge routing: {routed_note}*", "", ] if result.get("_recovered"): lines += ["> ⚠️ **Partial output recovered.** The model's response was cut off " "(token limit) and its JSON was truncated. The findings below were salvaged " "from the complete portion; the last finding may be missing. Re-run for a full " "review — a model with a smaller output (or fewer findings) usually completes cleanly.", ""] lines += [ "## Project", project_desc.strip() or "_no description provided_", "", "## Submission Check", (lambda sc: (("⚠️ **OUT OF SCOPE — wrong review track.** " if not sc.get("matches_declared_scope", True) else "") + f"Detected: {sc.get('detected_project', '—')} · {sc.get('detected_use_or_district', '—')}. " + sc.get("note", "") + (f" **Suggested track: {sc['suggested_track']}.**" if sc.get("suggested_track") else "")))( result.get("submission_check", {})) or "—", "", "## Verdict", s.get("verdict", "—"), "", f"**Must-fix:** {s.get('must_fix', 0)} · **Clarify:** {s.get('clarify', 0)} · " f"**Advisory:** {s.get('advisory', 0)}", "", ] if s.get("transition_note"): lines += ["> **Zoning transition:** " + s["transition_note"], ""] lines += ["## Findings", ""] for f in result.get("findings", []): lines += [ f"### {f.get('id', '?')} — {re.split(r'(?<=[^0-9])[.] ', f.get('description', ''))[0][:90]}", f"- **Page/Sheet:** {f.get('page', '?')} / {f.get('sheet_id', '?')} · " f"**Discipline:** {f.get('discipline', '?')} · **Category:** {f.get('category', '?')} · " f"**Severity:** {f.get('severity', '?')} · **Confidence:** {f.get('confidence', '?')}", f"- **Item:** {f.get('description', '')}", f"- **Citation:** {f.get('citation', '')}", "", ] sub_gaps, kb_gaps = resolved_gaps(s) if sub_gaps: lines += ["## Information Gaps — submission", "", "Missing from the submitted set; actionable by the applicant.", ""] lines += [f"- {g}" for g in sub_gaps] + [""] if kb_gaps: lines += ["## Knowledge-base Limitations — internal", "", "Coverage gaps in THIS TOOL's loaded knowledge, not deficiencies in the " "submission. These are deliberately withheld from the City-facing letter; " "they are the backlog for populating the reference files.", ""] lines += [f"- {g}" for g in kb_gaps] + [""] lines += ["## Knowledge Sources Used", "", *[f"- `{src.key}` ({src.origin})" for src in selected], "", "---", f"*{jur.report_footer}*"] return "\n".join(lines) def findings_df_rows(result: dict) -> list[dict]: return [{"ID": f.get("id", ""), "Page": f.get("page", ""), "Sheet": f.get("sheet_id", ""), "Discipline": f.get("discipline", ""), "Category": f.get("category", ""), "Severity": f.get("severity", ""), "Confidence": f.get("confidence", ""), "Description": f.get("description", ""), "Citation": f.get("citation", "")} for f in result.get("findings", [])] # ═══════════════════════════════════════════════════════════════════════════ # FLOW 1 — CORRECTIONS RESPONSE (applicant-side) # Input: a municipal corrections / Detailed-Review letter (+ optionally the # plans for context). Output: an item-by-item interpretation grounded in the # loaded knowledge, plus a DRAFT response letter scaffolded with explicit # [APPLICANT: ...] placeholders. It NEVER fabricates that something was fixed. # ═══════════════════════════════════════════════════════════════════════════ CORRECTIONS_SYSTEM_TMPL = """You are CrossBeam, assisting an applicant to interpret and respond to a municipal plan-review corrections letter for {jur_place}. You are given: (a) the text of the corrections / Detailed-Review letter, and optionally (b) the applicant's plan pages for context. Your job is to help the applicant UNDERSTAND each correction and STRUCTURE a response — NOT to claim anything has been fixed. CRITICAL HONESTY RULES: - NEVER state or imply that a correction has been resolved, that a drawing has been revised, or that a value now complies. You do not know what the applicant will change. Every resolution belongs to the applicant and is represented by an [APPLICANT: ...] placeholder. - GROUNDED CITATIONS ONLY: cite a {jur_safety_short} clause or {jur_landuse_short} section ONLY if that exact number appears in the KNOWLEDGE below. If the letter names a section the knowledge doesn't contain, echo the letter's own citation and mark governing_rule_source "from letter"; never invent a number. - Distinguish what the CITY is asking from what the CODE requires from what the APPLICANT must do. Keep them separate. - If a correction is ambiguous or needs information not present, say so in needs_from_applicant rather than guessing. - Category each item: land-use, safety-codes, completeness (missing document/signature/form), or other. KNOWLEDGE (authoritative — cite only what appears here): {knowledge} OUTPUT: respond with ONLY a JSON object, no markdown fences: {{ "letter_meta": {{"permit_number": "...", "municipality": "...", "review_type": "...", "date": "...", "contact": "..."}}, "corrections": [ {{"id": "C-01", "city_item": "", "category": "land-use|safety-codes|completeness|other", "governing_rule": "", "governing_rule_source": "knowledge|from letter|none", "interpretation": "", "resolution_options": ["