razaali10 commited on
Commit
bf988fa
·
verified ·
1 Parent(s): ea71adb

Upload 13 files

Browse files
Files changed (6) hide show
  1. CITY_REVIEW_UPDATE.md +80 -0
  2. README.md +22 -1
  3. engine.py +199 -32
  4. mcp_server.py +46 -60
  5. requirements.txt +1 -2
  6. 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) → grounded correction findings + report |
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 = "") -> 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 = sc.get("land_use_district") or sc.get("detected_use_or_district") \
 
1204
  or "not shown on the submitted plans"
1205
- use_type = sc.get("use_type") or "not determinable without the district"
 
1206
 
1207
- L = [f"# Detailed Review {jur.place}", ""]
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** | {use_type} |",
1217
  f"| **Site Address** | {site_address or '—'} |",
 
1218
  f"| **Applicant** | {applicant or '—'} |",
1219
- f"| **Review generated** | {date.today().isoformat()} · {provider} / {model} · "
1220
- f"pages {pages_reviewed}/{total_pages} |",
 
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 += [f"Please review the bylaw discrepancies below. {verdict}", ""]
 
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.append("")
1302
-
1303
- # ── Safety-codes / building-permit stream ──
1304
- if safety:
1305
- L += ["## Building/Safety Codes separate permit stream", "",
1306
- f"The following {len(safety)} item(s) fall under the "
1307
- f"{jur.safety_framework} and are reviewed at building permit stage, not as "
1308
- "land-use discrepancies:", ""]
1309
- for f in safety:
1310
- L += [f"- **{f.get('citation', '—')}** — {f.get('description', '').strip()} "
1311
- f"_({f.get('severity', '')})_"]
1312
- L.append("")
1313
-
1314
- if reviewer:
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
- L.append("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: FastMCP auto-enables Host-header validation when it is
25
- constructed with the default host (127.0.0.1). Behind the HF Spaces proxy that
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
- real = _real_slugs()
72
- if slug:
73
- if slug in choices:
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
- resolved = _slug(slug) # raises on an unknown slug
84
- return load_jurisdiction_file(choices.get(resolved, ""))
 
 
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
- # Scope to the requested municipality. Loading unscoped pulled EVERY city's
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
- import uvicorn
529
- from starlette.middleware.cors import CORSMiddleware
530
-
531
- app = mcp.streamable_http_app()
532
- app.add_middleware(
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.25,<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
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",