File size: 83,318 Bytes
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4efd9a2
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4efd9a2
 
 
 
 
 
 
 
 
 
c3e3226
4efd9a2
c3e3226
 
 
 
 
 
 
 
 
 
 
4efd9a2
 
 
 
 
c3e3226
 
 
4efd9a2
 
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e917f80
 
 
 
 
c3e3226
2235290
c3e3226
 
 
 
3d382a7
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e917f80
 
c3e3226
 
 
 
 
 
e917f80
 
 
 
c3e3226
 
3d382a7
 
 
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2235290
 
 
 
 
 
 
 
 
 
3d382a7
 
 
 
 
 
 
 
 
 
 
 
 
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d382a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d382a7
 
 
 
 
 
 
 
 
 
 
c3e3226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e917f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d382a7
e917f80
 
 
 
 
 
 
 
 
 
 
3d382a7
e917f80
3d382a7
e917f80
3d382a7
e917f80
 
 
 
 
 
 
 
3d382a7
e917f80
 
3d382a7
 
e917f80
 
 
 
3d382a7
 
 
 
 
 
 
 
e917f80
 
 
 
 
 
 
3d382a7
e917f80
 
 
 
3d382a7
 
 
 
 
 
e917f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d382a7
e917f80
 
 
 
3d382a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e917f80
3d382a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e917f80
3d382a7
e917f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
"""Engine for the Calgary Suite Plan Review Space (WNTR_GIS architecture).

Deterministic layer (free): knowledge discovery, PDF page rendering + text
extraction, in-memory SQLite database build.
LLM layer (user's key): review pipeline (route -> vision review) and a
provider-agnostic chat that queries the SQLite DB through a JSON tool protocol
(SELECT-only SQL, knowledge search, page text) β€” the same "tools over token
dumps" idea as WNTR_GIS.
"""

from __future__ import annotations

import base64
import json
import os
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path

import fitz  # PyMuPDF

from jurisdiction import Jurisdiction, load_jurisdiction  # noqa: E402

from providers import llm_call, supports_vision

TEXT_EXT = {".md", ".txt"}
PDF_EXT = {".pdf"}
MAX_SOURCE_CHARS = 60_000
KNOWLEDGE_BUDGET_CHARS = 120_000

# ────────────────────────────────────────────────────────── knowledge layer ──

@dataclass
class Source:
    skill: str
    rel: str
    content: str
    origin: str  # "skill" | "drop-folder" | "session-upload"

    @property
    def key(self) -> str:
        return f"{self.skill}/{self.rel}"

    def head(self, n: int = 200) -> str:
        override = getattr(self, "_head_override", None)
        if override:
            return override[:n]
        body = re.sub(r"^---\n.*?\n---\n", "", self.content, flags=re.S).strip()
        return " ".join(body.split())[:n]


@dataclass
class Skill:
    name: str
    skill_md: str
    description: str
    sources: list[Source] = field(default_factory=list)
    track: str = "suites"
    track_label: str = ""
    track_scope: str = ""
    jurisdiction: str = ""


def _read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8", errors="replace")[:MAX_SOURCE_CHARS]
    except Exception as exc:  # noqa: BLE001
        return f"[unreadable: {exc}]"


HARD_PDF_CHARS = 4_000_000     # absolute extraction ceiling (~1000+ pages)
_EXTRACT_CACHE: dict = {}      # (path, size, mtime) -> text


def _pdf_text_file(path_or_bytes) -> str:
    """Extract FULL text of a PDF (up to HARD_PDF_CHARS); cached for on-disk files."""
    cache_key = None
    try:
        if isinstance(path_or_bytes, (str, Path)):
            p = Path(path_or_bytes)
            stat = p.stat()
            cache_key = (str(p), stat.st_size, stat.st_mtime)
            if cache_key in _EXTRACT_CACHE:
                return _EXTRACT_CACHE[cache_key]
            doc = fitz.open(p)
        else:
            doc = fitz.open(stream=path_or_bytes, filetype="pdf")
        parts = []
        for i, page in enumerate(doc):
            parts.append(f"\n--- page {i + 1} ---\n{page.get_text('text')}")
            if sum(len(p_) for p_ in parts) > HARD_PDF_CHARS:
                parts.append("\n[truncated at extraction ceiling]")
                break
        doc.close()
        text = "".join(parts)[:HARD_PDF_CHARS]
        if cache_key:
            _EXTRACT_CACHE[cache_key] = text
        return text
    except Exception as exc:  # noqa: BLE001
        return f"[pdf extraction failed: {exc}]"


_SECTION_RE = re.compile(r"\b(\d{1,2}\.\d{1,2}\.\d{1,3}(?:\.\d{1,3})?\.?)\s")


def _chunk_head(chunk: str) -> str:
    """Preview for a chunk: page span + section numbers spotted, so the router can pick."""
    pages = re.findall(r"--- page (\d+) ---", chunk)
    span = f"pp.{pages[0]}-{pages[-1]}" if pages else ""
    # Collapse clause numbers (9.37.2.1) to subsection level (9.37) so the whole
    # chunk's coverage fits in a short preview the router can scan.
    subs = list(dict.fromkeys(".".join(m.split(".")[:2]) for m in _SECTION_RE.findall(chunk)))
    sec_note = (" sections " + ", ".join(subs[:20])) if subs else ""
    first = " ".join(chunk.split())[:100]
    return f"[{span}{sec_note}] {first}"


def make_sources(skill: str, rel: str, origin: str, text: str) -> list[Source]:
    """One Source if small; otherwise split into router-selectable chunks.

    Large documents (e.g., a full NBC Alberta Part 9 PDF) become
    name.pdf#chunkN sources with page-span + section-number previews, so the
    knowledge router can load only the relevant sections instead of a
    truncated head of the whole book.
    """
    if len(text) <= MAX_SOURCE_CHARS:
        return [Source(skill, rel, text, origin)]
    # Split on page markers, pack into ~MAX_SOURCE_CHARS chunks
    pieces = re.split(r"(?=\n--- page \d+ ---)", text)
    chunks, buf = [], ""
    for piece in pieces:
        if buf and len(buf) + len(piece) > MAX_SOURCE_CHARS:
            chunks.append(buf); buf = piece
        else:
            buf += piece
    if buf:
        chunks.append(buf)
    out = []
    for n, chunk in enumerate(chunks, 1):
        src = Source(skill, f"{rel}#chunk{n}", chunk, origin)
        src._head_override = _chunk_head(chunk)  # type: ignore[attr-defined]
        out.append(src)
    return out


def _frontmatter_desc(md: str) -> str:
    m = re.match(r"^---\n(.*?)\n---", md, flags=re.S)
    if m:
        d = re.search(r'description:\s*"?(.*?)"?\s*(?:\n[a-z_]+:|\Z)', m.group(1), flags=re.S)
        if d:
            return " ".join(d.group(1).split())[:600]
    return ""


def load_knowledge(base_dir, uploaded=None,
                   jurisdiction: str = "") -> tuple[list[Skill], list[Source]]:
    """Load skills + drop-folder knowledge.

    uploaded: list of (filename, bytes) from Streamlit uploads.
    jurisdiction: when set, only load skills whose SKILL.md frontmatter declares a
        matching `jurisdiction:` (comma-separated list allowed), plus skills that
        declare none (treated as universal). This lets one deployment hold several
        municipalities' knowledge without a Calgary review pulling in Houston rules.
    """
    base = Path(base_dir)
    want = jurisdiction.strip().lower()
    skills: list[Skill] = []
    for skill_dir in sorted((base / "skills").glob("*")):
        smd = skill_dir / "SKILL.md"
        if not skill_dir.is_dir() or not smd.exists():
            continue
        md = _read_text(smd)
        fm = re.match(r"^---\n(.*?)\n---", md, flags=re.S)
        fm_text = fm.group(1) if fm else ""
        def _fm_key(key: str, default: str = "") -> str:
            m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', fm_text, flags=re.M)
            return m.group(1).strip() if m else default
        skill_jur = _fm_key("jurisdiction")
        if want and skill_jur:
            allowed = {j.strip().lower() for j in skill_jur.split(",") if j.strip()}
            if want not in allowed:
                continue  # belongs to a different municipality
        sk = Skill(skill_dir.name, md, _frontmatter_desc(md),
                   track=_fm_key("track", "suites"),
                   track_label=_fm_key("track_label"),
                   track_scope=_fm_key("track_scope"),
                   jurisdiction=skill_jur)
        seen_keys: set[str] = set()
        for ref in sorted(skill_dir.rglob("*")):
            if not ref.is_file() or ref.name.lower() == "skill.md":
                continue
            # Case-duplicate guard (e.g. CLAUDE.md vs Claude.md uploaded twice):
            # first occurrence wins, case-insensitively.
            dedupe_key = str(ref.relative_to(skill_dir)).lower()
            if dedupe_key in seen_keys:
                continue
            seen_keys.add(dedupe_key)
            if ref.suffix.lower() in TEXT_EXT:
                sk.sources += make_sources(sk.name, str(ref.relative_to(skill_dir)),
                                           "skill", _read_text(ref))
            elif ref.suffix.lower() in PDF_EXT:
                sk.sources += make_sources(sk.name, str(ref.relative_to(skill_dir)),
                                           "skill", _pdf_text_file(ref))
        skills.append(sk)

    loose: list[Source] = []
    drop = base / "knowledge"
    if drop.exists():
        for f in sorted(drop.rglob("*")):
            if f.is_file() and f.suffix.lower() in TEXT_EXT:
                loose += make_sources("knowledge", str(f.relative_to(drop)), "drop-folder", _read_text(f))
            elif f.is_file() and f.suffix.lower() in PDF_EXT:
                loose += make_sources("knowledge", str(f.relative_to(drop)), "drop-folder", _pdf_text_file(f))

    for name, data in (uploaded or []):
        suffix = Path(name).suffix.lower()
        if suffix in TEXT_EXT:
            try:
                txt = data.decode("utf-8", errors="replace")
            except Exception:  # noqa: BLE001
                txt = "[decode failed]"
            loose += make_sources("knowledge", name, "session-upload", txt)
        elif suffix in PDF_EXT:
            loose += make_sources("knowledge", name, "session-upload", _pdf_text_file(data))

    return skills, loose


DEFAULT_TRACK_META = {
    "suites": {
        "label": "secondary/backyard suite",
        "scope": "a secondary or backyard suite application (basement suite, garden/laneway/garage suite) on a low-density residential parcel",
    },
}


def discover_tracks(skills: list[Skill]) -> dict:
    """Group skills into review tracks declared in SKILL.md frontmatter.

    Adding a review type = dropping a skill folder whose frontmatter declares
    `track`, `track_label`, and `track_scope` β€” no code changes.
    """
    tracks: dict[str, dict] = {}
    for sk in skills:
        t = sk.track or "suites"
        entry = tracks.setdefault(t, {"label": "", "scope": "", "skills": []})
        entry["skills"].append(sk)
        if sk.track_label and not entry["label"]:
            entry["label"] = sk.track_label
        if sk.track_scope and not entry["scope"]:
            entry["scope"] = sk.track_scope
    for t, entry in tracks.items():
        meta = DEFAULT_TRACK_META.get(t, {})
        entry["label"] = entry["label"] or meta.get("label", t.replace("-", " "))
        entry["scope"] = entry["scope"] or meta.get("scope", f"a {entry['label']} application")
    return tracks


def knowledge_manifest(skills: list[Skill], loose: list[Source]) -> str:
    lines = []
    for sk in skills:
        lines.append(f"SKILL {sk.name}: {sk.description}")
        lines += [f"  - {s.key}  [{len(s.content):,} chars]  {s.head(120)}" for s in sk.sources]
    if loose:
        lines.append("LOOSE KNOWLEDGE (drop folder / session uploads):")
        lines += [f"  - {s.key}  [{len(s.content):,} chars; {s.origin}]  {s.head(120)}" for s in loose]
    return "\n".join(lines)


# ─────────────────────────────────────────────────────────────── pdf layer ──

def pdf_to_pages(pdf_bytes: bytes, max_pages: int = 10, long_edge: int = 1568,
                 jpeg_quality: int = 80):
    """Render plan pages β†’ base64 JPEGs + embedded text. Returns (b64s, texts, total, dims)."""
    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
    total = doc.page_count
    b64s, texts, dims = [], [], []
    for i in range(min(total, max_pages)):
        page = doc.load_page(i)
        rect = page.rect
        zoom = long_edge / max(rect.width, rect.height)
        pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
        b64s.append(base64.standard_b64encode(pix.tobytes("jpeg", jpg_quality=jpeg_quality)).decode())
        texts.append(page.get_text("text")[:4000])
        dims.append((round(rect.width), round(rect.height)))
    doc.close()
    return b64s, texts, total, dims


def dxf_to_pages(dxf_bytes: bytes, max_pages: int = 50, long_edge: int = 1568,
                 jpeg_quality: int = 85):
    """Render a DXF CAD drawing β†’ base64 JPEGs + entity text, one 'page' per layout
    (model space + each paper-space layout). Returns (b64s, texts, total, dims).

    Text extraction (TEXT/MTEXT/dimension/attribute entities) is reliable; the
    raster render is best-effort β€” real DXFs may use xrefs, SHX fonts, or custom
    styles the pip-only renderer can't fully resolve, so a layout that fails to
    render still contributes its extracted text.
    """
    import io
    import tempfile
    import ezdxf
    from ezdxf.addons.drawing import RenderContext, Frontend
    from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as tf:
        tf.write(dxf_bytes)
        path = tf.name
    try:
        try:
            doc = ezdxf.readfile(path)
        except ezdxf.DXFStructureError as e:
            raise RuntimeError(
                "This DXF could not be parsed (corrupt or unsupported structure): "
                f"{e}. Try re-exporting as DXF (AutoCAD 2010+ / ASCII) or as PDF."
            ) from e

        # Order layouts: model space first, then paper-space layouts.
        layout_names = ["Model"] + [n for n in doc.layout_names_in_taborder()
                                    if n != "Model"]
        b64s, texts, dims = [], [], []
        total = len(layout_names)
        for name in layout_names[:max_pages]:
            layout = doc.layout(name) if name != "Model" else doc.modelspace()
            # --- text (reliable) ---
            chunks = []
            for e in layout:
                t = e.dxftype()
                if t == "TEXT":
                    chunks.append(e.dxf.text)
                elif t == "MTEXT":
                    chunks.append(e.text.replace("\\P", "\n"))
                elif t in ("ATTRIB", "ATTDEF"):
                    chunks.append(getattr(e.dxf, "text", ""))
                elif t == "DIMENSION":
                    m = getattr(e.dxf, "text", "")
                    if m and m != "<>":
                        chunks.append(m)
            page_text = f"[DXF layout: {name}]\n" + "\n".join(c for c in chunks if c)
            texts.append(page_text[:4000])
            # --- render (best-effort) ---
            img_b64 = ""
            try:
                fig = plt.figure(dpi=150)
                ax = fig.add_axes([0, 0, 1, 1]); ax.set_axis_off()
                Frontend(RenderContext(doc), MatplotlibBackend(ax)).draw_layout(
                    layout, finalize=True)
                buf = io.BytesIO()
                fig.savefig(buf, format="jpeg", bbox_inches="tight",
                            pil_kwargs={"quality": jpeg_quality})
                plt.close(fig)
                img_b64 = base64.standard_b64encode(buf.getvalue()).decode()
                w, h = fig.get_size_inches() * fig.dpi
                dims.append((round(float(w)), round(float(h))))
            except Exception:  # noqa: BLE001 β€” render failure must not lose the text
                plt.close("all")
                dims.append((0, 0))
            b64s.append(img_b64)
        return b64s, texts, total, dims
    finally:
        try:
            os.unlink(path)
        except OSError:
            pass


def file_to_pages(filename: str, data: bytes, max_pages: int = 50):
    """Dispatch a plan-set upload to the right extractor by extension.

    PDF β†’ rasterize + embedded text (highest fidelity).
    DXF β†’ CAD vector render + entity text (pip-only, best-effort raster).
    DWG β†’ not parseable in this environment (proprietary binary); guide the user.
    """
    ext = os.path.splitext(filename)[1].lower()
    if ext == ".pdf":
        return pdf_to_pages(data, max_pages=max_pages)
    if ext == ".dxf":
        return dxf_to_pages(data, max_pages=max_pages)
    if ext == ".dwg":
        raise RuntimeError(
            "DWG is AutoCAD's proprietary binary format and can't be read in this "
            "hosted environment (it needs the ODA File Converter, which isn't "
            "available on the Space). Please export the drawing from your CAD "
            "software as **PDF** (best fidelity for review) or **DXF** (AutoCAD "
            "2010+/ASCII) and upload that instead."
        )
    raise RuntimeError(f"Unsupported plan-set type '{ext}'. Upload a PDF or DXF.")


def guess_sheet_id(text: str) -> str:
    m = re.search(r"\b([ASMEC]-?\d{1,3}(?:\.\d+)?)\b", text or "")
    return m.group(1) if m else ""


# ──────────────────────────────────────────────────────────── sqlite layer ──

def build_review_db(page_texts: list[str], dims, total_pages: int,
                    skills: list[Skill], loose: list[Source], meta: dict) -> sqlite3.Connection:
    """In-memory SQLite the AI queries via tools (WNTR_GIS pattern)."""
    con = sqlite3.connect(":memory:", check_same_thread=False)
    cur = con.cursor()
    cur.executescript("""
    CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
    CREATE TABLE pages (page INTEGER PRIMARY KEY, sheet_guess TEXT, width INTEGER,
                        height INTEGER, text TEXT);
    CREATE TABLE knowledge_sources (key TEXT PRIMARY KEY, skill TEXT, rel TEXT,
                                    origin TEXT, chars INTEGER, head TEXT);
    CREATE TABLE runs (run_id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, provider TEXT,
                       model TEXT, pages_reviewed INTEGER, verdict TEXT,
                       must_fix INTEGER, clarify INTEGER, advisory INTEGER,
                       transition_note TEXT, routing TEXT);
    CREATE TABLE findings (fid TEXT, run_id INTEGER, page INTEGER, sheet_id TEXT,
                           discipline TEXT, category TEXT, severity TEXT,
                           confidence TEXT, description TEXT, citation TEXT);
    """)
    for k, v in meta.items():
        cur.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", (k, str(v)))
    cur.execute("INSERT OR REPLACE INTO meta VALUES ('total_pages',?)", (str(total_pages),))
    for i, t in enumerate(page_texts):
        w, h = dims[i] if i < len(dims) else (0, 0)
        cur.execute("INSERT INTO pages VALUES (?,?,?,?,?)",
                    (i + 1, guess_sheet_id(t), w, h, t))
    for s in [x for sk in skills for x in sk.sources] + loose:
        cur.execute("INSERT OR REPLACE INTO knowledge_sources VALUES (?,?,?,?,?,?)",
                    (s.key, s.skill, s.rel, s.origin, len(s.content), s.head(160)))
    con.commit()
    return con


def record_run(con: sqlite3.Connection, provider: str, model: str,
               pages_reviewed: int, routing: str, result: dict) -> int:
    s = result.get("summary", {})
    cur = con.cursor()
    cur.execute("INSERT INTO runs (ts,provider,model,pages_reviewed,verdict,must_fix,"
                "clarify,advisory,transition_note,routing) VALUES (?,?,?,?,?,?,?,?,?,?)",
                (datetime.now().isoformat(timespec="seconds"), provider, model, pages_reviewed,
                 s.get("verdict", ""), int(s.get("must_fix", 0) or 0),
                 int(s.get("clarify", 0) or 0), int(s.get("advisory", 0) or 0),
                 s.get("transition_note", ""), routing))
    run_id = cur.lastrowid
    for f in result.get("findings", []):
        cur.execute("INSERT INTO findings VALUES (?,?,?,?,?,?,?,?,?,?)",
                    (f.get("id", ""), run_id, f.get("page"), f.get("sheet_id", ""),
                     f.get("discipline", ""), f.get("category", ""), f.get("severity", ""),
                     f.get("confidence", ""), f.get("description", ""), f.get("citation", "")))
    con.commit()
    return run_id


DB_SCHEMA_DOC = """SQLite tables available:
- meta(key, value) β€” project details, suite_type, application_date, total_pages
- pages(page, sheet_guess, width, height, text) β€” embedded text per plan page
- knowledge_sources(key, skill, rel, origin, chars, head) β€” loaded knowledge catalog
- runs(run_id, ts, provider, model, pages_reviewed, verdict, must_fix, clarify, advisory, transition_note, routing)
- findings(fid, run_id, page, sheet_id, discipline, category, severity, confidence, description, citation)"""


# ──────────────────────────────────────────────────────────────── prompts ──

CRITICAL_RULES_TMPL = """CRITICAL RULES ({jur_place} jurisdiction β€” review track: {track_label}):
- SCOPE GATE (Phase 0, before any findings): identify the ACTUAL project from title blocks, project data tables, and drawings (project name, use, zoning/district, applicant). This review track covers ONLY {track_scope}. If the submission does not match this track (wrong building type, use, or application kind), set submission_check.matches_declared_scope=false, and if another available review track fits, name it in submission_check.suggested_track, explain what the submission actually is, produce ZERO track-specific findings, and set the verdict to "out of scope β€” wrong review track". Reviewing a mismatched submission against this track's criteria produces false corrections and is worse than no review.
- VIOLATION IS NOT MISMATCH (critical): a submission that IS this track's project type but BREAKS one of its rules is IN SCOPE β€” the violation is a FINDING, never a scope rejection. For the suites track specifically: a proposal for a secondary suite, a backyard suite, or BOTH is a suite application and is in scope. The rule that a secondary suite and a backyard suite may not share one parcel is a substantive land-use requirement β€” when a submission proposes both on one parcel, that is a MUST-FIX land-use finding (cite the same-parcel density rule), NOT grounds to declare out of scope. Never bounce a genuine suite because it is non-compliant; reviewing non-compliant suites is the entire purpose. Set matches_declared_scope=true and raise the finding.
- SUGGESTED TRACK MUST EXIST: submission_check.suggested_track may ONLY name a track from AVAILABLE TRACKS listed below. If no listed track fits better, leave suggested_track empty. Never invent a track name.
- CONTENT, NOT SHELL: a submission matches this track only if it actually CONTAINS the defining content of {track_scope} β€” not merely a structure that could later host it. A bare accessory building (detached garage, shed, carport) shown as a single-storey vehicle structure with only overhead/man doors, no habitable rooms, no egress-window bedrooms, no kitchen/bathroom, and no dwelling-unit separation, is NOT a suite β€” it is an accessory building. In that case set matches_declared_scope=false and, in the note, distinguish the two real possibilities: (a) this is a garage/accessory-building-only permit β†’ out of scope for suite review; or (b) a suite is intended but the suite-defining drawings (floor plan with habitable rooms, egress windows, fire/sound separations, plumbing) are ABSENT from this set β†’ incomplete submission. Do NOT infer suite intent from garage dimensions alone, and never pass an accessory-building-only set as "in scope" with zero findings β€” that falsely implies a compliant suite.
- REVIEW ORDER (follow the municipality's own sequence): (1) read the SITE ADDRESS from the title block/site plan and report it; (2) determine the LAND USE DISTRICT for that address β€” from the drawings if stated, otherwise from the district supplied to you in the project context; (3) look the district up in the district-standards reference and apply ITS standards; (4) only then assess the submission and write findings. If a district-specific standard is not populated in the knowledge (marked [VERIFY]), say the district standard could not be confirmed and record an information gap β€” never substitute another district's numbers or invent one.
- USE TYPE WORDING: never report use_type as "not determinable without the district" when a district has been supplied or is shown on the plans β€” that is self-contradictory. If the district is known but its permitted/discretionary use list is not in the loaded knowledge, say so naming the district. Reserve "without the district" for the case where the district itself is genuinely unknown.
- IDENTIFY THE LAND USE DISTRICT FIRST. Every municipal review states the district and the Use Type (Permitted / Discretionary) in its header, because district determines which standards apply. Populate submission_check.land_use_district and use_type. If the drawings do not state the district, set it to "not shown on the submitted plans" and treat EVERY district-dependent conclusion as unconfirmed, saying so β€” do not assume a district.
- BYLAW DISCREPANCY FORM (land-use findings): every land-use finding must additionally carry `regulation`, `standard`, and `provided`, taken from the regulation index in the knowledge. `regulation` is the City's heading (e.g. "412 Parcel Coverage"); `standard` is that regulation's Standard wording; `provided` states what the plans actually show, in the City's register, with the numeric delta where measurable (e.g. "Plans indicate a rear setback of 1.02m (-0.48m)." / "Plans do not indicate a designated private amenity space for the Backyard Suite."). Safety-codes and reviewer-items leave these three fields empty.
- RUN EVERY MANDATORY CHECK. The regulation index lists checks that must be assessed on EVERY suite review β€” parcel coverage, parking per dwelling unit, suite parking, rear setback, faΓ§ade separation, amenity space, and same-parcel suite density. If the submitted set does not contain enough information to measure one of them, still raise it, with `provided` stating plainly that the plans do not show it. Silently omitting a mandatory check is a review defect.
- AVAILABLE TRACKS: {available_tracks}
- CONVERT UNITS BEFORE JUDGING A DIMENSION. Drawings are often imperial while the code is metric (1 in = 25.4 mm). Never assert that a dimension fails a metric minimum without converting it first and stating the converted value in the finding. A nominal window/door callout (e.g. 48"x32") is a FRAME size, not an unobstructed opening β€” the correct finding is that operation type and clear-opening dimensions are not stated, not that the unit is undersized.
- GROUNDED CITATIONS ONLY: every cited clause/section number must appear VERBATIM in the KNOWLEDGE below. Before writing any citation, confirm that exact number string is present in the knowledge text. If the rule is real but its number is not in the knowledge (e.g. a {jur_safety_short} article you recall but that is not quoted below), DO NOT write the number β€” cite the knowledge reference by name (e.g. a skill reference file) or record the gap under information_gaps. Never emit a clause number you cannot see in the knowledge; a plausible-but-unverified number (e.g. guessing a foundation article) is a citation error.
- ENGINEERED-DESIGN ITEMS STAY REVIEWER-ITEMS: new exterior stairwell foundations, retaining walls, beam/structural modifications, and frost-cover/footing-depth adequacy are engineer-of-record scope. Raise them as category "reviewer-item" with a [REVIEWER: ...] blank and severity per judgment β€” do NOT reclassify them as safety-codes with a specific code citation, and do NOT attach a clause number you cannot verify in the knowledge.
- Every finding MUST carry a specific citation: a {jur_safety_short} provision / municipal building advisory (safety-codes items) OR a {jur_landuse} section / permit condition (land-use items). No citation -> DROP the finding. No false positives.
- BE EXHAUSTIVE: report EVERY discrepancy you can substantiate from the drawings and the appended per-page text β€” not a representative sample. A non-compliant plan commonly contains 10+ distinct issues across land-use and safety-codes; do not stop after the first several. Read each page's note block line by line (ceiling height, window sizes, door specs, stair widths, separation build-ups, alarms, sound, backflow, electrical branch circuits, amenity space) and raise each stated deficiency as its own finding.
- GAP CLASSIFICATION: separate what the SUBMISSION lacks from what the LOADED KNOWLEDGE lacks. A missing dimension on a drawing is a submission gap; an unpopulated [VERIFY] district standard is a knowledge gap. Never write a knowledge gap as though the applicant failed to provide something.
- VERDICT CONSISTENCY: summary.verdict must agree with the scope decision. Use an "out of scope" verdict ONLY when submission_check.matches_declared_scope is false. When the submission is in scope, the verdict states the outcome (e.g., "revisions required β€” N must-fix items" or "supportable subject to conditions"), never "out of scope".
- Tag every finding "safety-codes" or "land-use" β€” never blend them; their variance/appeal paths differ.
{jur_transition_rule}- Structural, engineering, and professional-judgment items get category "reviewer-item" with a [REVIEWER: ...] placeholder instead of invented specifications.
- Mark visual reads you are unsure of with confidence "low" and prefix the description with [VERIFY].
- Use only the knowledge provided; if a needed criterion is not in the knowledge, record it as an information gap rather than inventing a number."""

ROUTER_SYSTEM_TMPL = """You are the knowledge router for a {jur_domain}.
You will see: (a) each skill's SKILL.md decision-tree router, (b) a manifest of every loadable knowledge source.
Pick the sources needed for THIS project. Follow the routers; include loose knowledge files whose titles/heads are relevant.
Respond with ONLY a JSON object: {{"selected": ["<skill>/<rel>", ...], "reasoning": "<one sentence>"}} β€” no markdown fences, no extra text."""

REVIEW_SYSTEM_TMPL = """You are CrossBeam, a {track_label} plan reviewer for {jur_place}.
You review uploaded architectural plan pages against the knowledge provided and produce draft correction findings.

{critical_rules}

KNOWLEDGE (authoritative for this review):
{knowledge}

OUTPUT: respond with ONLY a JSON object, no markdown fences:
{{
  "submission_check": {{"detected_project": "...", "detected_use_or_district": "...",
    "land_use_district": "<district exactly as designated, or 'not shown on the submitted plans'>",
    "use_type": "<Permitted|Discretionary β€” if the district IS known but its use list is not in the knowledge, write 'not determinable β€” <district> use list not in loaded knowledge'; only write 'not determinable without the district' when the district itself is unknown>",
    "matches_declared_scope": true, "suggested_track": "", "note": "..."}},
  "sheet_manifest": [{{"page": 1, "sheet_id": "A1", "title": "..."}}],
  "findings": [
    {{"id": "F-01", "page": 2, "sheet_id": "A2", "discipline": "arch|site|structural|mep|planning",
      "category": "safety-codes|land-use|reviewer-item",
      "description": "...", "citation": "...", "severity": "must-fix|clarify|advisory",
      "confidence": "high|medium|low",
      "regulation": "<land-use only: heading as the City writes it, e.g. '412 Parcel Coverage'>",
      "standard": "<land-use only: the Standard wording for that regulation>",
      "provided": "<land-use only: what the plans show, City register, with numeric delta>"}}
  ],
  "summary": {{"verdict": "...", "must_fix": 0, "clarify": 0, "advisory": 0,
               "transition_note": "...",
               "information_gaps_submission": ["<missing from the SUBMITTED SET β€” the applicant can fix these>"],
               "information_gaps_knowledge": ["<missing from the LOADED KNOWLEDGE β€” e.g. an unpopulated [VERIFY] district row; never phrased as an applicant deficiency>"]}}
}}"""

DEFAULT_CHAT_SYSTEM_TMPL = """You are a {jur_persona} ({jur_safety} + {jur_landuse}).

HOW TO REASON (follow this every time):
1. PLAN: briefly decide which tool calls you need.
2. GATHER: call the tools β€” query the findings/pages/knowledge database rather than guessing. Never hand-compute what a query returns.
3. VERIFY: sanity-check numbers (counts sum, severities match the run row) before presenting.
4. ANSWER: lead with the engineering conclusion, then supporting specifics with citations ({jur_safety_short} vs {jur_landuse_short}), then a clear recommendation.

KEY PRINCIPLES:
- Safety-codes items and land-use items follow different appeal paths β€” keep them distinct.
{jur_chat_transition}- If it isn't in the database or knowledge, say so plainly rather than inventing it."""


# Module-level default so callers that don't pass a jurisdiction still work
# (the Calgary reference implementation). App/engine callers pass an explicit one.
_DEFAULT_JUR = load_jurisdiction(".")


def _jur_fields(jur: Jurisdiction) -> dict:
    """Common jurisdiction substitutions shared by every prompt template."""
    trule = jur.transition_rule()
    return {
        "jur_place": jur.place,
        "jur_safety": jur.safety_framework,
        "jur_safety_short": jur.safety_short,
        "jur_landuse": jur.landuse_framework,
        "jur_landuse_short": jur.landuse_short,
        "jur_persona": jur.reviewer_persona,
        "jur_domain": jur.review_domain,
        # transition lines are whole bullets that vanish when disabled
        "jur_transition_rule": (f"- {trule}\n" if trule else ""),
        "jur_chat_transition": (
            f"- District/time-dependent answers must state the application date relative to "
            f"{jur.transition_label}.\n" if jur.transition_label else ""),
    }


def build_critical_rules(track_label: str, track_scope: str, available_tracks: str,
                         jur: Jurisdiction) -> str:
    return CRITICAL_RULES_TMPL.format(track_label=track_label, track_scope=track_scope,
                                      available_tracks=available_tracks, **_jur_fields(jur))


def build_router_system(jur: Jurisdiction) -> str:
    return ROUTER_SYSTEM_TMPL.format(**_jur_fields(jur))


def build_chat_system(jur: Jurisdiction) -> str:
    return DEFAULT_CHAT_SYSTEM_TMPL.format(**_jur_fields(jur))


CHAT_TOOL_PROTOCOL = """
TOOLS β€” to use one, respond with ONLY a JSON object (no other text):
  {"tool": "sql", "query": "SELECT ..."}            -- SELECT-only queries against the schema below
  {"tool": "knowledge_search", "q": "search terms"} -- full-text search across all loaded knowledge
  {"tool": "page_text", "page": 2}                  -- embedded text of a plan page
You may call tools across multiple turns. When you have enough, answer in plain language (not JSON).

""" + DB_SCHEMA_DOC


def _parse_json(text: str) -> dict:
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.S)
    start, end = text.find("{"), text.rfind("}")
    if start == -1:
        raise ValueError("no JSON object in model output")
    candidate = text[start:end + 1] if end > start else text[start:]
    try:
        return json.loads(candidate)
    except json.JSONDecodeError:
        pass
    # Recovery path: models that hit the token cap emit a truncated final finding
    # (e.g. "Expecting ',' delimiter"). Rather than lose the whole review, salvage
    # the well-formed findings and the summary/submission_check we can still read.
    return _salvage_json(text[start:])


def _salvage_json(blob: str) -> dict:
    """Best-effort recovery of a truncated findings JSON object."""
    out: dict = {}
    # submission_check: grab the first balanced {...} after the key
    for key in ("submission_check", "summary"):
        obj = _extract_object(blob, key)
        if obj is not None:
            out[key] = obj
    # findings: collect every complete top-level object inside the findings array
    fmatch = re.search(r'"findings"\s*:\s*\[', blob)
    if fmatch:
        i = fmatch.end()
        findings = []
        depth = 0
        buf = ""
        while i < len(blob):
            c = blob[i]
            if c == "]" and depth == 0:
                break
            if c == "{":
                if depth == 0:
                    buf = ""
                depth += 1
            if depth > 0:
                buf += c
            if c == "}":
                depth -= 1
                if depth == 0:
                    try:
                        findings.append(json.loads(buf))
                    except json.JSONDecodeError:
                        pass  # drop the truncated last one
            i += 1
        out["findings"] = findings
    out.setdefault("findings", [])
    summ = out.setdefault("summary", {})
    if "must_fix" not in summ:
        summ["must_fix"] = sum(1 for f in out["findings"]
                               if str(f.get("severity", "")).lower() == "must-fix")
    summ.setdefault("clarify", 0)
    summ.setdefault("advisory", 0)
    summ.setdefault("verdict",
                    f"revisions required β€” {summ['must_fix']} must-fix item(s) "
                    f"(recovered from truncated model output)")
    out["_recovered"] = True
    return out


def _extract_object(blob: str, key: str):
    m = re.search(rf'"{key}"\s*:\s*{{', blob)
    if not m:
        return None
    i = m.end() - 1
    depth = 0
    buf = ""
    while i < len(blob):
        c = blob[i]
        if c == "{":
            depth += 1
        buf += c
        if c == "}":
            depth -= 1
            if depth == 0:
                try:
                    return json.loads(buf)
                except json.JSONDecodeError:
                    return None
        i += 1
    return None


# ─────────────────────────────────────────────────────────── review pipeline ──

def route(provider: str, model: str, api_key: str, skills: list[Skill],
          loose: list[Source], project_desc: str,
          base_url: str = "", jur: Jurisdiction | None = None) -> tuple[list[Source], str]:
    jur = jur or _DEFAULT_JUR
    all_sources = {s.key: s for sk in skills for s in sk.sources} | {s.key: s for s in loose}
    if sum(len(s.content) for s in all_sources.values()) <= KNOWLEDGE_BUDGET_CHARS // 2:
        return list(all_sources.values()), "small knowledge base β€” loaded everything"

    routers = "\n\n".join(f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills)
    try:
        raw = llm_call(provider, model, api_key, build_router_system(jur),
                       f"PROJECT:\n{project_desc}\n\nROUTERS:\n{routers}\n\nMANIFEST:\n"
                       f"{knowledge_manifest(skills, loose)}", max_tokens=1200,
                       base_url=base_url)
        data = _parse_json(raw)
        picked = [all_sources[k] for k in data.get("selected", []) if k in all_sources]
        if not picked:
            raise ValueError("router selected nothing recognizable")
        out, used = [], 0
        for s in picked:
            if used + len(s.content) > KNOWLEDGE_BUDGET_CHARS:
                break
            out.append(s); used += len(s.content)
        return out, data.get("reasoning", "routed")
    except Exception as exc:  # noqa: BLE001
        out, used = [], 0
        for s in all_sources.values():
            if used + len(s.content) > KNOWLEDGE_BUDGET_CHARS:
                break
            out.append(s); used += len(s.content)
        return out, f"router fallback ({exc})"


def run_review(provider: str, model: str, api_key: str, skills: list[Skill],
               selected: list[Source], project_desc: str, images_b64: list[str],
               page_texts: list[str], system_extra: str = "",
               track: dict | None = None, available_tracks: list[str] | None = None,
               base_url: str = "", jur: Jurisdiction | None = None) -> dict:
    jur = jur or _DEFAULT_JUR
    track = track or {"label": DEFAULT_TRACK_META["suites"]["label"],
                      "scope": DEFAULT_TRACK_META["suites"]["scope"]}
    knowledge_blob = "\n\n".join(
        [f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills] +
        [f"=== {s.key} ===\n{s.content}" for s in selected])[:KNOWLEDGE_BUDGET_CHARS + 40_000]
    avail = ", ".join(available_tracks) if available_tracks else track["label"]
    rules = build_critical_rules(track["label"], track["scope"], avail, jur)
    system = REVIEW_SYSTEM_TMPL.format(track_label=track["label"], jur_place=jur.place,
                                       critical_rules=rules, knowledge=knowledge_blob)
    if system_extra.strip():
        system = system_extra.strip() + "\n\n" + system

    vision = supports_vision(provider, model)
    user_text = (f"PROJECT DETAILS:\n{project_desc}\n\nToday's date: {date.today().isoformat()}.\n"
                 + ("Plan pages follow as images (page 1..n); " if vision
                    else "NOTE: this model has no vision β€” review from embedded text only; "
                         "mark all geometric/visual checks as information gaps; ")
                 + "embedded text extracted per page is appended.")
    user_text += "\n" + "\n".join(f"--- embedded text, page {i + 1} ---\n{t}"
                                  for i, t in enumerate(page_texts))
    raw = llm_call(provider, model, api_key, system, user_text,
                   images_b64=images_b64 if vision else None, max_tokens=8000,
                   base_url=base_url)
    result = _parse_json(raw)

    # Deterministic reconciliation: the verdict string must not contradict the
    # scope decision or the findings. The model sometimes keeps an "out of scope"
    # verdict out of habit even when it correctly kept the suite in scope and
    # raised the same-parcel issue as a finding.
    try:
        sc = result.get("submission_check", {}) or {}
        summ = result.setdefault("summary", {})
        # Recount severities from the findings themselves. Models miscount their own
        # output, and a review whose header disagrees with its own list is not usable.
        _sev = {"must-fix": 0, "clarify": 0, "advisory": 0}
        for _f in (result.get("findings") or []):
            _k = _f.get("severity")
            if _k in _sev:
                _sev[_k] += 1
        summ["must_fix"] = _sev["must-fix"]
        summ["clarify"] = _sev["clarify"]
        summ["advisory"] = _sev["advisory"]
        # The counters above are authoritative, but the verdict STRING is written
        # free-hand by the model and routinely carries a different number (it tends
        # to echo the safety-codes bucket size). A header that contradicts its own
        # counters two lines later is not usable, so any count embedded in the
        # verdict is rewritten in place β€” phrasing preserved, arithmetic corrected.
        _v = str(summ.get("verdict", "") or "")
        if _v:
            _fixed = re.sub(r"\b\d+\s+(?=must[-\s]?fix)", f"{_sev['must-fix']} ", _v,
                            flags=re.IGNORECASE)
            if _fixed != _v:
                summ["verdict"] = _fixed
                summ["_verdict_recount"] = (
                    f"verdict count corrected to {_sev['must-fix']} from the findings list")
        in_scope = sc.get("matches_declared_scope", True)
        n_find = len(result.get("findings", []) or [])
        verdict = str(summ.get("verdict", "") or "")
        # If the model produced findings, it reviewed the submission in scope.
        # A leftover matches_declared_scope=false is a habit-echo (e.g. reading a
        # same-parcel violation as a scope call) β€” reconcile it so the OUT OF
        # SCOPE banner does not contradict the findings.
        if n_find > 0 and not in_scope:
            sc["matches_declared_scope"] = True
            in_scope = True
            note = str(sc.get("note", "") or "")
            flag = " (in scope β€” the issue noted here is raised as a finding, not a scope rejection)"
            if "raised as a finding" not in note:
                sc["note"] = (note + flag).strip()
            result["submission_check"] = sc
        # Weak-model guard: some smaller models declare a REAL suite out of scope
        # AND emit zero findings (e.g. reading the same-parcel density rule as a
        # scope call). We cannot recover their findings, but we must not present a
        # false "out of scope β€” 0 findings" on a genuine suite. Detect the
        # fingerprint: out-of-scope + zero findings, but the model's OWN note
        # identifies the submission as a suite. Flag it as an incomplete/low-quality
        # run rather than a clean rejection, so the user re-runs on a stronger model.
        else:
            note_l = (str(sc.get("detected_project", "")) + " " + str(sc.get("note", ""))).lower()
            looks_like_suite = "suite" in note_l
            genuine_nonsuite = any(w in note_l for w in
                                   ("garage", "accessory building", "school", "apartment",
                                    "commercial", "industrial", "institutional", "not a suite"))
            if (not in_scope and n_find == 0 and looks_like_suite and not genuine_nonsuite):
                summ["verdict"] = ("inconclusive β€” this appears to be a suite submission but the "
                                   "selected model returned no findings and mis-flagged scope. "
                                   "Re-run on a stronger model (Claude, GPT-4.1, or Gemini) for a "
                                   "reliable review.")
                sc["note"] = (str(sc.get("note", "")).strip() +
                              " [Auto-flag: same-parcel or other suite-rule violations are FINDINGS, "
                              "not a scope rejection β€” this run under-performed; re-run recommended.]").strip()
                result["submission_check"] = sc
        if (in_scope or n_find > 0) and "out of scope" in verdict.lower():
            mf = summ.get("must_fix", n_find)
            summ["verdict"] = (f"revisions required β€” {mf} must-fix item(s)"
                               if n_find else "in scope β€” see submission check")
    except Exception:  # noqa: BLE001 β€” never let reconciliation break a review
        pass
    return result



# ── information gaps: submission vs knowledge base ──────────────────────────
# A gap in the SUBMISSION is actionable by the applicant and belongs in a
# City-facing letter. A gap in the LOADED KNOWLEDGE is a statement about this
# tool's own coverage β€” true, worth recording internally, and corrosive if it
# reaches an external document under "provided as a courtesy".
_KB_GAP_MARKERS = (
    "loaded knowledge", "knowledge base", "knowledge is not", "not fully populated",
    "not populated", "[verify]", "skill", "reference file", "not configured",
    "no skills", "knowledge does not", "not in the knowledge",
)


def split_information_gaps(gaps: list) -> tuple[list, list]:
    """Return (submission_gaps, knowledge_gaps).

    Heuristic fallback for models that emit a single flat `information_gaps`
    array. The schema now asks for the split explicitly; this keeps older or
    weaker model output from leaking tooling state into the letter.
    """
    sub, kb = [], []
    for g in gaps or []:
        t = str(g).lower()
        (kb if any(m in t for m in _KB_GAP_MARKERS) else sub).append(g)
    return sub, kb


def resolved_gaps(summary: dict) -> tuple[list, list]:
    """Prefer explicit model-supplied arrays, else split the flat one."""
    sub = summary.get("information_gaps_submission")
    kb = summary.get("information_gaps_knowledge")
    if sub is not None or kb is not None:
        return list(sub or []), list(kb or [])
    return split_information_gaps(summary.get("information_gaps") or [])


# ───────────────────────────────────────────────────────────── chat with DB ──

def _run_tool(con: sqlite3.Connection, call: dict,
              all_sources: list[Source]) -> str:
    tool = call.get("tool")
    if tool == "sql":
        q = (call.get("query") or "").strip().rstrip(";")
        if not re.match(r"(?is)^\s*(select|with)\b", q) or re.search(
                r"(?i)\b(insert|update|delete|drop|alter|create|attach|pragma)\b", q):
            return "ERROR: only read-only SELECT/WITH queries are allowed."
        try:
            cur = con.execute(q)
            cols = [d[0] for d in cur.description]
            rows = cur.fetchmany(60)
            return json.dumps({"columns": cols, "rows": rows, "row_count": len(rows)})
        except Exception as exc:  # noqa: BLE001
            return f"SQL ERROR: {exc}"
    if tool == "knowledge_search":
        q = (call.get("q") or "").lower()
        hits = []
        for s in all_sources:
            idx = s.content.lower().find(q)
            if q and idx >= 0:
                hits.append({"source": s.key,
                             "excerpt": s.content[max(0, idx - 200):idx + 500]})
            if len(hits) >= 5:
                break
        return json.dumps(hits or [{"note": "no matches"}])
    if tool == "page_text":
        try:
            row = con.execute("SELECT text FROM pages WHERE page=?",
                              (int(call.get("page", 0)),)).fetchone()
            return row[0] if row else "ERROR: page not found"
        except Exception as exc:  # noqa: BLE001
            return f"ERROR: {exc}"
    return f"ERROR: unknown tool {tool!r}"


def chat_turn(provider: str, model: str, api_key: str, system_prompt: str,
              con: sqlite3.Connection, skills: list[Skill], loose: list[Source],
              history: list[dict], user_msg: str, doc_context: str = "",
              max_iters: int = 4, base_url: str = "") -> tuple[str, list[dict]]:
    """Provider-agnostic tool loop. Returns (answer, tool_trace)."""
    all_sources = [x for sk in skills for x in sk.sources] + loose
    system = system_prompt.strip() + "\n" + CHAT_TOOL_PROTOCOL
    if doc_context:
        system += "\n\n=== REFERENCE DOCUMENTS ===\n" + doc_context[:20_000]

    convo = list(history)
    pending = user_msg
    trace = []
    for _ in range(max_iters):
        reply = llm_call(provider, model, api_key, system, pending,
                         history=convo, max_tokens=2500, base_url=base_url)
        try:
            call = _parse_json(reply)
            if not isinstance(call, dict) or "tool" not in call:
                raise ValueError
        except Exception:  # noqa: BLE001
            return reply, trace
        result = _run_tool(con, call, all_sources)
        trace.append({"call": call, "result": result[:400]})
        convo = convo + [{"role": "user", "content": pending},
                         {"role": "assistant", "content": reply}]
        pending = f"TOOL RESULT:\n{result[:6000]}\n\nContinue (call another tool as JSON, or answer in plain language)."
    return reply if isinstance(reply, str) else "Tool budget exhausted.", trace


# ──────────────────────────────────────────────────────────────── reporting ──

def render_report(result: dict, project_desc: str, routed_note: str,
                  selected: list[Source], pages_reviewed: int, total_pages: int,
                  provider: str, model: str, jur: Jurisdiction | None = None) -> str:
    jur = jur or _DEFAULT_JUR
    s = result.get("summary", {})
    lines = [
        f"# {jur.report_title}", "",
        f"*Generated {date.today().isoformat()} Β· {provider} / {model} Β· "
        f"pages reviewed: {pages_reviewed}/{total_pages} Β· knowledge routing: {routed_note}*", "",
    ]
    if result.get("_recovered"):
        lines += ["> ⚠️ **Partial output recovered.** The model's response was cut off "
                  "(token limit) and its JSON was truncated. The findings below were salvaged "
                  "from the complete portion; the last finding may be missing. Re-run for a full "
                  "review β€” a model with a smaller output (or fewer findings) usually completes cleanly.", ""]
    lines += [
        "## Project", project_desc.strip() or "_no description provided_", "",
        "## Submission Check",
        (lambda sc: (("⚠️ **OUT OF SCOPE β€” wrong review track.** " if not sc.get("matches_declared_scope", True) else "")
                     + f"Detected: {sc.get('detected_project', 'β€”')} Β· {sc.get('detected_use_or_district', 'β€”')}. "
                     + sc.get("note", "")
                     + (f" **Suggested track: {sc['suggested_track']}.**" if sc.get("suggested_track") else "")))(
                     result.get("submission_check", {})) or "β€”", "",
        "## Verdict", s.get("verdict", "β€”"), "",
        f"**Must-fix:** {s.get('must_fix', 0)} Β· **Clarify:** {s.get('clarify', 0)} Β· "
        f"**Advisory:** {s.get('advisory', 0)}", "",
    ]
    if s.get("transition_note"):
        lines += ["> **Zoning transition:** " + s["transition_note"], ""]
    lines += ["## Findings", ""]
    for f in result.get("findings", []):
        lines += [
            f"### {f.get('id', '?')} β€” {re.split(r'(?<=[^0-9])[.] ', f.get('description', ''))[0][:90]}",
            f"- **Page/Sheet:** {f.get('page', '?')} / {f.get('sheet_id', '?')} Β· "
            f"**Discipline:** {f.get('discipline', '?')} Β· **Category:** {f.get('category', '?')} Β· "
            f"**Severity:** {f.get('severity', '?')} Β· **Confidence:** {f.get('confidence', '?')}",
            f"- **Item:** {f.get('description', '')}",
            f"- **Citation:** {f.get('citation', '')}", "",
        ]
    sub_gaps, kb_gaps = resolved_gaps(s)
    if sub_gaps:
        lines += ["## Information Gaps β€” submission", "",
                  "Missing from the submitted set; actionable by the applicant.", ""]
        lines += [f"- {g}" for g in sub_gaps] + [""]
    if kb_gaps:
        lines += ["## Knowledge-base Limitations β€” internal", "",
                  "Coverage gaps in THIS TOOL's loaded knowledge, not deficiencies in the "
                  "submission. These are deliberately withheld from the City-facing letter; "
                  "they are the backlog for populating the reference files.", ""]
        lines += [f"- {g}" for g in kb_gaps] + [""]
    lines += ["## Knowledge Sources Used", "",
              *[f"- `{src.key}` ({src.origin})" for src in selected], "",
              "---",
              f"*{jur.report_footer}*"]
    return "\n".join(lines)


def findings_df_rows(result: dict) -> list[dict]:
    return [{"ID": f.get("id", ""), "Page": f.get("page", ""), "Sheet": f.get("sheet_id", ""),
             "Discipline": f.get("discipline", ""), "Category": f.get("category", ""),
             "Severity": f.get("severity", ""), "Confidence": f.get("confidence", ""),
             "Description": f.get("description", ""), "Citation": f.get("citation", "")}
            for f in result.get("findings", [])]


# ═══════════════════════════════════════════════════════════════════════════
#  FLOW 1 β€” CORRECTIONS RESPONSE (applicant-side)
#  Input: a municipal corrections / Detailed-Review letter (+ optionally the
#  plans for context). Output: an item-by-item interpretation grounded in the
#  loaded knowledge, plus a DRAFT response letter scaffolded with explicit
#  [APPLICANT: ...] placeholders. It NEVER fabricates that something was fixed.
# ═══════════════════════════════════════════════════════════════════════════

CORRECTIONS_SYSTEM_TMPL = """You are CrossBeam, assisting an applicant to interpret and respond to a municipal plan-review corrections letter for {jur_place}.

You are given: (a) the text of the corrections / Detailed-Review letter, and optionally (b) the applicant's plan pages for context. Your job is to help the applicant UNDERSTAND each correction and STRUCTURE a response β€” NOT to claim anything has been fixed.

CRITICAL HONESTY RULES:
- NEVER state or imply that a correction has been resolved, that a drawing has been revised, or that a value now complies. You do not know what the applicant will change. Every resolution belongs to the applicant and is represented by an [APPLICANT: ...] placeholder.
- GROUNDED CITATIONS ONLY: cite a {jur_safety_short} clause or {jur_landuse_short} section ONLY if that exact number appears in the KNOWLEDGE below. If the letter names a section the knowledge doesn't contain, echo the letter's own citation and mark governing_rule_source "from letter"; never invent a number.
- Distinguish what the CITY is asking from what the CODE requires from what the APPLICANT must do. Keep them separate.
- If a correction is ambiguous or needs information not present, say so in needs_from_applicant rather than guessing.
- Category each item: land-use, safety-codes, completeness (missing document/signature/form), or other.

KNOWLEDGE (authoritative β€” cite only what appears here):
{knowledge}

OUTPUT: respond with ONLY a JSON object, no markdown fences:
{{
  "letter_meta": {{"permit_number": "...", "municipality": "...", "review_type": "...", "date": "...", "contact": "..."}},
  "corrections": [
    {{"id": "C-01",
      "city_item": "<concise restatement of the city's correction, <=40 words>",
      "category": "land-use|safety-codes|completeness|other",
      "governing_rule": "<clause/section grounded in knowledge, or the letter's own citation>",
      "governing_rule_source": "knowledge|from letter|none",
      "interpretation": "<what the city is actually asking for, plainly>",
      "resolution_options": ["<option the applicant could take>", "..."],
      "needs_from_applicant": "<what the applicant must provide/change/confirm to resolve this>",
      "confidence": "high|medium|low"}}
  ],
  "summary": {{"total_items": 0, "by_category": {{"land-use": 0, "safety-codes": 0, "completeness": 0, "other": 0}}, "notes": "..."}}
}}"""


def run_corrections(provider: str, model: str, api_key: str, skills: list[Skill],
                    selected: list[Source], letter_text: str, plan_context: str = "",
                    images_b64: list[str] | None = None, system_extra: str = "",
                    base_url: str = "", jur: Jurisdiction | None = None) -> dict:
    """Interpret a corrections letter into grounded, per-item analysis."""
    jur = jur or _DEFAULT_JUR
    images_b64 = images_b64 or []
    knowledge_blob = "\n\n".join(
        [f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills] +
        [f"=== {s.key} ===\n{s.content}" for s in selected])[:KNOWLEDGE_BUDGET_CHARS + 40_000]
    system = CORRECTIONS_SYSTEM_TMPL.format(knowledge=knowledge_blob, **_jur_fields(jur))
    if system_extra.strip():
        system = system_extra.strip() + "\n\n" + system

    vision = supports_vision(provider, model) and images_b64
    user = ("CORRECTIONS / DETAILED-REVIEW LETTER (verbatim text):\n"
            + letter_text[:40_000]
            + (("\n\nAPPLICANT PLAN CONTEXT (extracted text, for your understanding "
                "of what the drawings currently show β€” do NOT assume anything is "
                "revised):\n" + plan_context[:20_000]) if plan_context else "")
            + "\n\nParse EVERY correction/condition/discrepancy in the letter into its own item.")
    raw = llm_call(provider, model, api_key, system, user,
                   images_b64=images_b64 if vision else None, max_tokens=8000,
                   base_url=base_url)
    result = _parse_json(raw)
    # deterministic backfill of the summary counts
    try:
        items = result.get("corrections", []) or []
        by = {"land-use": 0, "safety-codes": 0, "completeness": 0, "other": 0}
        for it in items:
            by[it.get("category", "other") if it.get("category") in by else "other"] += 1
        result.setdefault("summary", {})
        result["summary"]["total_items"] = len(items)
        result["summary"]["by_category"] = by
    except Exception:  # noqa: BLE001
        pass
    return result


def render_corrections_analysis(result: dict, jur: Jurisdiction | None = None) -> str:
    """Human-readable per-item analysis (Markdown)."""
    jur = jur or _DEFAULT_JUR
    m = result.get("letter_meta", {})
    s = result.get("summary", {})
    by = s.get("by_category", {})
    lines = [
        f"# Corrections Analysis β€” {jur.place}", "",
        f"*Permit {m.get('permit_number', 'β€”')} Β· {m.get('review_type', 'β€”')} Β· "
        f"received {m.get('date', 'β€”')}*", "",
        f"**{s.get('total_items', 0)} correction item(s)** β€” "
        f"land-use {by.get('land-use', 0)} Β· safety-codes {by.get('safety-codes', 0)} Β· "
        f"completeness {by.get('completeness', 0)} Β· other {by.get('other', 0)}", "",
        (s.get("notes", "") or ""), "",
        "## Item-by-item", "",
    ]
    for c in result.get("corrections", []):
        rule = c.get("governing_rule", "").strip()
        src = c.get("governing_rule_source", "")
        rule_line = (f" Β· **Rule:** {rule}" + (f" _({src})_" if src and src != "none" else "")) if rule else ""
        opts = c.get("resolution_options", []) or []
        lines += [
            f"### {c.get('id', '?')} β€” {c.get('category', '')}"
            f" Β· confidence {c.get('confidence', 'β€”')}",
            f"**City asks:** {c.get('city_item', '')}{rule_line}",
            "",
            f"**What this means:** {c.get('interpretation', '')}",
            "",
        ]
        if opts:
            lines.append("**Options to resolve:**")
            lines += [f"- {o}" for o in opts]
            lines.append("")
        lines += [f"**You must provide:** {c.get('needs_from_applicant', '')}", "", "---", ""]
    lines += ["*Draft interpretation for the applicant's use β€” not legal advice and not a "
              "sealed professional opinion. Verify every cited clause against the current "
              f"{jur.safety_short} / {jur.landuse_short} and confirm each resolution before "
              "submitting.*"]
    return "\n".join(lines)


def render_response_letter(result: dict, jur: Jurisdiction | None = None,
                           applicant_name: str = "", contact: str = "") -> str:
    """DRAFT response letter, scaffolded with [APPLICANT: ...] placeholders.

    Deliberately does NOT claim any correction is resolved β€” each item leaves an
    explicit placeholder for the applicant to describe their actual resolution.
    """
    jur = jur or _DEFAULT_JUR
    m = result.get("letter_meta", {})
    perm = m.get("permit_number", "[permit number]")
    lines = [
        f"# DRAFT Response Letter β€” {perm}", "",
        "> This is a scaffold. Replace every **[APPLICANT: …]** placeholder with your "
        "actual resolution and the revised sheet reference before sending. Do not send "
        "with placeholders remaining.", "",
        "---", "",
        f"To: {m.get('municipality', jur.place)} β€” Planning & Development",
        f"Re: {perm} β€” {m.get('review_type', 'Detailed Review')} β€” Response to corrections",
        f"Date: [date]", "",
        "Dear Reviewer,", "",
        "Thank you for the review comments. We respond to each item below and have "
        "included a revised drawing set with this resubmission.", "",
    ]
    for c in result.get("corrections", []):
        cid = c.get("id", "?")
        rule = c.get("governing_rule", "").strip()
        rule_txt = f" (re: {rule})" if rule else ""
        lines += [
            f"**{cid} β€” {c.get('city_item', '')}**{rule_txt}",
            f"[APPLICANT: describe how you resolved this β€” e.g. \"Revised {c.get('needs_from_applicant', 'the item')} "
            f"as shown on Sheet ___.\" If you disagree or seek relaxation, state your rationale here.]",
            "",
        ]
    lines += [
        "We trust the above and the revised drawings address the review comments. "
        "Please contact us with any further questions.", "",
        "Sincerely,",
        applicant_name or "[Applicant / Agent name]",
        contact or "[Contact]", "",
        "---",
        "*Draft generated for the applicant's use. The applicant is responsible for the "
        "accuracy of every statement and resolution before submission.*",
    ]
    return "\n".join(lines)


# ═══════════════════════════════════════════════════════════════════════════
#  FLOW 2 β€” PRE-SUBMISSION CHECKLIST (applicant-side)
#  Input: the chosen review track (+ optional project details). Output: what a
#  complete submission needs, drawn from the loaded skills for that track.
# ═══════════════════════════════════════════════════════════════════════════

CHECKLIST_SYSTEM_TMPL = """You are CrossBeam, generating a PRE-SUBMISSION checklist for a {track_label} in {jur_place}.

Produce a practical checklist of what a COMPLETE submission needs, drawn ONLY from the KNOWLEDGE below. Group items into: required drawings/sheets, required data/calculations to show on those sheets, and common review pitfalls for this project type. Where the knowledge gives a specific threshold or rule an applicant commonly misses, include it as a pitfall with its grounded citation.

GROUNDED ONLY: cite a clause/section number ONLY if it appears in the KNOWLEDGE. Otherwise describe the requirement without a number. Do not invent requirements not supported by the knowledge; if the knowledge is thin on completeness, say so in notes.

KNOWLEDGE:
{knowledge}

OUTPUT: respond with ONLY a JSON object, no markdown fences:
{{
  "required_drawings": ["<sheet/drawing a complete set includes>", "..."],
  "required_data": ["<data/calculation/label that must appear>", "..."],
  "common_pitfalls": [{{"item": "<what applicants commonly miss>", "citation": "<grounded or ''>"}}],
  "notes": "<caveats, especially where knowledge is incomplete>"
}}"""


def run_checklist(provider: str, model: str, api_key: str, skills: list[Skill],
                  track: dict, project_desc: str = "", base_url: str = "",
                  jur: Jurisdiction | None = None) -> dict:
    """Generate a pre-submission checklist for a track from its skills."""
    jur = jur or _DEFAULT_JUR
    knowledge_blob = "\n\n".join(
        [f"=== {sk.name}/SKILL.md ===\n{sk.skill_md}" for sk in skills] +
        [f"=== {s.key} ===\n{s.content}" for sk in skills for s in sk.sources]
    )[:KNOWLEDGE_BUDGET_CHARS]
    system = CHECKLIST_SYSTEM_TMPL.format(track_label=track.get("label", "project"),
                                          knowledge=knowledge_blob, **_jur_fields(jur))
    user = (f"Project type: {track.get('label', '')}\n"
            f"Project details (optional): {project_desc or 'not provided'}\n"
            "Generate the pre-submission checklist from the knowledge above.")
    raw = llm_call(provider, model, api_key, system, user, max_tokens=3000, base_url=base_url)
    return _parse_json(raw)


def render_checklist(result: dict, track_label: str, jur: Jurisdiction | None = None) -> str:
    jur = jur or _DEFAULT_JUR
    lines = [f"# Pre-Submission Checklist β€” {track_label}", f"*{jur.place}*", ""]
    if result.get("required_drawings"):
        lines += ["## Required drawings / sheets", ""]
        lines += [f"- [ ] {d}" for d in result["required_drawings"]] + [""]
    if result.get("required_data"):
        lines += ["## Data & calculations to show", ""]
        lines += [f"- [ ] {d}" for d in result["required_data"]] + [""]
    if result.get("common_pitfalls"):
        lines += ["## Common review pitfalls", ""]
        for p in result["common_pitfalls"]:
            cite = p.get("citation", "").strip()
            lines.append(f"- [ ] {p.get('item', '')}" + (f" β€” _{cite}_" if cite else ""))
        lines.append("")
    if result.get("notes"):
        lines += ["---", f"*{result['notes']}*"]
    return "\n".join(lines)


# ═══════════════════════════════════════════════════════════════════════════
#  CITY REVIEW LETTER FORMAT
#  Mirrors a municipal Detailed Review: header block, General Comments, a
#  Bylaw Discrepancies table (Regulation | Standard | Provided), Prior to
#  Decision Requirements, then Advisory Comments.
# ═══════════════════════════════════════════════════════════════════════════

def check_application_date(entered: str, page_texts: list[str]) -> str:
    """Cross-check the entered application date against dates in the drawings.

    The entered date drives every district/transition conclusion, so a typo silently
    changes the legal framing. Returns a warning string, or "" when consistent.
    Deterministic β€” no LLM.
    """
    if not entered:
        return ""
    m = re.match(r"(\d{4})-(\d{2})-(\d{2})", entered.strip())
    if not m:
        return ""
    entered_year = m.group(1)
    blob = "\n".join(page_texts or [])
    # Dates in title blocks: YYYY-MM-DD, DD-MM-YY, or a bare 4-digit year near
    # "ISSUED"/"REVISION"/permit numbers such as DP2024-00595.
    years = set(re.findall(r"\b(20[0-9]{2})-[01]?\d-[0-3]?\d\b", blob))
    years |= {y for y in re.findall(r"\b(?:DP|BP)(20[0-9]{2})-\d+", blob)}
    years = {y for y in years if y.isdigit()}
    if not years or entered_year in years:
        return ""
    found = ", ".join(sorted(years))
    return (f"⚠️ Application date check: you entered **{entered}**, but the submitted "
            f"documents reference year(s) **{found}**. The application date drives every "
            f"district and transition conclusion in this review β€” confirm it is correct "
            f"before relying on the findings.")


def render_city_letter(result: dict, project_desc: str, pages_reviewed: int,
                       total_pages: int, provider: str, model: str,
                       jur: Jurisdiction | None = None,
                       date_warning: str = "", site_address: str = "",
                       applicant: str = "", permit_number: str = "") -> str:
    """Render the review in a municipal Detailed-Review letter format."""
    jur = jur or _DEFAULT_JUR
    sc = result.get("submission_check", {}) or {}
    s = result.get("summary", {}) or {}
    findings = result.get("findings", []) or []

    land_use = [f for f in findings if f.get("category") == "land-use"]
    safety = [f for f in findings if f.get("category") == "safety-codes"]
    reviewer = [f for f in findings if f.get("category") == "reviewer-item"]
    must_fix = [f for f in findings if f.get("severity") == "must-fix"]

    district = sc.get("land_use_district") or sc.get("detected_use_or_district") \
        or "not shown on the submitted plans"
    use_type = sc.get("use_type") or "not determinable without the district"

    L = [f"# Detailed Review β€” {jur.place}", ""]
    if date_warning:
        L += [f"> {date_warning}", ""]
    L += [
        "| | |",
        "|---|---|",
        f"| **Application Number** | {permit_number or 'β€”'} |",
        f"| **Application Description** | {sc.get('detected_project', 'β€”')} |",
        f"| **Land Use District** | {district} |",
        f"| **Use Type** | {use_type} |",
        f"| **Site Address** | {site_address or 'β€”'} |",
        f"| **Applicant** | {applicant or 'β€”'} |",
        f"| **Review generated** | {date.today().isoformat()} Β· {provider} / {model} Β· "
        f"pages {pages_reviewed}/{total_pages} |",
        "", "## General Comments", "",
    ]

    verdict = str(s.get("verdict", "")).strip()
    # A Detailed Review is a DEVELOPMENT PERMIT document. Its support decision and
    # its prior-to-decision conditions are land-use determinations; NBC items are
    # resolved at building permit stage and are reported separately below. Mixing
    # them made the letter condition the DP on items it had just declared out of
    # the DP stream.
    dp_must_fix = [f for f in must_fix if f.get("category") != "safety-codes"]
    safety_must_fix = [f for f in must_fix if f.get("category") == "safety-codes"]
    supported = not dp_must_fix
    lead = ("In general, the proposed development is **not supported as submitted**."
            if not supported else
            "In general, the proposed development appears **supportable subject to the "
            "conditions below**.")
    L += [lead, ""]
    if not supported:
        themes = sorted({(f.get("regulation") or f.get("citation") or "").split("β€”")[0].strip()
                         for f in dp_must_fix if f.get("category") == "land-use"})
        themes = [t for t in themes if t][:6]
        if themes:
            L += ["The submission does not meet the minimum requirements for the following "
                  "regulations: " + ", ".join(themes) + ".", ""]
    if safety_must_fix:
        L += [f"Separately, {len(safety_must_fix)} outstanding Building Code item(s) are "
              f"listed under Building/Safety Codes below. Those are resolved at building "
              f"permit stage and do not form part of this decision.", ""]
    _v = verdict[:1].upper() + verdict[1:] if verdict else ""
    L += [("Please review the bylaw discrepancies below." + (f" {_v}" if _v else "")), ""]
    if sc.get("note"):
        L += [sc["note"], ""]
    if s.get("transition_note"):
        L += [f"*{s['transition_note']}*", ""]

    # ── Bylaw Discrepancies (land-use) ──
    L += ["## Bylaw Discrepancies", ""]
    if land_use:
        L += ["| Regulation | Standard | Provided |", "|---|---|---|"]

        def _reg_label(f: dict) -> str:
            """A clean Regulation cell.

            Prefer the explicit `regulation` field. Never print a knowledge-file path
            in this column β€” if a finding only cites a reference file (e.g. the
            district-identification precondition), give it a readable heading instead.
            """
            reg = (f.get("regulation") or "").strip()
            if reg and ".md" not in reg and "/" not in reg:
                return reg
            cite = (f.get("citation") or "").strip()
            if cite and ".md" not in cite and "/" not in cite:
                return cite
            text = (f.get("description", "") + " " + cite).lower()
            if "land use district" in text or "district" in text:
                return "Land Use District (to be identified)"
            return "Application completeness"

        for f in land_use:
            reg = _reg_label(f).replace("|", "/")
            std = (f.get("standard") or "").replace("|", "/").replace("\n", " ")
            prov = (f.get("provided") or f.get("description", "")).replace("|", "/").replace("\n", " ")
            sev = f.get("severity", "")
            if sev and sev != "must-fix":
                prov += f"  _({sev})_"
            L.append(f"| **{reg}** | {std or 'β€”'} | {prov} |")
        L.append("")
    else:
        L += ["No land-use discrepancies were identified from the submitted set.", ""]

    # ── Prior to Decision ──
    L += ["## Prior to Decision Requirements", "",
          "The following must be addressed by the Applicant through a written submission "
          "and amended plans prior to a decision:", ""]
    n = 1
    L += [f"{n}. Submit a complete set of amended plans, in PDF format. Ensure that all "
          f"plans affected by the revisions are amended accordingly."]
    n += 1
    L += [f"{n}. Submit a written response providing a point-by-point explanation of how "
          f"each item below was addressed and/or resolved."]
    n += 1
    if district.lower().startswith("not shown"):
        L += [f"{n}. **Identify the parcel's Land Use District** on the plans and confirm "
              f"the Use Type (Permitted/Discretionary) for the proposed suite. Every "
              f"district-dependent conclusion in this review is unconfirmed until this is "
              f"provided."]
        n += 1
    for f in dp_must_fix:
        reg = f.get("regulation") or f.get("citation") or ""
        tag = f" ({reg})" if reg else ""
        L += [f"{n}. {f.get('description', '').strip()}{tag}"]
        n += 1
    if not dp_must_fix:
        L += [f"{n}. No land-use discrepancies require resolution prior to a decision."]
        n += 1
    if safety_must_fix:
        L += [f"{n}. Address the {len(safety_must_fix)} Building Code item(s) listed under "
              f"**Building/Safety Codes** below through the building permit application. "
              f"They are not conditions of this development permit decision."]
        n += 1
    L.append("")

    # ── Safety-codes / building-permit stream ──
    if safety:
        L += ["## Building/Safety Codes β€” separate permit stream", "",
              f"The following {len(safety)} item(s) fall under the "
              f"{jur.safety_framework} and are reviewed at building permit stage, not as "
              "land-use discrepancies:", ""]
        for f in safety:
            L += [f"- **{f.get('citation', 'β€”')}** β€” {f.get('description', '').strip()} "
                  f"_({f.get('severity', '')})_"]
        L.append("")

    if reviewer:
        L += ["## Reviewer / Engineer-of-Record Items", ""]
        for f in reviewer:
            L += [f"- {f.get('description', '').strip()}"]
        L.append("")

    # Only SUBMISSION gaps reach the applicant. Knowledge-base gaps are recorded in
    # the internal report instead β€” telling a City reviewer that this tool's own
    # standards table is unpopulated is not a courtesy, it is noise they cannot act on.
    sub_gaps, _kb_gaps = resolved_gaps(s)
    if sub_gaps:
        L += ["## Advisory Comments", "",
              "The comments below represent some, but not all, of the requirements that "
              "must be complied with. They are provided as a courtesy:", ""]
        L += [f"- {g}" for g in sub_gaps]
        L.append("")

    L += ["---", f"*{jur.report_footer}*"]
    return "\n".join(L)


# ═══════════════════════════════════════════════════════════════════════════
#  ADDRESS β†’ LAND USE DISTRICT
#  The City's own workflow: read the address, look up the land use map, apply
#  that district's standards. Address extraction is deterministic and offline.
#  The district lookup is BEST-EFFORT over the municipality's open-data API and
#  degrades to a manual-lookup link β€” a review must never silently depend on it.
# ═══════════════════════════════════════════════════════════════════════════

_ADDR_RE = re.compile(
    r"\b(\d{1,5}(?:\s*-\s*\d{1,5})?\s+[A-Z0-9][A-Za-z0-9'\.\- ]{2,40}?"
    r"\s+(?:ST|STREET|AVE|AVENUE|RD|ROAD|DR|DRIVE|WY|WAY|CR|CRES|CRESCENT|BV|BLVD|"
    r"BOULEVARD|PL|PLACE|CL|CLOSE|GATE|GA|LN|LANE|TR|TRAIL|CI|CIRCLE|MR|MANOR|"
    r"HT|HEIGHTS|PT|POINT|GV|GROVE|BAY|LD|LANDING|PK|PARK|GD|GARDENS|VW|VIEW|"
    r"TC|TERRACE|CO|COURT|SQ|SQUARE|ME|MEWS|RI|RISE|HL|HILL|CM|COMMON|PS|PASSAGE)"
    r"\.?\s*(?:N\.?E\.?|N\.?W\.?|S\.?E\.?|S\.?W\.?|N|S|E|W)?)\b",
    re.IGNORECASE)


def guess_site_address(page_texts: list[str]) -> str:
    """Best-effort site address from the drawing text (title block / site plan).

    Deterministic, offline. Returns "" when nothing address-like is found β€” the
    caller should then ask the user rather than guessing.
    """
    blob = "\n".join(page_texts or [])
    counts: dict[str, int] = {}
    for m in _ADDR_RE.finditer(blob):
        a = re.sub(r"\s+", " ", m.group(1)).strip().rstrip(",")
        if len(a) < 8 or a.lower().startswith(("sheet", "scale", "page")):
            continue
        counts[a] = counts.get(a, 0) + 1
    if not counts:
        return ""
    # Title blocks repeat the project address on every sheet β€” prefer the most common,
    # then the longest (more complete) form.
    return sorted(counts.items(), key=lambda kv: (-kv[1], -len(kv[0])))[0][0]


def lookup_land_use_district(address: str, jur: Jurisdiction | None = None,
                             timeout: float = 6.0) -> dict:
    """Best-effort land use district lookup for an address.

    Reads the municipality's `district_lookup` config (see jurisdiction.yaml). If no
    API is configured, or the call fails/times out, returns a result whose
    `manual_url` tells the reviewer where to look it up themselves. NEVER raises, and
    NEVER guesses a district β€” an unavailable lookup is reported as unavailable.

    Returns: {"district": str, "source": str, "manual_url": str, "note": str}
    """
    jur = jur or _DEFAULT_JUR
    cfg = jur.data.get("district_lookup") or {}
    manual = cfg.get("manual_url", "")
    out = {"district": "", "source": "", "manual_url": manual, "note": ""}
    if not address:
        out["note"] = "No site address supplied β€” cannot look up a district."
        return out
    api = cfg.get("api_url", "")
    if not cfg.get("enabled") or not api:
        out["note"] = ("Automated district lookup is not configured for this "
                       "municipality. Look the district up manually and enter it.")
        return out
    try:
        import requests
        field = cfg.get("address_field", "address")
        dfield = cfg.get("district_field", "land_use_designation")
        r = requests.get(api, params={"$where": f"upper({field}) like upper('%{address}%')",
                                      "$limit": 5}, timeout=timeout)
        if r.status_code != 200:
            out["note"] = (f"District lookup returned HTTP {r.status_code}. "
                           f"Verify manually.")
            return out
        rows = r.json() if r.content else []
        vals = {str(row.get(dfield, "")).strip() for row in rows if row.get(dfield)}
        vals.discard("")
        if len(vals) == 1:
            out["district"] = vals.pop()
            out["source"] = api
            out["note"] = ("Retrieved from the municipality's open-data service β€” "
                           "confirm it is the designation in force on the application "
                           "date before relying on it.")
        elif len(vals) > 1:
            out["note"] = (f"Address matched multiple parcels with differing "
                           f"designations ({', '.join(sorted(vals))}). Confirm manually.")
        else:
            out["note"] = "No matching parcel found. Confirm manually."
    except Exception as exc:  # noqa: BLE001 β€” lookup must never break a review
        out["note"] = f"District lookup unavailable ({type(exc).__name__}). Verify manually."
    return out