razaali10 commited on
Commit
4efd9a2
Β·
verified Β·
1 Parent(s): 1c2f435

Upload 12 files

Browse files
Files changed (3) hide show
  1. ADDING_A_MUNICIPALITY.md +70 -0
  2. engine.py +19 -3
  3. mcp_server.py +151 -7
ADDING_A_MUNICIPALITY.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adding a municipality (worked example: City of Houston)
2
+
3
+ Two things are needed. Only the **second** one makes reviews actually work.
4
+
5
+ ## 1. Config β€” framing (5 minutes)
6
+
7
+ `jurisdictions/houston.yaml` is already included as a starter. It sets the city name,
8
+ region, the code/bylaw framework names findings must cite, and disables the dated
9
+ transition logic (Houston has no Calgary-style rezoning revert).
10
+
11
+ After this step the tool *speaks* Houston: report titles, personas, framework names.
12
+ It cannot yet cite a single Houston rule.
13
+
14
+ ## 2. Skills β€” the grounded rules (the real work)
15
+
16
+ Reviews are only as good as the distilled knowledge. Create folders under `skills/`
17
+ whose `SKILL.md` frontmatter declares the jurisdiction:
18
+
19
+ ```
20
+ skills/houston-development/SKILL.md
21
+ ---
22
+ name: houston-development
23
+ jurisdiction: houston # <-- REQUIRED, or it will load for every city
24
+ track: development
25
+ track_label: "Chapter 42 development review"
26
+ track_scope: "a Houston development application subject to Code of Ordinances Chapter 42"
27
+ description: "..."
28
+ ---
29
+ ```
30
+
31
+ `jurisdiction:` is what keeps cities apart. Skills tagged `calgary` never load for a
32
+ Houston review and vice-versa β€” verified by test. A skill with **no** `jurisdiction:`
33
+ loads for every municipality (use only for genuinely universal content).
34
+
35
+ ### Turning your Houston documents into skills
36
+
37
+ You said you have the documents. **Do not drop the raw PDFs into `knowledge/`.** That
38
+ was tested here with a 674-page code PDF and it made results *worse*: 38% of its pages
39
+ were image-only, the relevant clauses were scattered across 600 pages, and the router
40
+ spent its budget on chunks that didn't match. Reviews improved only after clauses were
41
+ distilled into structured tables.
42
+
43
+ For each rule you want the tool to enforce, add a row:
44
+
45
+ | Rule | Clause | As written |
46
+ |---|---|---|
47
+ | Minimum lot size, single-family | Ch. 42-181 | "..." |
48
+
49
+ Put those in `skills/houston-development/references/*.md`. The grounded-citation rule
50
+ then lets findings cite `Ch. 42-181` because that exact string is in the knowledge.
51
+ Anything not distilled will be reported as an information gap instead of a guess β€”
52
+ which is the correct behaviour, not a failure.
53
+
54
+ ### Sanity check
55
+
56
+ ```bash
57
+ python - <<'PY'
58
+ import engine as eng
59
+ print("houston skills:", [s.name for s in eng.load_knowledge(".", jurisdiction="houston")[0]])
60
+ print("calgary skills:", [s.name for s in eng.load_knowledge(".", jurisdiction="calgary")[0]])
61
+ PY
62
+ ```
63
+
64
+ Until Houston skills exist, `get_review_kit(municipality="houston")` returns an explicit
65
+ "no review skills configured yet" message rather than pretending it can review.
66
+
67
+ ## 3. Optional β€” remove Calgary
68
+
69
+ If this deployment is Houston-only, delete `skills/alberta-suites`,
70
+ `skills/calgary-*`, and `jurisdictions/calgary.yaml`. Nothing else references them.
engine.py CHANGED
@@ -60,6 +60,7 @@ class Skill:
60
  track: str = "suites"
61
  track_label: str = ""
62
  track_scope: str = ""
 
63
 
64
 
65
  def _read_text(path: Path) -> str:
@@ -153,9 +154,18 @@ def _frontmatter_desc(md: str) -> str:
153
  return ""
154
 
155
 
156
- def load_knowledge(base_dir, uploaded=None) -> tuple[list[Skill], list[Source]]:
157
- """uploaded: list of (filename, bytes) from Streamlit uploads."""
 
 
 
 
 
 
 
 
158
  base = Path(base_dir)
 
159
  skills: list[Skill] = []
160
  for skill_dir in sorted((base / "skills").glob("*")):
161
  smd = skill_dir / "SKILL.md"
@@ -167,10 +177,16 @@ def load_knowledge(base_dir, uploaded=None) -> tuple[list[Skill], list[Source]]:
167
  def _fm_key(key: str, default: str = "") -> str:
168
  m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm_text, flags=re.M)
169
  return m.group(1).strip() if m else default
 
 
 
 
 
170
  sk = Skill(skill_dir.name, md, _frontmatter_desc(md),
171
  track=_fm_key("track", "suites"),
172
  track_label=_fm_key("track_label"),
173
- track_scope=_fm_key("track_scope"))
 
174
  seen_keys: set[str] = set()
175
  for ref in sorted(skill_dir.rglob("*")):
176
  if not ref.is_file() or ref.name.lower() == "skill.md":
 
60
  track: str = "suites"
61
  track_label: str = ""
62
  track_scope: str = ""
63
+ jurisdiction: str = ""
64
 
65
 
66
  def _read_text(path: Path) -> str:
 
154
  return ""
155
 
156
 
157
+ def load_knowledge(base_dir, uploaded=None,
158
+ jurisdiction: str = "") -> tuple[list[Skill], list[Source]]:
159
+ """Load skills + drop-folder knowledge.
160
+
161
+ uploaded: list of (filename, bytes) from Streamlit uploads.
162
+ jurisdiction: when set, only load skills whose SKILL.md frontmatter declares a
163
+ matching `jurisdiction:` (comma-separated list allowed), plus skills that
164
+ declare none (treated as universal). This lets one deployment hold several
165
+ municipalities' knowledge without a Calgary review pulling in Houston rules.
166
+ """
167
  base = Path(base_dir)
168
+ want = jurisdiction.strip().lower()
169
  skills: list[Skill] = []
170
  for skill_dir in sorted((base / "skills").glob("*")):
171
  smd = skill_dir / "SKILL.md"
 
177
  def _fm_key(key: str, default: str = "") -> str:
178
  m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm_text, flags=re.M)
179
  return m.group(1).strip() if m else default
180
+ skill_jur = _fm_key("jurisdiction")
181
+ if want and skill_jur:
182
+ allowed = {j.strip().lower() for j in skill_jur.split(",") if j.strip()}
183
+ if want not in allowed:
184
+ continue # belongs to a different municipality
185
  sk = Skill(skill_dir.name, md, _frontmatter_desc(md),
186
  track=_fm_key("track", "suites"),
187
  track_label=_fm_key("track_label"),
188
+ track_scope=_fm_key("track_scope"),
189
+ jurisdiction=skill_jur)
190
  seen_keys: set[str] = set()
191
  for ref in sorted(skill_dir.rglob("*")):
192
  if not ref.is_file() or ref.name.lower() == "skill.md":
mcp_server.py CHANGED
@@ -43,6 +43,15 @@ mcp = FastMCP("crossbeam-plan-review", stateless_http=True)
43
 
44
 
45
  # ── helpers ────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
46
  def _jur(slug: str = ""):
47
  choices = list_jurisdictions(BASE_DIR)
48
  if slug and slug in choices:
@@ -58,9 +67,14 @@ def _resolve_key(provider: str, api_key: str) -> str:
58
  return os.environ.get(env, "") if env else ""
59
 
60
 
61
- def _load(track_id: str = ""):
62
- skills, loose = eng.load_knowledge(BASE_DIR)
 
63
  tracks = eng.discover_tracks(skills)
 
 
 
 
64
  if track_id and track_id in tracks:
65
  tr = tracks[track_id]
66
  else:
@@ -89,7 +103,7 @@ def list_municipalities() -> dict:
89
  def list_review_tracks(municipality: str = "") -> dict:
90
  """List available review tracks/types (e.g. suites, multi-residential, institutional)
91
  and the models available for the LLM-backed tools."""
92
- _, _, tracks, _ = _load()
93
  return {
94
  "tracks": [{"id": t, "label": v["label"], "scope": v["scope"]}
95
  for t, v in tracks.items()],
@@ -100,11 +114,132 @@ def list_review_tracks(municipality: str = "") -> dict:
100
  @mcp.tool()
101
  def list_knowledge(municipality: str = "") -> dict:
102
  """Show the knowledge base (skills + reference files) the reviews are grounded in."""
103
- skills, loose = eng.load_knowledge(BASE_DIR)
104
  return {"manifest": eng.knowledge_manifest(skills, loose)}
105
 
106
 
107
  # ── Flow 3: plan review ─────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  @mcp.tool()
109
  def review_plan_set(
110
  pdf_base64: str,
@@ -128,7 +263,7 @@ def review_plan_set(
128
  if not key:
129
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
130
  jur = _jur(municipality)
131
- skills_all, loose, tracks, tr = _load(review_track)
132
  skills = tr["skills"]
133
  data = base64.b64decode(pdf_base64)
134
  b64s, texts, total, dims = eng.file_to_pages("upload.pdf", data, max_pages=max_pages)
@@ -170,7 +305,7 @@ def analyze_corrections_letter(
170
  if not key:
171
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
172
  jur = _jur(municipality)
173
- skills, loose = eng.load_knowledge(BASE_DIR)
174
  sk = [x for t in eng.discover_tracks(skills).values() for x in t["skills"]]
175
  sources = [s for x in sk for s in x.sources]
176
  data = base64.b64decode(letter_base64)
@@ -204,7 +339,7 @@ def generate_checklist(
204
  if not key:
205
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
206
  jur = _jur(municipality)
207
- _, _, tracks, tr = _load(review_track)
208
  result = eng.run_checklist(provider, model, key, tr["skills"],
209
  {"label": tr["label"], "scope": tr["scope"]},
210
  project_desc=project_description, base_url=base_url, jur=jur)
@@ -224,7 +359,16 @@ async def _landing(request):
224
 
225
  choices = list_jurisdictions(BASE_DIR)
226
  muns = ", ".join(s for s in choices if not s.startswith("_") and s != "default") or "β€”"
 
 
 
227
  base = str(request.base_url).rstrip("/")
 
 
 
 
 
 
228
  rows = "".join(
229
  f"<tr><td><code>{n}</code></td><td>{d}</td></tr>" for n, d in [
230
  ("list_municipalities", "Configured jurisdictions + their frameworks"),
 
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:
 
67
  return os.environ.get(env, "") if env else ""
68
 
69
 
70
+ def _load(track_id: str = "", municipality: str = ""):
71
+ """Load knowledge scoped to a municipality so multiple cities can coexist."""
72
+ skills, loose = eng.load_knowledge(BASE_DIR, jurisdiction=_slug(municipality))
73
  tracks = eng.discover_tracks(skills)
74
+ if not tracks:
75
+ # A configured municipality with no skills yet (e.g. a freshly added city).
76
+ # Return an explicit empty track rather than crashing on next(iter({})).
77
+ return skills, loose, {}, {"label": "unconfigured", "scope": "", "skills": []}
78
  if track_id and track_id in tracks:
79
  tr = tracks[track_id]
80
  else:
 
103
  def list_review_tracks(municipality: str = "") -> dict:
104
  """List available review tracks/types (e.g. suites, multi-residential, institutional)
105
  and the models available for the LLM-backed tools."""
106
+ _, _, tracks, _ = _load("", municipality)
107
  return {
108
  "tracks": [{"id": t, "label": v["label"], "scope": v["scope"]}
109
  for t, v in tracks.items()],
 
114
  @mcp.tool()
115
  def list_knowledge(municipality: str = "") -> dict:
116
  """Show the knowledge base (skills + reference files) the reviews are grounded in."""
117
+ skills, loose = eng.load_knowledge(BASE_DIR, jurisdiction=_slug(municipality))
118
  return {"manifest": eng.knowledge_manifest(skills, loose)}
119
 
120
 
121
  # ── Flow 3: plan review ─────────────────────────────────────────────────────
122
+ # ═══════════════════════════════════════════════════════════════════════════
123
+ # CLIENT-SIDE TOOLS β€” no API key required.
124
+ #
125
+ # When the caller is itself an LLM (ChatGPT, Claude, Gemini, HuggingChat), it
126
+ # makes no sense for this server to call ANOTHER model: you would pay twice and
127
+ # wait twice. These tools instead hand the agent everything it needs β€” the
128
+ # grounded knowledge, the review rules, the output schema, and deterministic
129
+ # document extraction β€” and the agent's own model does the reasoning.
130
+ #
131
+ # Use the *_with_llm tools further below only for non-LLM callers (n8n, scripts,
132
+ # cron) that have no model of their own.
133
+ # ═══════════════════════════════════════════════════════════════════════════
134
+
135
+ @mcp.tool()
136
+ def extract_document(pdf_base64: str = "", max_pages: int = 15,
137
+ include_images: bool = False) -> dict:
138
+ """Extract text (and optionally page images) from a PDF or DXF. NO API KEY NEEDED.
139
+
140
+ Deterministic parsing via PyMuPDF/ezdxf β€” no LLM involved. Returns per-page text
141
+ so an agent can read a plan set or letter it cannot otherwise open. Set
142
+ include_images=true to also get base64 JPEGs of each page for vision models
143
+ (omitted by default because they are large)."""
144
+ if not pdf_base64:
145
+ return {"error": "Provide pdf_base64 (base64-encoded PDF or DXF bytes)."}
146
+ try:
147
+ data = base64.b64decode(pdf_base64)
148
+ except Exception as exc: # noqa: BLE001
149
+ return {"error": f"pdf_base64 is not valid base64: {exc}"}
150
+ try:
151
+ b64s, texts, total, dims = eng.file_to_pages("upload.pdf", data, max_pages=max_pages)
152
+ except Exception as exc: # noqa: BLE001
153
+ return {"error": str(exc)}
154
+ out = {"total_pages": total, "pages_extracted": len(texts),
155
+ "pages": [{"page": i + 1, "sheet_id": eng.guess_sheet_id(t) or "", "text": t}
156
+ for i, t in enumerate(texts)]}
157
+ if include_images:
158
+ out["page_images_base64_jpeg"] = b64s
159
+ return out
160
+
161
+
162
+ @mcp.tool()
163
+ def get_review_kit(municipality: str = "", review_track: str = "suites",
164
+ application_date: str = "") -> dict:
165
+ """Everything needed to REVIEW a plan set yourself. NO API KEY NEEDED.
166
+
167
+ Returns the jurisdiction framing, the grounded knowledge base for the chosen
168
+ track, the critical review rules (scope gate, grounded-citation rule, category
169
+ discipline), and the JSON output schema.
170
+
171
+ Recommended agent workflow:
172
+ 1. extract_document(pdf_base64=...) β†’ the plan text/images
173
+ 2. get_review_kit(municipality=..., review_track=...) β†’ rules + knowledge
174
+ 3. YOUR model produces the findings, obeying `critical_rules` and citing ONLY
175
+ clause numbers that appear verbatim in `knowledge`.
176
+ """
177
+ jur = _jur(municipality)
178
+ _, _, tracks, tr = _load(review_track, municipality)
179
+ if not tracks:
180
+ return {"error": f"No review skills are configured for '{jur.place}' yet.",
181
+ "municipality": jur.place,
182
+ "how_to_fix": ("Add skill folders under skills/ whose SKILL.md frontmatter "
183
+ f"declares `jurisdiction: {_slug(municipality)}` (plus track, "
184
+ "track_label, track_scope). Until then this municipality has "
185
+ "framing but no grounded rules, so no review can be performed."),
186
+ "frameworks": {"safety": jur.safety_framework,
187
+ "land_use": jur.landuse_framework}}
188
+ knowledge = "\n\n".join(
189
+ [f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in tr["skills"]] +
190
+ [f"=== {s.key} ===\n{s.content}" for sk in tr["skills"] for s in sk.sources])
191
+ rules = eng.build_critical_rules(tr["label"], tr["scope"],
192
+ ", ".join(v["label"] for v in tracks.values()), jur)
193
+ trans = f" (assess against {jur.transition_label})" if jur.transition_label else ""
194
+ return {
195
+ "municipality": jur.place,
196
+ "review_track": {"id": review_track, "label": tr["label"], "scope": tr["scope"]},
197
+ "frameworks": {"safety": jur.safety_framework, "land_use": jur.landuse_framework},
198
+ "project_framing": (f"Municipality: {jur.place}\nReview type: {tr['label']}\n"
199
+ f"Intended application date: {application_date or 'not stated'}{trans}"),
200
+ "critical_rules": rules,
201
+ "knowledge": knowledge,
202
+ "output_schema": eng.REVIEW_SYSTEM_TMPL.split("OUTPUT:", 1)[-1].strip(),
203
+ "note": ("Cite ONLY clause/section numbers that appear verbatim in `knowledge`. "
204
+ "If a rule is real but its number is not present, name the reference file "
205
+ "instead or record an information gap β€” never invent a number."),
206
+ }
207
+
208
+
209
+ @mcp.tool()
210
+ def get_corrections_kit(municipality: str = "") -> dict:
211
+ """Everything needed to INTERPRET a corrections letter yourself. NO API KEY NEEDED.
212
+
213
+ Returns the grounded knowledge, the honesty rules, and the output schema for
214
+ turning a municipal corrections/Detailed-Review letter into an item-by-item
215
+ analysis and a draft response.
216
+
217
+ The honesty rule is the important part: a draft response must NEVER claim a
218
+ correction has been resolved. Every resolution belongs to the applicant and is
219
+ represented by an [APPLICANT: ...] placeholder."""
220
+ jur = _jur(municipality)
221
+ skills, _ = eng.load_knowledge(BASE_DIR)
222
+ sk = [x for t in eng.discover_tracks(skills).values() for x in t["skills"]]
223
+ knowledge = "\n\n".join(
224
+ [f"=== {x.name}/SKILL.md ===\n{x.skill_md}" for x in sk] +
225
+ [f"=== {s.key} ===\n{s.content}" for x in sk for s in x.sources])
226
+ system = eng.CORRECTIONS_SYSTEM_TMPL.format(knowledge="<knowledge supplied separately>",
227
+ **eng._jur_fields(jur))
228
+ return {
229
+ "municipality": jur.place,
230
+ "frameworks": {"safety": jur.safety_framework, "land_use": jur.landuse_framework},
231
+ "rules_and_schema": system,
232
+ "knowledge": knowledge,
233
+ "honesty_rule": ("NEVER state or imply a correction has been resolved. Every "
234
+ "resolution is an [APPLICANT: ...] placeholder the applicant fills in."),
235
+ }
236
+
237
+
238
+ # ═══════════════════════════════════════════════════════════════════════════
239
+ # SERVER-SIDE TOOLS β€” these DO call an LLM provider (need an API key).
240
+ # Use them from non-LLM callers: n8n, scripts, schedulers.
241
+ # ═══════════════════════════════════════════════════════════════════════════
242
+
243
  @mcp.tool()
244
  def review_plan_set(
245
  pdf_base64: str,
 
263
  if not key:
264
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
265
  jur = _jur(municipality)
266
+ skills_all, loose, tracks, tr = _load(review_track, municipality)
267
  skills = tr["skills"]
268
  data = base64.b64decode(pdf_base64)
269
  b64s, texts, total, dims = eng.file_to_pages("upload.pdf", data, max_pages=max_pages)
 
305
  if not key:
306
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
307
  jur = _jur(municipality)
308
+ skills, loose = eng.load_knowledge(BASE_DIR, jurisdiction=_slug(municipality))
309
  sk = [x for t in eng.discover_tracks(skills).values() for x in t["skills"]]
310
  sources = [s for x in sk for s in x.sources]
311
  data = base64.b64decode(letter_base64)
 
339
  if not key:
340
  return {"error": f"No API key for {provider}. Pass api_key or set the env var."}
341
  jur = _jur(municipality)
342
+ _, _, tracks, tr = _load(review_track, municipality)
343
  result = eng.run_checklist(provider, model, key, tr["skills"],
344
  {"label": tr["label"], "scope": tr["scope"]},
345
  project_desc=project_description, base_url=base_url, jur=jur)
 
359
 
360
  choices = list_jurisdictions(BASE_DIR)
361
  muns = ", ".join(s for s in choices if not s.startswith("_") and s != "default") or "β€”"
362
+ # HF Spaces terminates TLS at its proxy, so request.base_url reports http://
363
+ # inside the container. Emitting that URL makes clients (e.g. HuggingChat)
364
+ # reject it as insecure β€” honour the forwarded proto and default to https.
365
  base = str(request.base_url).rstrip("/")
366
+ proto = request.headers.get("x-forwarded-proto", "")
367
+ host = request.headers.get("host", "")
368
+ if proto:
369
+ base = f"{proto}://{host}"
370
+ elif base.startswith("http://") and not host.startswith(("localhost", "127.0.0.1")):
371
+ base = "https://" + base[len("http://"):]
372
  rows = "".join(
373
  f"<tr><td><code>{n}</code></td><td>{d}</td></tr>" for n, d in [
374
  ("list_municipalities", "Configured jurisdictions + their frameworks"),