Land-Develop-MCP / engine.py
razaali10's picture
Update engine.py
3d382a7 verified
Raw
History Blame Contribute Delete
83.3 kB
"""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": ["<skill>/<rel>", ...], "reasoning": "<one sentence>"}} β€” 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": "<district exactly as designated, or 'not shown on the submitted plans'>",
"use_type": "<Permitted|Discretionary β€” if the district IS known but its use list is not in the knowledge, write 'not determinable β€” <district> 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": "<land-use only: heading as the City writes it, e.g. '412 Parcel Coverage'>",
"standard": "<land-use only: the Standard wording for that regulation>",
"provided": "<land-use only: what the plans show, City register, with numeric delta>"}}
],
"summary": {{"verdict": "...", "must_fix": 0, "clarify": 0, "advisory": 0,
"transition_note": "...",
"information_gaps_submission": ["<missing from the SUBMITTED SET β€” the applicant can fix these>"],
"information_gaps_knowledge": ["<missing from the LOADED KNOWLEDGE β€” e.g. an unpopulated [VERIFY] district row; never phrased as an applicant deficiency>"]}}
}}"""
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": "<concise restatement of the city's correction, <=40 words>",
"category": "land-use|safety-codes|completeness|other",
"governing_rule": "<clause/section grounded in knowledge, or the letter's own citation>",
"governing_rule_source": "knowledge|from letter|none",
"interpretation": "<what the city is actually asking for, plainly>",
"resolution_options": ["<option the applicant could take>", "..."],
"needs_from_applicant": "<what the applicant must provide/change/confirm to resolve this>",
"confidence": "high|medium|low"}}
],
"summary": {{"total_items": 0, "by_category": {{"land-use": 0, "safety-codes": 0, "completeness": 0, "other": 0}}, "notes": "..."}}
}}"""
def run_corrections(provider: str, model: str, api_key: str, skills: list[Skill],
selected: list[Source], letter_text: str, plan_context: str = "",
images_b64: list[str] | None = None, system_extra: str = "",
base_url: str = "", jur: Jurisdiction | None = None) -> dict:
"""Interpret a corrections letter into grounded, per-item analysis."""
jur = jur or _DEFAULT_JUR
images_b64 = images_b64 or []
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]
system = CORRECTIONS_SYSTEM_TMPL.format(knowledge=knowledge_blob, **_jur_fields(jur))
if system_extra.strip():
system = system_extra.strip() + "\n\n" + system
vision = supports_vision(provider, model) and images_b64
user = ("CORRECTIONS / DETAILED-REVIEW LETTER (verbatim text):\n"
+ letter_text[:40_000]
+ (("\n\nAPPLICANT PLAN CONTEXT (extracted text, for your understanding "
"of what the drawings currently show β€” do NOT assume anything is "
"revised):\n" + plan_context[:20_000]) if plan_context else "")
+ "\n\nParse EVERY correction/condition/discrepancy in the letter into its own item.")
raw = llm_call(provider, model, api_key, system, user,
images_b64=images_b64 if vision else None, max_tokens=8000,
base_url=base_url)
result = _parse_json(raw)
# deterministic backfill of the summary counts
try:
items = result.get("corrections", []) or []
by = {"land-use": 0, "safety-codes": 0, "completeness": 0, "other": 0}
for it in items:
by[it.get("category", "other") if it.get("category") in by else "other"] += 1
result.setdefault("summary", {})
result["summary"]["total_items"] = len(items)
result["summary"]["by_category"] = by
except Exception: # noqa: BLE001
pass
return result
def render_corrections_analysis(result: dict, jur: Jurisdiction | None = None) -> str:
"""Human-readable per-item analysis (Markdown)."""
jur = jur or _DEFAULT_JUR
m = result.get("letter_meta", {})
s = result.get("summary", {})
by = s.get("by_category", {})
lines = [
f"# Corrections Analysis β€” {jur.place}", "",
f"*Permit {m.get('permit_number', 'β€”')} Β· {m.get('review_type', 'β€”')} Β· "
f"received {m.get('date', 'β€”')}*", "",
f"**{s.get('total_items', 0)} correction item(s)** β€” "
f"land-use {by.get('land-use', 0)} Β· safety-codes {by.get('safety-codes', 0)} Β· "
f"completeness {by.get('completeness', 0)} Β· other {by.get('other', 0)}", "",
(s.get("notes", "") or ""), "",
"## Item-by-item", "",
]
for c in result.get("corrections", []):
rule = c.get("governing_rule", "").strip()
src = c.get("governing_rule_source", "")
rule_line = (f" Β· **Rule:** {rule}" + (f" _({src})_" if src and src != "none" else "")) if rule else ""
opts = c.get("resolution_options", []) or []
lines += [
f"### {c.get('id', '?')} β€” {c.get('category', '')}"
f" Β· confidence {c.get('confidence', 'β€”')}",
f"**City asks:** {c.get('city_item', '')}{rule_line}",
"",
f"**What this means:** {c.get('interpretation', '')}",
"",
]
if opts:
lines.append("**Options to resolve:**")
lines += [f"- {o}" for o in opts]
lines.append("")
lines += [f"**You must provide:** {c.get('needs_from_applicant', '')}", "", "---", ""]
lines += ["*Draft interpretation for the applicant's use β€” not legal advice and not a "
"sealed professional opinion. Verify every cited clause against the current "
f"{jur.safety_short} / {jur.landuse_short} and confirm each resolution before "
"submitting.*"]
return "\n".join(lines)
def render_response_letter(result: dict, jur: Jurisdiction | None = None,
applicant_name: str = "", contact: str = "") -> str:
"""DRAFT response letter, scaffolded with [APPLICANT: ...] placeholders.
Deliberately does NOT claim any correction is resolved β€” each item leaves an
explicit placeholder for the applicant to describe their actual resolution.
"""
jur = jur or _DEFAULT_JUR
m = result.get("letter_meta", {})
perm = m.get("permit_number", "[permit number]")
lines = [
f"# DRAFT Response Letter β€” {perm}", "",
"> This is a scaffold. Replace every **[APPLICANT: …]** placeholder with your "
"actual resolution and the revised sheet reference before sending. Do not send "
"with placeholders remaining.", "",
"---", "",
f"To: {m.get('municipality', jur.place)} β€” Planning & Development",
f"Re: {perm} β€” {m.get('review_type', 'Detailed Review')} β€” Response to corrections",
f"Date: [date]", "",
"Dear Reviewer,", "",
"Thank you for the review comments. We respond to each item below and have "
"included a revised drawing set with this resubmission.", "",
]
for c in result.get("corrections", []):
cid = c.get("id", "?")
rule = c.get("governing_rule", "").strip()
rule_txt = f" (re: {rule})" if rule else ""
lines += [
f"**{cid} β€” {c.get('city_item', '')}**{rule_txt}",
f"[APPLICANT: describe how you resolved this β€” e.g. \"Revised {c.get('needs_from_applicant', 'the item')} "
f"as shown on Sheet ___.\" If you disagree or seek relaxation, state your rationale here.]",
"",
]
lines += [
"We trust the above and the revised drawings address the review comments. "
"Please contact us with any further questions.", "",
"Sincerely,",
applicant_name or "[Applicant / Agent name]",
contact or "[Contact]", "",
"---",
"*Draft generated for the applicant's use. The applicant is responsible for the "
"accuracy of every statement and resolution before submission.*",
]
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# FLOW 2 β€” PRE-SUBMISSION CHECKLIST (applicant-side)
# Input: the chosen review track (+ optional project details). Output: what a
# complete submission needs, drawn from the loaded skills for that track.
# ═══════════════════════════════════════════════════════════════════════════
CHECKLIST_SYSTEM_TMPL = """You are CrossBeam, generating a PRE-SUBMISSION checklist for a {track_label} in {jur_place}.
Produce a practical checklist of what a COMPLETE submission needs, drawn ONLY from the KNOWLEDGE below. Group items into: required drawings/sheets, required data/calculations to show on those sheets, and common review pitfalls for this project type. Where the knowledge gives a specific threshold or rule an applicant commonly misses, include it as a pitfall with its grounded citation.
GROUNDED ONLY: cite a clause/section number ONLY if it appears in the KNOWLEDGE. Otherwise describe the requirement without a number. Do not invent requirements not supported by the knowledge; if the knowledge is thin on completeness, say so in notes.
KNOWLEDGE:
{knowledge}
OUTPUT: respond with ONLY a JSON object, no markdown fences:
{{
"required_drawings": ["<sheet/drawing a complete set includes>", "..."],
"required_data": ["<data/calculation/label that must appear>", "..."],
"common_pitfalls": [{{"item": "<what applicants commonly miss>", "citation": "<grounded or ''>"}}],
"notes": "<caveats, especially where knowledge is incomplete>"
}}"""
def run_checklist(provider: str, model: str, api_key: str, skills: list[Skill],
track: dict, project_desc: str = "", base_url: str = "",
jur: Jurisdiction | None = None) -> dict:
"""Generate a pre-submission checklist for a track from its skills."""
jur = jur or _DEFAULT_JUR
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 sk in skills for s in sk.sources]
)[:KNOWLEDGE_BUDGET_CHARS]
system = CHECKLIST_SYSTEM_TMPL.format(track_label=track.get("label", "project"),
knowledge=knowledge_blob, **_jur_fields(jur))
user = (f"Project type: {track.get('label', '')}\n"
f"Project details (optional): {project_desc or 'not provided'}\n"
"Generate the pre-submission checklist from the knowledge above.")
raw = llm_call(provider, model, api_key, system, user, max_tokens=3000, base_url=base_url)
return _parse_json(raw)
def render_checklist(result: dict, track_label: str, jur: Jurisdiction | None = None) -> str:
jur = jur or _DEFAULT_JUR
lines = [f"# Pre-Submission Checklist β€” {track_label}", f"*{jur.place}*", ""]
if result.get("required_drawings"):
lines += ["## Required drawings / sheets", ""]
lines += [f"- [ ] {d}" for d in result["required_drawings"]] + [""]
if result.get("required_data"):
lines += ["## Data & calculations to show", ""]
lines += [f"- [ ] {d}" for d in result["required_data"]] + [""]
if result.get("common_pitfalls"):
lines += ["## Common review pitfalls", ""]
for p in result["common_pitfalls"]:
cite = p.get("citation", "").strip()
lines.append(f"- [ ] {p.get('item', '')}" + (f" β€” _{cite}_" if cite else ""))
lines.append("")
if result.get("notes"):
lines += ["---", f"*{result['notes']}*"]
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# CITY REVIEW LETTER FORMAT
# Mirrors a municipal Detailed Review: header block, General Comments, a
# Bylaw Discrepancies table (Regulation | Standard | Provided), Prior to
# Decision Requirements, then Advisory Comments.
# ═══════════════════════════════════════════════════════════════════════════
def check_application_date(entered: str, page_texts: list[str]) -> str:
"""Cross-check the entered application date against dates in the drawings.
The entered date drives every district/transition conclusion, so a typo silently
changes the legal framing. Returns a warning string, or "" when consistent.
Deterministic β€” no LLM.
"""
if not entered:
return ""
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", entered.strip())
if not m:
return ""
entered_year = m.group(1)
blob = "\n".join(page_texts or [])
# Dates in title blocks: YYYY-MM-DD, DD-MM-YY, or a bare 4-digit year near
# "ISSUED"/"REVISION"/permit numbers such as DP2024-00595.
years = set(re.findall(r"\b(20[0-9]{2})-[01]?\d-[0-3]?\d\b", blob))
years |= {y for y in re.findall(r"\b(?:DP|BP)(20[0-9]{2})-\d+", blob)}
years = {y for y in years if y.isdigit()}
if not years or entered_year in years:
return ""
found = ", ".join(sorted(years))
return (f"⚠️ Application date check: you entered **{entered}**, but the submitted "
f"documents reference year(s) **{found}**. The application date drives every "
f"district and transition conclusion in this review β€” confirm it is correct "
f"before relying on the findings.")
def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
total_pages: int, provider: str, model: str,
jur: Jurisdiction | None = None,
date_warning: str = "", site_address: str = "",
applicant: str = "", permit_number: str = "") -> str:
"""Render the review in a municipal Detailed-Review letter format."""
jur = jur or _DEFAULT_JUR
sc = result.get("submission_check", {}) or {}
s = result.get("summary", {}) or {}
findings = result.get("findings", []) or []
land_use = [f for f in findings if f.get("category") == "land-use"]
safety = [f for f in findings if f.get("category") == "safety-codes"]
reviewer = [f for f in findings if f.get("category") == "reviewer-item"]
must_fix = [f for f in findings if f.get("severity") == "must-fix"]
district = sc.get("land_use_district") or sc.get("detected_use_or_district") \
or "not shown on the submitted plans"
use_type = sc.get("use_type") or "not determinable without the district"
L = [f"# Detailed Review β€” {jur.place}", ""]
if date_warning:
L += [f"> {date_warning}", ""]
L += [
"| | |",
"|---|---|",
f"| **Application Number** | {permit_number or 'β€”'} |",
f"| **Application Description** | {sc.get('detected_project', 'β€”')} |",
f"| **Land Use District** | {district} |",
f"| **Use Type** | {use_type} |",
f"| **Site Address** | {site_address or 'β€”'} |",
f"| **Applicant** | {applicant or 'β€”'} |",
f"| **Review generated** | {date.today().isoformat()} Β· {provider} / {model} Β· "
f"pages {pages_reviewed}/{total_pages} |",
"", "## General Comments", "",
]
verdict = str(s.get("verdict", "")).strip()
# A Detailed Review is a DEVELOPMENT PERMIT document. Its support decision and
# its prior-to-decision conditions are land-use determinations; NBC items are
# resolved at building permit stage and are reported separately below. Mixing
# them made the letter condition the DP on items it had just declared out of
# the DP stream.
dp_must_fix = [f for f in must_fix if f.get("category") != "safety-codes"]
safety_must_fix = [f for f in must_fix if f.get("category") == "safety-codes"]
supported = not dp_must_fix
lead = ("In general, the proposed development is **not supported as submitted**."
if not supported else
"In general, the proposed development appears **supportable subject to the "
"conditions below**.")
L += [lead, ""]
if not supported:
themes = sorted({(f.get("regulation") or f.get("citation") or "").split("β€”")[0].strip()
for f in dp_must_fix if f.get("category") == "land-use"})
themes = [t for t in themes if t][:6]
if themes:
L += ["The submission does not meet the minimum requirements for the following "
"regulations: " + ", ".join(themes) + ".", ""]
if safety_must_fix:
L += [f"Separately, {len(safety_must_fix)} outstanding Building Code item(s) are "
f"listed under Building/Safety Codes below. Those are resolved at building "
f"permit stage and do not form part of this decision.", ""]
_v = verdict[:1].upper() + verdict[1:] if verdict else ""
L += [("Please review the bylaw discrepancies below." + (f" {_v}" if _v else "")), ""]
if sc.get("note"):
L += [sc["note"], ""]
if s.get("transition_note"):
L += [f"*{s['transition_note']}*", ""]
# ── Bylaw Discrepancies (land-use) ──
L += ["## Bylaw Discrepancies", ""]
if land_use:
L += ["| Regulation | Standard | Provided |", "|---|---|---|"]
def _reg_label(f: dict) -> str:
"""A clean Regulation cell.
Prefer the explicit `regulation` field. Never print a knowledge-file path
in this column β€” if a finding only cites a reference file (e.g. the
district-identification precondition), give it a readable heading instead.
"""
reg = (f.get("regulation") or "").strip()
if reg and ".md" not in reg and "/" not in reg:
return reg
cite = (f.get("citation") or "").strip()
if cite and ".md" not in cite and "/" not in cite:
return cite
text = (f.get("description", "") + " " + cite).lower()
if "land use district" in text or "district" in text:
return "Land Use District (to be identified)"
return "Application completeness"
for f in land_use:
reg = _reg_label(f).replace("|", "/")
std = (f.get("standard") or "").replace("|", "/").replace("\n", " ")
prov = (f.get("provided") or f.get("description", "")).replace("|", "/").replace("\n", " ")
sev = f.get("severity", "")
if sev and sev != "must-fix":
prov += f" _({sev})_"
L.append(f"| **{reg}** | {std or 'β€”'} | {prov} |")
L.append("")
else:
L += ["No land-use discrepancies were identified from the submitted set.", ""]
# ── Prior to Decision ──
L += ["## Prior to Decision Requirements", "",
"The following must be addressed by the Applicant through a written submission "
"and amended plans prior to a decision:", ""]
n = 1
L += [f"{n}. Submit a complete set of amended plans, in PDF format. Ensure that all "
f"plans affected by the revisions are amended accordingly."]
n += 1
L += [f"{n}. Submit a written response providing a point-by-point explanation of how "
f"each item below was addressed and/or resolved."]
n += 1
if district.lower().startswith("not shown"):
L += [f"{n}. **Identify the parcel's Land Use District** on the plans and confirm "
f"the Use Type (Permitted/Discretionary) for the proposed suite. Every "
f"district-dependent conclusion in this review is unconfirmed until this is "
f"provided."]
n += 1
for f in dp_must_fix:
reg = f.get("regulation") or f.get("citation") or ""
tag = f" ({reg})" if reg else ""
L += [f"{n}. {f.get('description', '').strip()}{tag}"]
n += 1
if not dp_must_fix:
L += [f"{n}. No land-use discrepancies require resolution prior to a decision."]
n += 1
if safety_must_fix:
L += [f"{n}. Address the {len(safety_must_fix)} Building Code item(s) listed under "
f"**Building/Safety Codes** below through the building permit application. "
f"They are not conditions of this development permit decision."]
n += 1
L.append("")
# ── Safety-codes / building-permit stream ──
if safety:
L += ["## Building/Safety Codes β€” separate permit stream", "",
f"The following {len(safety)} item(s) fall under the "
f"{jur.safety_framework} and are reviewed at building permit stage, not as "
"land-use discrepancies:", ""]
for f in safety:
L += [f"- **{f.get('citation', 'β€”')}** β€” {f.get('description', '').strip()} "
f"_({f.get('severity', '')})_"]
L.append("")
if reviewer:
L += ["## Reviewer / Engineer-of-Record Items", ""]
for f in reviewer:
L += [f"- {f.get('description', '').strip()}"]
L.append("")
# Only SUBMISSION gaps reach the applicant. Knowledge-base gaps are recorded in
# the internal report instead β€” telling a City reviewer that this tool's own
# standards table is unpopulated is not a courtesy, it is noise they cannot act on.
sub_gaps, _kb_gaps = resolved_gaps(s)
if sub_gaps:
L += ["## Advisory Comments", "",
"The comments below represent some, but not all, of the requirements that "
"must be complied with. They are provided as a courtesy:", ""]
L += [f"- {g}" for g in sub_gaps]
L.append("")
L += ["---", f"*{jur.report_footer}*"]
return "\n".join(L)
# ═══════════════════════════════════════════════════════════════════════════
# ADDRESS β†’ LAND USE DISTRICT
# The City's own workflow: read the address, look up the land use map, apply
# that district's standards. Address extraction is deterministic and offline.
# The district lookup is BEST-EFFORT over the municipality's open-data API and
# degrades to a manual-lookup link β€” a review must never silently depend on it.
# ═══════════════════════════════════════════════════════════════════════════
_ADDR_RE = re.compile(
r"\b(\d{1,5}(?:\s*-\s*\d{1,5})?\s+[A-Z0-9][A-Za-z0-9'\.\- ]{2,40}?"
r"\s+(?:ST|STREET|AVE|AVENUE|RD|ROAD|DR|DRIVE|WY|WAY|CR|CRES|CRESCENT|BV|BLVD|"
r"BOULEVARD|PL|PLACE|CL|CLOSE|GATE|GA|LN|LANE|TR|TRAIL|CI|CIRCLE|MR|MANOR|"
r"HT|HEIGHTS|PT|POINT|GV|GROVE|BAY|LD|LANDING|PK|PARK|GD|GARDENS|VW|VIEW|"
r"TC|TERRACE|CO|COURT|SQ|SQUARE|ME|MEWS|RI|RISE|HL|HILL|CM|COMMON|PS|PASSAGE)"
r"\.?\s*(?:N\.?E\.?|N\.?W\.?|S\.?E\.?|S\.?W\.?|N|S|E|W)?)\b",
re.IGNORECASE)
def guess_site_address(page_texts: list[str]) -> str:
"""Best-effort site address from the drawing text (title block / site plan).
Deterministic, offline. Returns "" when nothing address-like is found β€” the
caller should then ask the user rather than guessing.
"""
blob = "\n".join(page_texts or [])
counts: dict[str, int] = {}
for m in _ADDR_RE.finditer(blob):
a = re.sub(r"\s+", " ", m.group(1)).strip().rstrip(",")
if len(a) < 8 or a.lower().startswith(("sheet", "scale", "page")):
continue
counts[a] = counts.get(a, 0) + 1
if not counts:
return ""
# Title blocks repeat the project address on every sheet β€” prefer the most common,
# then the longest (more complete) form.
return sorted(counts.items(), key=lambda kv: (-kv[1], -len(kv[0])))[0][0]
def lookup_land_use_district(address: str, jur: Jurisdiction | None = None,
timeout: float = 6.0) -> dict:
"""Best-effort land use district lookup for an address.
Reads the municipality's `district_lookup` config (see jurisdiction.yaml). If no
API is configured, or the call fails/times out, returns a result whose
`manual_url` tells the reviewer where to look it up themselves. NEVER raises, and
NEVER guesses a district β€” an unavailable lookup is reported as unavailable.
Returns: {"district": str, "source": str, "manual_url": str, "note": str}
"""
jur = jur or _DEFAULT_JUR
cfg = jur.data.get("district_lookup") or {}
manual = cfg.get("manual_url", "")
out = {"district": "", "source": "", "manual_url": manual, "note": ""}
if not address:
out["note"] = "No site address supplied β€” cannot look up a district."
return out
api = cfg.get("api_url", "")
if not cfg.get("enabled") or not api:
out["note"] = ("Automated district lookup is not configured for this "
"municipality. Look the district up manually and enter it.")
return out
try:
import requests
field = cfg.get("address_field", "address")
dfield = cfg.get("district_field", "land_use_designation")
r = requests.get(api, params={"$where": f"upper({field}) like upper('%{address}%')",
"$limit": 5}, timeout=timeout)
if r.status_code != 200:
out["note"] = (f"District lookup returned HTTP {r.status_code}. "
f"Verify manually.")
return out
rows = r.json() if r.content else []
vals = {str(row.get(dfield, "")).strip() for row in rows if row.get(dfield)}
vals.discard("")
if len(vals) == 1:
out["district"] = vals.pop()
out["source"] = api
out["note"] = ("Retrieved from the municipality's open-data service β€” "
"confirm it is the designation in force on the application "
"date before relying on it.")
elif len(vals) > 1:
out["note"] = (f"Address matched multiple parcels with differing "
f"designations ({', '.join(sorted(vals))}). Confirm manually.")
else:
out["note"] = "No matching parcel found. Confirm manually."
except Exception as exc: # noqa: BLE001 β€” lookup must never break a review
out["note"] = f"District lookup unavailable ({type(exc).__name__}). Verify manually."
return out