Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- CITY_REVIEW_UPDATE.md +80 -0
- README.md +22 -1
- engine.py +199 -32
- mcp_server.py +46 -60
- requirements.txt +1 -2
- smoke_test.py +29 -1
CITY_REVIEW_UPDATE.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# City of Calgary Detailed Review alignment
|
| 2 |
+
|
| 3 |
+
This revision aligns Calgary suite reviews with the structure and review
|
| 4 |
+
discipline demonstrated by City of Calgary file DP2024-00595.
|
| 5 |
+
|
| 6 |
+
## City-style output
|
| 7 |
+
|
| 8 |
+
`review_plan_set` returns `city_review_letter_markdown` with:
|
| 9 |
+
|
| 10 |
+
1. Detailed Review 1 - Development Permit title
|
| 11 |
+
2. Application metadata
|
| 12 |
+
3. General Comments
|
| 13 |
+
4. Bylaw Discrepancies table:
|
| 14 |
+
`Regulation | Standard | Provided`
|
| 15 |
+
5. Prior to Decision Requirements
|
| 16 |
+
6. Applicant options to cancel, proceed to decision, or amend
|
| 17 |
+
7. Advisory Comments
|
| 18 |
+
|
| 19 |
+
The City-style letter is a development-permit planning document. NBC-AE and
|
| 20 |
+
engineer-of-record observations remain in `report_markdown` and are not rendered
|
| 21 |
+
as City land-use conditions.
|
| 22 |
+
|
| 23 |
+
## Municipal context inputs
|
| 24 |
+
|
| 25 |
+
The following optional `review_plan_set` arguments should be populated whenever
|
| 26 |
+
they are available:
|
| 27 |
+
|
| 28 |
+
- `application_number`
|
| 29 |
+
- `site_address`
|
| 30 |
+
- `applicant`
|
| 31 |
+
- `community`
|
| 32 |
+
- `land_use_district`
|
| 33 |
+
- `use_type`
|
| 34 |
+
- `planning_contact`
|
| 35 |
+
- `date_dr_sent`
|
| 36 |
+
- `response_due_date`
|
| 37 |
+
- `municipal_records_context`
|
| 38 |
+
|
| 39 |
+
Municipality-confirmed records override drawing silence. For example, an
|
| 40 |
+
existing secondary suite confirmed by City records is treated as confirmed
|
| 41 |
+
context even when it is absent from the submitted plan set.
|
| 42 |
+
|
| 43 |
+
## Mandatory Calgary backyard-suite checks
|
| 44 |
+
|
| 45 |
+
The reconciliation layer accounts for:
|
| 46 |
+
|
| 47 |
+
- parcel coverage;
|
| 48 |
+
- parking for the principal dwelling;
|
| 49 |
+
- parking for the backyard suite;
|
| 50 |
+
- rear-property-line setback;
|
| 51 |
+
- closest-façade separation;
|
| 52 |
+
- private amenity space; and
|
| 53 |
+
- secondary-suite/backyard-suite density.
|
| 54 |
+
|
| 55 |
+
A passing check is retained in `mandatory_checks`. A missing check becomes a
|
| 56 |
+
low-confidence clarification; it is not silently omitted and is not converted
|
| 57 |
+
into an invented violation.
|
| 58 |
+
|
| 59 |
+
## Geometry controls
|
| 60 |
+
|
| 61 |
+
- A labelled parking rectangle is accepted only when it is inside the legal
|
| 62 |
+
parcel, dimensioned, accessible, and assigned to a dwelling unit.
|
| 63 |
+
- Rear, side, and front property lines must be classified before selecting a
|
| 64 |
+
setback dimension.
|
| 65 |
+
- Backyard-suite separation uses the closest applicable façade-to-façade
|
| 66 |
+
measurement.
|
| 67 |
+
- Numeric discrepancies require Required, Provided, Delta, units, and source.
|
| 68 |
+
Unreconciled calculations are flagged instead of copied.
|
| 69 |
+
|
| 70 |
+
## Validation
|
| 71 |
+
|
| 72 |
+
Run:
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
python smoke_test.py
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
The test suite validates the MCP surface, City header, City report sections,
|
| 79 |
+
separation of DP and building-code streams, applicant options, mandatory-check
|
| 80 |
+
reconciliation, and corrections-response honesty controls.
|
README.md
CHANGED
|
@@ -22,7 +22,7 @@ Claude, ChatGPT, Gemini, n8n, HF UI, local clients, or through a tunnel.
|
|
| 22 |
| `list_municipalities` | Configured jurisdictions and their code/bylaw frameworks |
|
| 23 |
| `list_review_tracks` | Review types (suites, multi-residential, institutional…) + available models |
|
| 24 |
| `list_knowledge` | The knowledge base reviews are grounded in |
|
| 25 |
-
| `review_plan_set` | Review a plan set (PDF, base64) →
|
| 26 |
| `analyze_corrections_letter` | Interpret a corrections letter → item-by-item analysis + draft response |
|
| 27 |
| `generate_checklist` | Pre-submission checklist for a project type |
|
| 28 |
|
|
@@ -36,6 +36,27 @@ the guarantees hold no matter which client calls them. In particular the correct
|
|
| 36 |
draft never claims a correction is resolved; every resolution is an
|
| 37 |
`[APPLICANT: …]` placeholder.
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
## Running locally (STDIO, e.g. Claude Desktop)
|
| 40 |
|
| 41 |
```bash
|
|
|
|
| 22 |
| `list_municipalities` | Configured jurisdictions and their code/bylaw frameworks |
|
| 23 |
| `list_review_tracks` | Review types (suites, multi-residential, institutional…) + available models |
|
| 24 |
| `list_knowledge` | The knowledge base reviews are grounded in |
|
| 25 |
+
| `review_plan_set` | Review a plan set (PDF, base64) → structured findings, technical screening report, and City-style Detailed Review |
|
| 26 |
| `analyze_corrections_letter` | Interpret a corrections letter → item-by-item analysis + draft response |
|
| 27 |
| `generate_checklist` | Pre-submission checklist for a project type |
|
| 28 |
|
|
|
|
| 36 |
draft never claims a correction is resolved; every resolution is an
|
| 37 |
`[APPLICANT: …]` placeholder.
|
| 38 |
|
| 39 |
+
### City of Calgary Detailed Review output
|
| 40 |
+
|
| 41 |
+
For Calgary reviews, pass municipal metadata when it is available:
|
| 42 |
+
|
| 43 |
+
- `application_number`, `site_address`, `applicant`, and `community`
|
| 44 |
+
- `land_use_district` and `use_type`
|
| 45 |
+
- `planning_contact`, `date_dr_sent`, and `response_due_date`
|
| 46 |
+
- `municipal_records_context` for verified City facts not shown on the drawings,
|
| 47 |
+
such as an existing registered or permitted secondary suite
|
| 48 |
+
|
| 49 |
+
The returned `city_review_letter_markdown` follows the City's Development Permit
|
| 50 |
+
sequence: application header, General Comments, Bylaw Discrepancies
|
| 51 |
+
(`Regulation | Standard | Provided`), Prior to Decision Requirements, applicant
|
| 52 |
+
options, and Advisory Comments. Building-code and engineer-of-record observations
|
| 53 |
+
remain in the separate technical report and are not presented as City DP conditions.
|
| 54 |
+
|
| 55 |
+
The Calgary suite track deterministically reconciles parcel coverage, parking for
|
| 56 |
+
each dwelling unit, backyard-suite rear setback, closest-façade separation, private
|
| 57 |
+
amenity space, and same-parcel suite density. A missing check becomes a verification
|
| 58 |
+
item; the engine does not silently omit it or invent compliance.
|
| 59 |
+
|
| 60 |
## Running locally (STDIO, e.g. Claude Desktop)
|
| 61 |
|
| 62 |
```bash
|
engine.py
CHANGED
|
@@ -472,6 +472,12 @@ CRITICAL_RULES_TMPL = """CRITICAL RULES ({jur_place} jurisdiction — review tra
|
|
| 472 |
- 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.
|
| 473 |
- 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.
|
| 474 |
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
- AVAILABLE TRACKS: {available_tracks}
|
| 476 |
- 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.
|
| 477 |
- 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.
|
|
@@ -504,6 +510,11 @@ OUTPUT: respond with ONLY a JSON object, no markdown fences:
|
|
| 504 |
"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>",
|
| 505 |
"matches_declared_scope": true, "suggested_track": "", "note": "..."}},
|
| 506 |
"sheet_manifest": [{{"page": 1, "sheet_id": "A1", "title": "..."}}],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
"findings": [
|
| 508 |
{{"id": "F-01", "page": 2, "sheet_id": "A2", "discipline": "arch|site|structural|mep|planning",
|
| 509 |
"category": "safety-codes|land-use|reviewer-item",
|
|
@@ -517,6 +528,62 @@ OUTPUT: respond with ONLY a JSON object, no markdown fences:
|
|
| 517 |
"transition_note": "...", "information_gaps": ["..."]}}
|
| 518 |
}}"""
|
| 519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
DEFAULT_CHAT_SYSTEM_TMPL = """You are a {jur_persona} ({jur_safety} + {jur_landuse}).
|
| 521 |
|
| 522 |
HOW TO REASON (follow this every time):
|
|
@@ -700,6 +767,82 @@ def route(provider: str, model: str, api_key: str, skills: list[Skill],
|
|
| 700 |
return out, f"router fallback ({exc})"
|
| 701 |
|
| 702 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
def run_review(provider: str, model: str, api_key: str, skills: list[Skill],
|
| 704 |
selected: list[Source], project_desc: str, images_b64: list[str],
|
| 705 |
page_texts: list[str], system_extra: str = "",
|
|
@@ -730,6 +873,7 @@ def run_review(provider: str, model: str, api_key: str, skills: list[Skill],
|
|
| 730 |
images_b64=images_b64 if vision else None, max_tokens=8000,
|
| 731 |
base_url=base_url)
|
| 732 |
result = _parse_json(raw)
|
|
|
|
| 733 |
|
| 734 |
# Deterministic reconciliation: the verdict string must not contradict the
|
| 735 |
# scope decision or the findings. The model sometimes keeps an "out of scope"
|
|
@@ -1188,7 +1332,10 @@ def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
|
|
| 1188 |
total_pages: int, provider: str, model: str,
|
| 1189 |
jur: Jurisdiction | None = None,
|
| 1190 |
date_warning: str = "", site_address: str = "",
|
| 1191 |
-
applicant: str = "", permit_number: str = ""
|
|
|
|
|
|
|
|
|
|
| 1192 |
"""Render the review in a municipal Detailed-Review letter format."""
|
| 1193 |
jur = jur or _DEFAULT_JUR
|
| 1194 |
sc = result.get("submission_check", {}) or {}
|
|
@@ -1200,11 +1347,13 @@ def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
|
|
| 1200 |
reviewer = [f for f in findings if f.get("category") == "reviewer-item"]
|
| 1201 |
must_fix = [f for f in findings if f.get("severity") == "must-fix"]
|
| 1202 |
|
| 1203 |
-
district =
|
|
|
|
| 1204 |
or "not shown on the submitted plans"
|
| 1205 |
-
|
|
|
|
| 1206 |
|
| 1207 |
-
L = [
|
| 1208 |
if date_warning:
|
| 1209 |
L += [f"> {date_warning}", ""]
|
| 1210 |
L += [
|
|
@@ -1213,11 +1362,13 @@ def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
|
|
| 1213 |
f"| **Application Number** | {permit_number or '—'} |",
|
| 1214 |
f"| **Application Description** | {sc.get('detected_project', '—')} |",
|
| 1215 |
f"| **Land Use District** | {district} |",
|
| 1216 |
-
f"| **Use Type** | {
|
| 1217 |
f"| **Site Address** | {site_address or '—'} |",
|
|
|
|
| 1218 |
f"| **Applicant** | {applicant or '—'} |",
|
| 1219 |
-
f"| **
|
| 1220 |
-
f"
|
|
|
|
| 1221 |
"", "## General Comments", "",
|
| 1222 |
]
|
| 1223 |
|
|
@@ -1235,7 +1386,8 @@ def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
|
|
| 1235 |
if themes:
|
| 1236 |
L += ["The submission does not meet the minimum requirements for the following "
|
| 1237 |
"regulations: " + ", ".join(themes) + ".", ""]
|
| 1238 |
-
L += [
|
|
|
|
| 1239 |
if sc.get("note"):
|
| 1240 |
L += [sc["note"], ""]
|
| 1241 |
if s.get("transition_note"):
|
|
@@ -1293,39 +1445,54 @@ def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
|
|
| 1293 |
f"district-dependent conclusion in this review is unconfirmed until this is "
|
| 1294 |
f"provided."]
|
| 1295 |
n += 1
|
| 1296 |
-
for f in must_fix:
|
| 1297 |
reg = f.get("regulation") or f.get("citation") or ""
|
| 1298 |
tag = f" ({reg})" if reg else ""
|
| 1299 |
L += [f"{n}. {f.get('description', '').strip()}{tag}"]
|
| 1300 |
n += 1
|
| 1301 |
-
L
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
L += ["## Reviewer / Engineer-of-Record Items", ""]
|
| 1316 |
-
for f in reviewer:
|
| 1317 |
-
L += [f"- {f.get('description', '').strip()}"]
|
| 1318 |
-
L.append("")
|
| 1319 |
|
| 1320 |
gaps = s.get("information_gaps") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1321 |
if gaps:
|
| 1322 |
-
L += ["## Advisory Comments", "",
|
| 1323 |
-
"The comments below represent some, but not all, of the requirements that "
|
| 1324 |
-
"must be complied with. They are provided as a courtesy:", ""]
|
| 1325 |
L += [f"- {g}" for g in gaps]
|
| 1326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1327 |
|
| 1328 |
-
L += ["---", f"*{jur.report_footer}*"]
|
| 1329 |
return "\n".join(L)
|
| 1330 |
|
| 1331 |
|
|
|
|
| 472 |
- 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.
|
| 473 |
- 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.
|
| 474 |
- 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.
|
| 475 |
+
- MUNICIPAL CONTEXT OVERRIDES DRAWING SILENCE. When PROJECT DETAILS supplies a municipality-confirmed district, use type, existing-suite record, application number, or other City record, carry it into the submission check and findings. Do not downgrade a confirmed City record to "not shown on plans." Clearly distinguish municipal-record evidence from drawing evidence in the `provided` wording.
|
| 476 |
+
- LEGAL-PARCEL GATE FOR PARKING. A rectangle or note labelled "parking" is not a compliant designated stall unless the drawings show it wholly inside the legal parcel, dimensioned, accessible, and assigned to the applicable dwelling unit. A stall drawn in a road, cul-de-sac, lane, boulevard, right-of-way, or outside the property boundary is not provided. Assess the Single Detached Dwelling, Backyard Suite, and any confirmed Secondary Suite separately.
|
| 477 |
+
- PROPERTY-LINE CLASSIFICATION BEFORE SETBACKS. Identify the front, rear, and side property lines from the legal parcel, street frontage, lane access, north arrow, and site context before selecting a setback dimension. Do not use the nearest convenient dimension. State the exact drawing dimension and boundary used; mark the finding [VERIFY] with low confidence if orientation is ambiguous.
|
| 478 |
+
- CLOSEST-FAÇADE MEASUREMENT. For Backyard Suite separation, use the closest applicable façade-to-façade distance, including relevant projections where the governing provision requires them. Do not substitute a centreline, eave-to-lot-line, or non-governing parallel dimension.
|
| 479 |
+
- DEVELOPMENT-PERMIT SCOPE. The City-style Bylaw Discrepancies and Prior to Decision sections contain land-use/planning items only. Safety-codes and engineer-of-record observations belong in the separate technical screening report and must not be presented as City development-permit conditions.
|
| 480 |
+
- CALCULATION TRACEABILITY. For every measurable discrepancy, identify Required, Provided, Delta = Provided - Required, units, and the drawing source. Preserve the City's concise `provided` sentence, but place uncertain or unreconciled arithmetic in `information_gaps`; never reproduce a numeric delta that does not reconcile.
|
| 481 |
- AVAILABLE TRACKS: {available_tracks}
|
| 482 |
- 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.
|
| 483 |
- 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.
|
|
|
|
| 510 |
"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>",
|
| 511 |
"matches_declared_scope": true, "suggested_track": "", "note": "..."}},
|
| 512 |
"sheet_manifest": [{{"page": 1, "sheet_id": "A1", "title": "..."}}],
|
| 513 |
+
"mandatory_checks": [
|
| 514 |
+
{{"check_id": "parcel_coverage|parking_main|parking_backyard|parking_secondary|rear_setback|separation|amenity_space|suite_density",
|
| 515 |
+
"regulation": "...", "status": "pass|fail|unknown",
|
| 516 |
+
"standard": "...", "provided": "...", "source_page": 1}}
|
| 517 |
+
],
|
| 518 |
"findings": [
|
| 519 |
{{"id": "F-01", "page": 2, "sheet_id": "A2", "discipline": "arch|site|structural|mep|planning",
|
| 520 |
"category": "safety-codes|land-use|reviewer-item",
|
|
|
|
| 528 |
"transition_note": "...", "information_gaps": ["..."]}}
|
| 529 |
}}"""
|
| 530 |
|
| 531 |
+
|
| 532 |
+
CALGARY_BACKYARD_MANDATORY_CHECKS = {
|
| 533 |
+
"parcel_coverage": {
|
| 534 |
+
"regulation": "412 Parcel Coverage",
|
| 535 |
+
"standard": ("(2) Unless otherwise referenced in subsection (3), the max. parcel "
|
| 536 |
+
"coverage is 50.0% of the area of a parcel where: (a) the area of a "
|
| 537 |
+
"parcel is equal to or less than 300.0m²; and (b) the parcel width is "
|
| 538 |
+
"less than 10.0m"),
|
| 539 |
+
"provided": "Plans do not provide enough information to verify parcel coverage.",
|
| 540 |
+
},
|
| 541 |
+
"parking_main": {
|
| 542 |
+
"regulation": "305, 423 Parking",
|
| 543 |
+
"standard": ("(c) requires a minimum of 1.0 motor vehicle parking stalls per "
|
| 544 |
+
"Dwelling Unit (which includes secondary suites if applicable) "
|
| 545 |
+
"(except as per below)"),
|
| 546 |
+
"provided": ("Plans do not demonstrate a designated compliant motor vehicle "
|
| 547 |
+
"parking stall for the Single Detached Dwelling."),
|
| 548 |
+
},
|
| 549 |
+
"parking_backyard": {
|
| 550 |
+
"regulation": "153.1 Backyard Suite",
|
| 551 |
+
"standard": "(c) requires a minimum of 1.0 motor vehicle parking stall.",
|
| 552 |
+
"provided": ("Plans do not demonstrate a designated compliant motor vehicle "
|
| 553 |
+
"parking stall for the Backyard Suite."),
|
| 554 |
+
},
|
| 555 |
+
"rear_setback": {
|
| 556 |
+
"regulation": "352 Backyard Suite",
|
| 557 |
+
"standard": ("(1) For a Backyard Suite, the minimum building setback from a rear "
|
| 558 |
+
"property line is: (a) 1.5m for any portion of the building used as "
|
| 559 |
+
"a Backyard Suite; and"),
|
| 560 |
+
"provided": "Plans do not provide a verified rear-property-line setback dimension.",
|
| 561 |
+
},
|
| 562 |
+
"separation": {
|
| 563 |
+
"regulation": "352 Backyard Suite",
|
| 564 |
+
"standard": ("(3) Unless otherwise referenced in subsections (3.1) and (3.2), a "
|
| 565 |
+
"minimum separation of 5.0m is required between the closest facade "
|
| 566 |
+
"of the main residential building to the closest façade of a "
|
| 567 |
+
"Backyard Suite."),
|
| 568 |
+
"provided": "Plans do not provide a verified closest-façade separation dimension.",
|
| 569 |
+
},
|
| 570 |
+
"amenity_space": {
|
| 571 |
+
"regulation": "352 Backyard Suite",
|
| 572 |
+
"standard": ("(7) A Backyard Suite must have a private amenity space that: (a) is "
|
| 573 |
+
"located outdoors; and (b) has a minimum area of 7.5m² with no "
|
| 574 |
+
"dimension less than 1.5m."),
|
| 575 |
+
"provided": ("Plans do not indicate a designated private amenity space for the "
|
| 576 |
+
"Backyard Suite."),
|
| 577 |
+
},
|
| 578 |
+
"suite_density": {
|
| 579 |
+
"regulation": "354 Accessory Suite - Density",
|
| 580 |
+
"standard": ("(2) Unless otherwise referenced in subsection (4), a Secondary "
|
| 581 |
+
"Suite and a Backyard Suite must not be located on the same parcel."),
|
| 582 |
+
"provided": ("The submitted information does not confirm whether a Secondary "
|
| 583 |
+
"Suite exists on the same parcel as the proposed Backyard Suite."),
|
| 584 |
+
},
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
DEFAULT_CHAT_SYSTEM_TMPL = """You are a {jur_persona} ({jur_safety} + {jur_landuse}).
|
| 588 |
|
| 589 |
HOW TO REASON (follow this every time):
|
|
|
|
| 767 |
return out, f"router fallback ({exc})"
|
| 768 |
|
| 769 |
|
| 770 |
+
def _reconcile_calgary_backyard_checks(result: dict, jur: Jurisdiction,
|
| 771 |
+
track: dict) -> None:
|
| 772 |
+
"""Ensure a Calgary backyard-suite run accounts for every City DR check.
|
| 773 |
+
|
| 774 |
+
A passing check belongs in ``mandatory_checks`` and does not become a
|
| 775 |
+
discrepancy. A missing check becomes a low-confidence clarification rather
|
| 776 |
+
than being silently omitted or guessed.
|
| 777 |
+
"""
|
| 778 |
+
if str(getattr(jur, "name", "")).lower() != "calgary":
|
| 779 |
+
return
|
| 780 |
+
sc = result.get("submission_check", {}) or {}
|
| 781 |
+
project_text = " ".join([
|
| 782 |
+
str(sc.get("detected_project", "")),
|
| 783 |
+
str(sc.get("note", "")),
|
| 784 |
+
str(track.get("label", "")),
|
| 785 |
+
]).lower()
|
| 786 |
+
if "backyard suite" not in project_text:
|
| 787 |
+
return
|
| 788 |
+
|
| 789 |
+
covered = {
|
| 790 |
+
str(c.get("check_id", "")).strip()
|
| 791 |
+
for c in (result.get("mandatory_checks") or [])
|
| 792 |
+
if str(c.get("status", "")).lower() in {"pass", "fail", "unknown"}
|
| 793 |
+
}
|
| 794 |
+
for f in result.get("findings") or []:
|
| 795 |
+
blob = " ".join([
|
| 796 |
+
str(f.get("regulation", "")), str(f.get("standard", "")),
|
| 797 |
+
str(f.get("provided", "")), str(f.get("description", "")),
|
| 798 |
+
]).lower()
|
| 799 |
+
if "412" in blob or "parcel coverage" in blob:
|
| 800 |
+
covered.add("parcel_coverage")
|
| 801 |
+
if "305" in blob or "423" in blob or "single detached" in blob:
|
| 802 |
+
covered.add("parking_main")
|
| 803 |
+
if "153.1" in blob or ("parking" in blob and "backyard suite" in blob):
|
| 804 |
+
covered.add("parking_backyard")
|
| 805 |
+
if "rear setback" in blob or ("rear property line" in blob and "1.5" in blob):
|
| 806 |
+
covered.add("rear_setback")
|
| 807 |
+
if "separation" in blob or "closest façade" in blob or "closest facade" in blob:
|
| 808 |
+
covered.add("separation")
|
| 809 |
+
if "amenity" in blob:
|
| 810 |
+
covered.add("amenity_space")
|
| 811 |
+
if "354" in blob or ("secondary suite" in blob and "same parcel" in blob):
|
| 812 |
+
covered.add("suite_density")
|
| 813 |
+
|
| 814 |
+
findings = result.setdefault("findings", [])
|
| 815 |
+
checks = result.setdefault("mandatory_checks", [])
|
| 816 |
+
next_num = len(findings) + 1
|
| 817 |
+
for check_id, spec in CALGARY_BACKYARD_MANDATORY_CHECKS.items():
|
| 818 |
+
if check_id in covered:
|
| 819 |
+
continue
|
| 820 |
+
checks.append({
|
| 821 |
+
"check_id": check_id,
|
| 822 |
+
"regulation": spec["regulation"],
|
| 823 |
+
"status": "unknown",
|
| 824 |
+
"standard": spec["standard"],
|
| 825 |
+
"provided": spec["provided"],
|
| 826 |
+
"source_page": None,
|
| 827 |
+
})
|
| 828 |
+
findings.append({
|
| 829 |
+
"id": f"F-AUTO-{next_num:02d}",
|
| 830 |
+
"page": None,
|
| 831 |
+
"sheet_id": "",
|
| 832 |
+
"discipline": "planning",
|
| 833 |
+
"category": "land-use",
|
| 834 |
+
"description": "[VERIFY] " + spec["provided"],
|
| 835 |
+
"citation": "Land Use Bylaw 1P2007",
|
| 836 |
+
"severity": "clarify",
|
| 837 |
+
"confidence": "low",
|
| 838 |
+
"regulation": spec["regulation"],
|
| 839 |
+
"standard": spec["standard"],
|
| 840 |
+
"provided": spec["provided"],
|
| 841 |
+
"auto_added": True,
|
| 842 |
+
})
|
| 843 |
+
next_num += 1
|
| 844 |
+
|
| 845 |
+
|
| 846 |
def run_review(provider: str, model: str, api_key: str, skills: list[Skill],
|
| 847 |
selected: list[Source], project_desc: str, images_b64: list[str],
|
| 848 |
page_texts: list[str], system_extra: str = "",
|
|
|
|
| 873 |
images_b64=images_b64 if vision else None, max_tokens=8000,
|
| 874 |
base_url=base_url)
|
| 875 |
result = _parse_json(raw)
|
| 876 |
+
_reconcile_calgary_backyard_checks(result, jur, track)
|
| 877 |
|
| 878 |
# Deterministic reconciliation: the verdict string must not contradict the
|
| 879 |
# scope decision or the findings. The model sometimes keeps an "out of scope"
|
|
|
|
| 1332 |
total_pages: int, provider: str, model: str,
|
| 1333 |
jur: Jurisdiction | None = None,
|
| 1334 |
date_warning: str = "", site_address: str = "",
|
| 1335 |
+
applicant: str = "", permit_number: str = "",
|
| 1336 |
+
community: str = "", land_use_district: str = "",
|
| 1337 |
+
use_type: str = "", planning_contact: str = "",
|
| 1338 |
+
date_dr_sent: str = "", response_due_date: str = "") -> str:
|
| 1339 |
"""Render the review in a municipal Detailed-Review letter format."""
|
| 1340 |
jur = jur or _DEFAULT_JUR
|
| 1341 |
sc = result.get("submission_check", {}) or {}
|
|
|
|
| 1347 |
reviewer = [f for f in findings if f.get("category") == "reviewer-item"]
|
| 1348 |
must_fix = [f for f in findings if f.get("severity") == "must-fix"]
|
| 1349 |
|
| 1350 |
+
district = land_use_district or sc.get("land_use_district") or \
|
| 1351 |
+
sc.get("detected_use_or_district") \
|
| 1352 |
or "not shown on the submitted plans"
|
| 1353 |
+
resolved_use_type = use_type or sc.get("use_type") or \
|
| 1354 |
+
"not determinable without the district"
|
| 1355 |
|
| 1356 |
+
L = ["# Detailed Review 1 – Development Permit", ""]
|
| 1357 |
if date_warning:
|
| 1358 |
L += [f"> {date_warning}", ""]
|
| 1359 |
L += [
|
|
|
|
| 1362 |
f"| **Application Number** | {permit_number or '—'} |",
|
| 1363 |
f"| **Application Description** | {sc.get('detected_project', '—')} |",
|
| 1364 |
f"| **Land Use District** | {district} |",
|
| 1365 |
+
f"| **Use Type** | {resolved_use_type} |",
|
| 1366 |
f"| **Site Address** | {site_address or '—'} |",
|
| 1367 |
+
f"| **Community** | {community or '—'} |",
|
| 1368 |
f"| **Applicant** | {applicant or '—'} |",
|
| 1369 |
+
f"| **Date DR Sent** | {date_dr_sent or '—'} |",
|
| 1370 |
+
f"| **Response Due Date** | {response_due_date or '—'} |",
|
| 1371 |
+
f"| **Planning** | {planning_contact or '—'} |",
|
| 1372 |
"", "## General Comments", "",
|
| 1373 |
]
|
| 1374 |
|
|
|
|
| 1386 |
if themes:
|
| 1387 |
L += ["The submission does not meet the minimum requirements for the following "
|
| 1388 |
"regulations: " + ", ".join(themes) + ".", ""]
|
| 1389 |
+
L += ["Please review the following bylaw discrepancies associated with the "
|
| 1390 |
+
"application and respond accordingly.", ""]
|
| 1391 |
if sc.get("note"):
|
| 1392 |
L += [sc["note"], ""]
|
| 1393 |
if s.get("transition_note"):
|
|
|
|
| 1445 |
f"district-dependent conclusion in this review is unconfirmed until this is "
|
| 1446 |
f"provided."]
|
| 1447 |
n += 1
|
| 1448 |
+
for f in [x for x in must_fix if x.get("category") == "land-use"]:
|
| 1449 |
reg = f.get("regulation") or f.get("citation") or ""
|
| 1450 |
tag = f" ({reg})" if reg else ""
|
| 1451 |
L += [f"{n}. {f.get('description', '').strip()}{tag}"]
|
| 1452 |
n += 1
|
| 1453 |
+
L += [
|
| 1454 |
+
"",
|
| 1455 |
+
f"{n}. Indicate, in writing, the manner in which the application will proceed:",
|
| 1456 |
+
"",
|
| 1457 |
+
" **(A)** Request cancellation of the application. Any applicable fee refund "
|
| 1458 |
+
"will be determined at the time of cancellation.",
|
| 1459 |
+
"",
|
| 1460 |
+
" **(B)** Pursue the application as proposed, with a decision by the Development "
|
| 1461 |
+
"Authority based on the discrepancies listed above.",
|
| 1462 |
+
"",
|
| 1463 |
+
" **(C)** Amend the application to address all Prior to Decision requirements "
|
| 1464 |
+
"of this Detailed Review.",
|
| 1465 |
+
"",
|
| 1466 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1467 |
|
| 1468 |
gaps = s.get("information_gaps") or []
|
| 1469 |
+
L += ["## Advisory Comments", "",
|
| 1470 |
+
"The following advisory comments are provided as a courtesy to the Applicant "
|
| 1471 |
+
"and registered property owner. They represent some, but not all, of the "
|
| 1472 |
+
"requirements that may apply:", ""]
|
| 1473 |
if gaps:
|
|
|
|
|
|
|
|
|
|
| 1474 |
L += [f"- {g}" for g in gaps]
|
| 1475 |
+
if safety or reviewer:
|
| 1476 |
+
L += [
|
| 1477 |
+
f"- A separate technical screening identified {len(safety)} safety-code "
|
| 1478 |
+
f"item(s) and {len(reviewer)} reviewer/engineer item(s). These are not "
|
| 1479 |
+
"Development Permit conditions and should be reviewed under the applicable "
|
| 1480 |
+
"building-permit and professional-design streams."
|
| 1481 |
+
]
|
| 1482 |
+
if not gaps and not safety and not reviewer:
|
| 1483 |
+
L += ["- Advisory comments will be finalized at the time of the Development "
|
| 1484 |
+
"Authority decision."]
|
| 1485 |
+
L += [
|
| 1486 |
+
"- The Applicant may appeal a decision of the Development Authority, including "
|
| 1487 |
+
"conditions of approval, in accordance with the appeal information provided with "
|
| 1488 |
+
"the decision.",
|
| 1489 |
+
"",
|
| 1490 |
+
"---",
|
| 1491 |
+
f"*Draft City-style screening output generated {date.today().isoformat()} using "
|
| 1492 |
+
f"{provider} / {model}; pages reviewed {pages_reviewed}/{total_pages}. "
|
| 1493 |
+
f"{jur.report_footer}*",
|
| 1494 |
+
]
|
| 1495 |
|
|
|
|
| 1496 |
return "\n".join(L)
|
| 1497 |
|
| 1498 |
|
mcp_server.py
CHANGED
|
@@ -21,12 +21,8 @@ just the provider name. The MCP client's own model does NOT perform the review
|
|
| 21 |
tools call the configured provider so the deterministic engine logic (scope gate,
|
| 22 |
grounding, reconciliation) always runs.
|
| 23 |
|
| 24 |
-
DNS-rebinding note:
|
| 25 |
-
|
| 26 |
-
rejects every request to /mcp with 421 Misdirected Request. Setting
|
| 27 |
-
`mcp.settings.host` after construction is too late — the security settings are
|
| 28 |
-
frozen in __init__. Both host and transport_security are therefore passed to the
|
| 29 |
-
constructor below.
|
| 30 |
"""
|
| 31 |
|
| 32 |
from __future__ import annotations
|
|
@@ -36,7 +32,6 @@ import os
|
|
| 36 |
from typing import Optional
|
| 37 |
|
| 38 |
from mcp.server.fastmcp import FastMCP
|
| 39 |
-
from mcp.server.transport_security import TransportSecuritySettings
|
| 40 |
|
| 41 |
import engine as eng
|
| 42 |
from jurisdiction import list_jurisdictions, load_jurisdiction_file
|
|
@@ -44,44 +39,25 @@ from providers import PROVIDERS
|
|
| 44 |
|
| 45 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 46 |
|
| 47 |
-
mcp = FastMCP(
|
| 48 |
-
"crossbeam-plan-review",
|
| 49 |
-
stateless_http=True,
|
| 50 |
-
json_response=True,
|
| 51 |
-
host="0.0.0.0",
|
| 52 |
-
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
| 53 |
-
)
|
| 54 |
|
| 55 |
|
| 56 |
# ── helpers ────────────────────────────────────────────────────────────────
|
| 57 |
-
def _real_slugs() -> list:
|
| 58 |
-
"""Configured municipality slugs, excluding templates and the root default."""
|
| 59 |
-
choices = list_jurisdictions(BASE_DIR)
|
| 60 |
-
return [s for s in choices if not s.startswith("_") and s != "default"]
|
| 61 |
-
|
| 62 |
-
|
| 63 |
def _slug(slug: str = "") -> str:
|
| 64 |
-
"""Resolve the active municipality slug.
|
| 65 |
-
|
| 66 |
-
An unknown slug raises instead of silently falling back to the first configured
|
| 67 |
-
municipality. Silently substituting Calgary for an unrecognised city would hand
|
| 68 |
-
the agent a confidently-cited but wrong bylaw, which is worse than no answer.
|
| 69 |
-
"""
|
| 70 |
choices = list_jurisdictions(BASE_DIR)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
return slug
|
| 75 |
-
raise ValueError(
|
| 76 |
-
f"Unknown municipality '{slug}'. Configured: {', '.join(real) or 'none'}. "
|
| 77 |
-
"Call list_municipalities() for the valid slugs.")
|
| 78 |
return real[0] if real else ""
|
| 79 |
|
| 80 |
|
| 81 |
def _jur(slug: str = ""):
|
| 82 |
choices = list_jurisdictions(BASE_DIR)
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
def _resolve_key(provider: str, api_key: str) -> str:
|
|
@@ -295,16 +271,8 @@ def get_corrections_kit(municipality: str = "") -> dict:
|
|
| 295 |
correction has been resolved. Every resolution belongs to the applicant and is
|
| 296 |
represented by an [APPLICANT: ...] placeholder."""
|
| 297 |
jur = _jur(municipality)
|
| 298 |
-
|
| 299 |
-
# skills, so a Toronto request was framed as Toronto but grounded in Calgary
|
| 300 |
-
# clauses — grounded, provenance-tagged, and wrong.
|
| 301 |
-
skills, _ = eng.load_knowledge(BASE_DIR, jurisdiction=_slug(municipality))
|
| 302 |
sk = [x for t in eng.discover_tracks(skills).values() for x in t["skills"]]
|
| 303 |
-
if not sk:
|
| 304 |
-
return {"error": f"No review knowledge is configured for '{jur.place}' yet.",
|
| 305 |
-
"municipality": jur.place,
|
| 306 |
-
"how_to_fix": ("Add skill folders under skills/ whose SKILL.md frontmatter "
|
| 307 |
-
f"declares `jurisdiction: {_slug(municipality)}`.")}
|
| 308 |
knowledge = "\n\n".join(
|
| 309 |
[f"=== {x.name}/SKILL.md ===\n{x.skill_md}" for x in sk] +
|
| 310 |
[f"=== {s.key} ===\n{s.content}" for x in sk for s in x.sources])
|
|
@@ -337,6 +305,16 @@ def review_plan_set(
|
|
| 337 |
project_description: str = "",
|
| 338 |
max_pages: int = 15,
|
| 339 |
base_url: str = "",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
) -> dict:
|
| 341 |
"""Review a plan set (PDF, base64-encoded) against a municipality's rules and produce
|
| 342 |
grounded correction findings. This is the city-side pre-screen. Returns the structured
|
|
@@ -355,7 +333,15 @@ def review_plan_set(
|
|
| 355 |
trans = f" (assess against {jur.transition_label})" if jur.transition_label else ""
|
| 356 |
desc = (f"Municipality: {jur.place}\nReview type: {tr['label']}\n"
|
| 357 |
f"Intended application date: {application_date or 'not stated'}{trans}\n"
|
| 358 |
-
f"Description: {project_description or 'not provided'}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
selected, routed = eng.route(provider, model, key, skills, loose, desc,
|
| 360 |
base_url=base_url, jur=jur)
|
| 361 |
result = eng.run_review(provider, model, key, skills, selected, desc, b64s, texts,
|
|
@@ -366,7 +352,15 @@ def review_plan_set(
|
|
| 366 |
provider, model, jur=jur)
|
| 367 |
date_warning = eng.check_application_date(application_date, texts)
|
| 368 |
city_letter = eng.render_city_letter(result, desc, len(b64s), total, provider, model,
|
| 369 |
-
jur=jur, date_warning=date_warning
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
return {"result": result, "report_markdown": report,
|
| 371 |
"city_review_letter_markdown": city_letter,
|
| 372 |
"date_warning": date_warning,
|
|
@@ -525,18 +519,10 @@ secret (<code>OPENAI_API_KEY</code>, <code>ANTHROPIC_API_KEY</code>,
|
|
| 525 |
|
| 526 |
if __name__ == "__main__":
|
| 527 |
if os.environ.get("MCP_HTTP") == "1":
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
CORSMiddleware,
|
| 534 |
-
allow_origins=["*"],
|
| 535 |
-
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
| 536 |
-
allow_headers=["*"],
|
| 537 |
-
expose_headers=["Mcp-Session-Id"],
|
| 538 |
-
)
|
| 539 |
-
uvicorn.run(app, host="0.0.0.0",
|
| 540 |
-
port=int(os.environ.get("MCP_PORT", "7860")))
|
| 541 |
else:
|
| 542 |
-
mcp.run()
|
|
|
|
| 21 |
tools call the configured provider so the deterministic engine logic (scope gate,
|
| 22 |
grounding, reconciliation) always runs.
|
| 23 |
|
| 24 |
+
DNS-rebinding note: on HF Spaces behind the proxy, FastMCP must run with
|
| 25 |
+
`stateless_http=True` and rebinding protection disabled (handled below).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
"""
|
| 27 |
|
| 28 |
from __future__ import annotations
|
|
|
|
| 32 |
from typing import Optional
|
| 33 |
|
| 34 |
from mcp.server.fastmcp import FastMCP
|
|
|
|
| 35 |
|
| 36 |
import engine as eng
|
| 37 |
from jurisdiction import list_jurisdictions, load_jurisdiction_file
|
|
|
|
| 39 |
|
| 40 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 41 |
|
| 42 |
+
mcp = FastMCP("crossbeam-plan-review", stateless_http=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def _slug(slug: str = "") -> str:
|
| 47 |
+
"""Resolve the active municipality slug (falls back to the first configured)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
choices = list_jurisdictions(BASE_DIR)
|
| 49 |
+
if slug and slug in choices:
|
| 50 |
+
return slug
|
| 51 |
+
real = [s for s in choices if not s.startswith("_") and s != "default"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return real[0] if real else ""
|
| 53 |
|
| 54 |
|
| 55 |
def _jur(slug: str = ""):
|
| 56 |
choices = list_jurisdictions(BASE_DIR)
|
| 57 |
+
if slug and slug in choices:
|
| 58 |
+
return load_jurisdiction_file(choices[slug])
|
| 59 |
+
real = [s for s in choices if not s.startswith("_") and s != "default"]
|
| 60 |
+
return load_jurisdiction_file(choices[real[0]] if real else "")
|
| 61 |
|
| 62 |
|
| 63 |
def _resolve_key(provider: str, api_key: str) -> str:
|
|
|
|
| 271 |
correction has been resolved. Every resolution belongs to the applicant and is
|
| 272 |
represented by an [APPLICANT: ...] placeholder."""
|
| 273 |
jur = _jur(municipality)
|
| 274 |
+
skills, _ = eng.load_knowledge(BASE_DIR)
|
|
|
|
|
|
|
|
|
|
| 275 |
sk = [x for t in eng.discover_tracks(skills).values() for x in t["skills"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
knowledge = "\n\n".join(
|
| 277 |
[f"=== {x.name}/SKILL.md ===\n{x.skill_md}" for x in sk] +
|
| 278 |
[f"=== {s.key} ===\n{s.content}" for x in sk for s in x.sources])
|
|
|
|
| 305 |
project_description: str = "",
|
| 306 |
max_pages: int = 15,
|
| 307 |
base_url: str = "",
|
| 308 |
+
application_number: str = "",
|
| 309 |
+
site_address: str = "",
|
| 310 |
+
applicant: str = "",
|
| 311 |
+
community: str = "",
|
| 312 |
+
land_use_district: str = "",
|
| 313 |
+
use_type: str = "",
|
| 314 |
+
planning_contact: str = "",
|
| 315 |
+
date_dr_sent: str = "",
|
| 316 |
+
response_due_date: str = "",
|
| 317 |
+
municipal_records_context: str = "",
|
| 318 |
) -> dict:
|
| 319 |
"""Review a plan set (PDF, base64-encoded) against a municipality's rules and produce
|
| 320 |
grounded correction findings. This is the city-side pre-screen. Returns the structured
|
|
|
|
| 333 |
trans = f" (assess against {jur.transition_label})" if jur.transition_label else ""
|
| 334 |
desc = (f"Municipality: {jur.place}\nReview type: {tr['label']}\n"
|
| 335 |
f"Intended application date: {application_date or 'not stated'}{trans}\n"
|
| 336 |
+
f"Description: {project_description or 'not provided'}\n"
|
| 337 |
+
f"Application number: {application_number or 'not provided'}\n"
|
| 338 |
+
f"Site address: {site_address or 'not provided'}\n"
|
| 339 |
+
f"Applicant: {applicant or 'not provided'}\n"
|
| 340 |
+
f"Community: {community or 'not provided'}\n"
|
| 341 |
+
f"Land Use District supplied by municipality: "
|
| 342 |
+
f"{land_use_district or 'not provided'}\n"
|
| 343 |
+
f"Use Type supplied by municipality: {use_type or 'not provided'}\n"
|
| 344 |
+
f"Municipal records context: {municipal_records_context or 'not provided'}")
|
| 345 |
selected, routed = eng.route(provider, model, key, skills, loose, desc,
|
| 346 |
base_url=base_url, jur=jur)
|
| 347 |
result = eng.run_review(provider, model, key, skills, selected, desc, b64s, texts,
|
|
|
|
| 352 |
provider, model, jur=jur)
|
| 353 |
date_warning = eng.check_application_date(application_date, texts)
|
| 354 |
city_letter = eng.render_city_letter(result, desc, len(b64s), total, provider, model,
|
| 355 |
+
jur=jur, date_warning=date_warning,
|
| 356 |
+
site_address=site_address, applicant=applicant,
|
| 357 |
+
permit_number=application_number,
|
| 358 |
+
community=community,
|
| 359 |
+
land_use_district=land_use_district,
|
| 360 |
+
use_type=use_type,
|
| 361 |
+
planning_contact=planning_contact,
|
| 362 |
+
date_dr_sent=date_dr_sent,
|
| 363 |
+
response_due_date=response_due_date)
|
| 364 |
return {"result": result, "report_markdown": report,
|
| 365 |
"city_review_letter_markdown": city_letter,
|
| 366 |
"date_warning": date_warning,
|
|
|
|
| 519 |
|
| 520 |
if __name__ == "__main__":
|
| 521 |
if os.environ.get("MCP_HTTP") == "1":
|
| 522 |
+
# HTTP/SSE transport for HF Spaces / n8n / tunnels.
|
| 523 |
+
port = int(os.environ.get("MCP_PORT", "7860"))
|
| 524 |
+
mcp.settings.host = "0.0.0.0"
|
| 525 |
+
mcp.settings.port = port
|
| 526 |
+
mcp.run(transport="streamable-http")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
else:
|
| 528 |
+
mcp.run() # STDIO for Claude Desktop / local clients
|
requirements.txt
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
mcp>=1.
|
| 2 |
PyMuPDF>=1.24.0
|
| 3 |
requests>=2.31
|
| 4 |
ezdxf>=1.3
|
| 5 |
matplotlib>=3.7
|
| 6 |
PyYAML>=6.0
|
| 7 |
uvicorn>=0.30
|
| 8 |
-
starlette>=0.37
|
|
|
|
| 1 |
+
mcp>=1.2
|
| 2 |
PyMuPDF>=1.24.0
|
| 3 |
requests>=2.31
|
| 4 |
ezdxf>=1.3
|
| 5 |
matplotlib>=3.7
|
| 6 |
PyYAML>=6.0
|
| 7 |
uvicorn>=0.30
|
|
|
smoke_test.py
CHANGED
|
@@ -47,9 +47,37 @@ eng.llm_call = lambda *a, **k: json.dumps({"submission_check": {"detected_projec
|
|
| 47 |
"summary": {"verdict": "revisions required", "must_fix": 1, "clarify": 0, "advisory": 0}})
|
| 48 |
slug = [m["slug"] for m in mun["municipalities"]][0]
|
| 49 |
r = mcpsrv.review_plan_set(pdf_base64=base64.b64encode(pdf("A1 suite")).decode(),
|
| 50 |
-
api_key="k", municipality=slug
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
ok(r.get("result", {}).get("summary", {}).get("must_fix") == 1, "review returns findings via MCP")
|
| 52 |
ok("report_markdown" in r and len(r["report_markdown"]) > 50, "report rendered via MCP")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
eng.llm_call = lambda *a, **k: json.dumps({"letter_meta": {"permit_number": "X"},
|
| 55 |
"corrections": [{"id": "C-01", "city_item": "i", "category": "land-use", "governing_rule": "s.1",
|
|
|
|
| 47 |
"summary": {"verdict": "revisions required", "must_fix": 1, "clarify": 0, "advisory": 0}})
|
| 48 |
slug = [m["slug"] for m in mun["municipalities"]][0]
|
| 49 |
r = mcpsrv.review_plan_set(pdf_base64=base64.b64encode(pdf("A1 suite")).decode(),
|
| 50 |
+
api_key="k", municipality=slug,
|
| 51 |
+
application_number="DP-TEST",
|
| 52 |
+
site_address="1 TEST WY NE",
|
| 53 |
+
applicant="TEST APPLICANT",
|
| 54 |
+
community="TEST COMMUNITY",
|
| 55 |
+
land_use_district="Residential - Test District",
|
| 56 |
+
use_type="Discretionary",
|
| 57 |
+
planning_contact="TEST REVIEWER")
|
| 58 |
ok(r.get("result", {}).get("summary", {}).get("must_fix") == 1, "review returns findings via MCP")
|
| 59 |
ok("report_markdown" in r and len(r["report_markdown"]) > 50, "report rendered via MCP")
|
| 60 |
+
city = r.get("city_review_letter_markdown", "")
|
| 61 |
+
ok("# Detailed Review 1 – Development Permit" in city, "City DR title rendered")
|
| 62 |
+
ok("DP-TEST" in city and "TEST COMMUNITY" in city and "Discretionary" in city,
|
| 63 |
+
"municipal header metadata rendered")
|
| 64 |
+
ok("## Bylaw Discrepancies" in city and "## Prior to Decision Requirements" in city,
|
| 65 |
+
"City DR core sections rendered")
|
| 66 |
+
ok("## Building/Safety Codes" not in city,
|
| 67 |
+
"building-code stream excluded from City DP conditions")
|
| 68 |
+
ok("**(A)** Request cancellation" in city and "**(C)** Amend the application" in city,
|
| 69 |
+
"City response options rendered")
|
| 70 |
+
|
| 71 |
+
backyard = {"submission_check": {"detected_project": "New: Backyard Suite",
|
| 72 |
+
"matches_declared_scope": True},
|
| 73 |
+
"findings": [], "summary": {}}
|
| 74 |
+
eng._reconcile_calgary_backyard_checks(
|
| 75 |
+
backyard, load_jurisdiction_file("jurisdictions/calgary.yaml"),
|
| 76 |
+
{"label": "secondary/backyard suite"})
|
| 77 |
+
ok(len(backyard.get("mandatory_checks", [])) == 7,
|
| 78 |
+
"all mandatory Calgary backyard-suite checks reconciled")
|
| 79 |
+
ok(all(f.get("severity") == "clarify" for f in backyard.get("findings", [])),
|
| 80 |
+
"missing mandatory checks become clarifications, not invented violations")
|
| 81 |
|
| 82 |
eng.llm_call = lambda *a, **k: json.dumps({"letter_meta": {"permit_number": "X"},
|
| 83 |
"corrections": [{"id": "C-01", "city_item": "i", "category": "land-use", "governing_rule": "s.1",
|