File size: 59,796 Bytes
0136798
dc1b199
0136798
 
3f046da
0136798
3f046da
0136798
 
837762d
 
0136798
 
b76f199
62f2173
dc1b199
 
3f6fdc5
49f0cfb
3f6fdc5
dc1b199
faa8fb3
dc1b199
3f6fdc5
 
0136798
3f046da
0136798
 
3f046da
0136798
 
 
 
 
 
 
 
 
3f046da
0136798
 
 
 
b76f199
0b42403
 
 
 
 
 
 
 
0136798
0b42403
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b42403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
0b42403
 
295512e
0b42403
 
 
295512e
0b42403
 
 
 
 
 
 
295512e
0b42403
 
 
295512e
0b42403
 
295512e
 
 
 
 
 
0b42403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
295512e
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b42403
 
5fca0ca
 
0b42403
 
 
 
 
 
 
 
 
5fca0ca
0b42403
5fca0ca
0b42403
5fca0ca
0b42403
5fca0ca
0b42403
 
5fca0ca
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
 
295512e
 
 
 
 
 
 
 
 
 
 
b76f199
295512e
b76f199
295512e
 
 
 
879e4e0
 
 
 
 
 
295512e
 
 
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
 
5fca0ca
879e4e0
295512e
 
879e4e0
5fca0ca
 
 
 
 
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
295512e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fca0ca
295512e
 
 
 
 
 
 
 
 
 
5fca0ca
 
 
 
 
295512e
 
 
 
5fca0ca
 
295512e
 
 
 
b76f199
0136798
b76f199
 
 
0136798
 
 
 
 
 
 
377c1bc
0136798
dc1b199
 
3f046da
0136798
 
b76f199
 
 
 
 
 
 
 
 
0136798
b410119
0136798
b76f199
 
377c1bc
 
b76f199
 
62f2173
6e02326
0136798
 
 
6036e5e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
6036e5e
 
295512e
6036e5e
 
295512e
6036e5e
 
 
295512e
 
 
 
 
 
 
 
 
 
 
 
6e02326
6036e5e
 
 
0136798
b76f199
 
 
0136798
 
 
3f046da
0136798
 
b76f199
 
 
 
 
 
 
 
 
0136798
b410119
0136798
b76f199
 
 
62f2173
6e02326
0136798
 
 
6036e5e
 
 
 
 
 
 
 
 
 
295512e
6036e5e
 
295512e
6036e5e
 
295512e
6036e5e
 
 
295512e
 
 
 
 
 
 
 
 
 
 
6e02326
6036e5e
 
 
b76f199
837762d
49f0cfb
b76f199
837762d
49f0cfb
 
dc1b199
0136798
b76f199
62f2173
 
 
 
 
 
 
 
 
 
 
da8a68d
b76f199
 
 
 
 
62f2173
b76f199
62f2173
 
da8a68d
 
 
 
 
 
879e4e0
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62f2173
da8a68d
62f2173
da8a68d
62f2173
da8a68d
 
 
 
 
879e4e0
 
 
 
 
da8a68d
 
 
 
 
 
879e4e0
da8a68d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62f2173
 
da8a68d
62f2173
da8a68d
 
 
 
 
879e4e0
 
 
 
 
b76f199
 
3f046da
0136798
 
 
 
 
 
 
 
3f046da
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
0136798
3f046da
0136798
 
 
3f046da
0136798
 
 
 
 
b76f199
62f2173
0136798
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
dc1b199
 
837762d
dc1b199
 
0136798
 
 
 
 
 
 
62f2173
0136798
3f046da
dc1b199
 
 
3f046da
0136798
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295512e
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
295512e
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
b76f199
 
faa8fb3
0136798
b76f199
 
 
 
 
 
 
6036e5e
295512e
879e4e0
dc1b199
b76f199
 
 
 
 
0136798
b76f199
 
 
 
 
 
 
295512e
b76f199
295512e
b76f199
0136798
b76f199
879e4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
0136798
377c1bc
 
 
6036e5e
 
b76f199
0136798
 
 
 
 
 
377c1bc
0136798
 
b76f199
 
 
 
 
 
879e4e0
dc1b199
6036e5e
 
b76f199
0136798
 
b76f199
 
 
 
 
 
879e4e0
dc1b199
 
837762d
49f0cfb
 
b76f199
 
faa8fb3
837762d
b76f199
 
 
 
 
 
 
6036e5e
295512e
879e4e0
49f0cfb
837762d
49f0cfb
879e4e0
837762d
 
 
 
 
 
 
b76f199
 
 
 
 
 
 
6036e5e
295512e
879e4e0
295512e
49f0cfb
 
 
0136798
 
 
faa8fb3
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
0136798
 
 
 
 
 
faa8fb3
0136798
 
 
 
 
 
dc1b199
0136798
 
 
 
 
dc1b199
0136798
 
 
dc1b199
 
 
 
 
3f6fdc5
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
3f6fdc5
 
 
 
 
 
dc1b199
 
 
 
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
"""Prompt-building helpers for all three AI generation modes.

Three distinct prompt strategies are supported:

* **generate** β€” Style-aware RAG generation. Injects a ``WritingStyleProfile``
  so the LLM adapts content to the user's detected writing voice.
* **proofread** β€” Reviews generated text for grammar, clarity, and style
  consistency with the user's profile.  Returns the corrected text with an
  inline annotation block.
* **enhance** β€” Expands the generated text using additional technical detail
  from passages retrieved from the user's own uploaded documents (tenant-scoped).

All prompts enforce the non-invention guarantee: only facts present in the
supplied BULLETS and EVIDENCE blocks may be stated. If information is missing,
the model must omit unsupported claims instead of printing placeholder text.
"""

import tiktoken
from langchain_core.prompts import ChatPromptTemplate

from app.chunking.splitter import count_tokens
from app.models.schemas import WritingStyleProfile

_ENCODING = tiktoken.get_encoding("cl100k_base")


# --- GENERATE mode ---

_UK_ENGLISH_RULE = (
    "STRICT BRITISH ENGLISH ONLY β€” this is a UK RICS report. "
    "You MUST use British spellings at all times. "
    "NEVER use American spellings. "
    "Critical examples: "
    "colour (not color), centre (not center), storey/storeys (not story/stories for building floors), "
    "metre/metres (not meter/meters for measurements), aluminium (not aluminum), "
    "mould (not mold), analyse (not analyze), recognise (not recognize), "
    "organise (not organize), utilise (not utilize), realise (not realize), "
    "programme (not program), licence (noun, not license), grey (not gray), "
    "draught (not draft for air), kerb (not curb), neighbouring (not neighboring), "
    "behaviour (not behavior), fibre (not fiber), insulation (correct in both β€” no change needed). "
    "Use '-ise' suffixes, not '-ize'. "
    "If you detect you have used an American spelling, correct it before outputting."
)

_BASE_SYSTEM_PROMPT = """\
You are an expert RICS surveyor and professional report writer producing section text \
by EXTENDING a base of woven standards + raw notes. \
HIGH AI INVOLVEMENT (68–100%) β€” FULL PROFESSIONAL ELABORATION: \
\
The grounded base is the structural-router output: STANDARD SOURCE PASSAGES \
(retrieved from the firm's templates) with the inspector's NOTES facts woven into \
their slots. Build your section text on top of this base. \
\
Rules: \
(1) The base wording (woven standards + notes) should still be discernible inside your \
output β€” readers should be able to trace each property-specific claim back to a NOTE \
and each standard professional phrase back to a STANDARD passage. \
(2) You MAY elaborate freely β€” diagnostic narrative, mechanism explanation, repair \
options, recommendations, implications. Every elaboration must remain consistent with \
the context (STANDARDS) and the inspector's observations (NOTES) β€” do NOT escape that \
envelope by introducing new property-specific facts, causes, or claims. \
(3) Preserve ALL exact numbers, measurements, dates, postcodes, and addresses exactly \
as given in the notes. Property-specific facts come ONLY from NOTES. \
(4) If a fact is missing or cannot be verified from notes/evidence, omit that \
unsupported claim β€” do not invent it. \
(5) """ + _UK_ENGLISH_RULE + """ \
(6) Output plain text only β€” no markdown and no bullet lists. Use subsection headings \
only if they appear in the SECTION SKELETON.\
"""

SYSTEM_PROMPT_L1 = (
    _BASE_SYSTEM_PROMPT
    + "\n\n"
    + "RICS SURVEY LEVEL MODE: LEVEL 1 (Condition Report) β€” OBSERVATION MODE.\n"
    + "You are recording condition and condition ratings; you are NOT advising on repairs.\n"
    + "STRICT RULES FOR LEVEL 1:\n"
    + "- Do NOT give repair solutions, options, or maintenance advice.\n"
    + "- Do NOT use directive/advice phrasing (e.g. 'we recommend', 'you should', 'should be repaired', 'repair', 'replace').\n"
    + "- Keep the paragraph concise and factual; focus on what was seen and the condition/limitations.\n"
)

SYSTEM_PROMPT_L2 = (
    _BASE_SYSTEM_PROMPT
    + "\n\n"
    + "RICS SURVEY LEVEL MODE: LEVEL 2 (Home Survey / HomeBuyer-style) β€” ADVICE MODE.\n"
    + "You are helping a buyer make an informed decision with practical, proportionate advice.\n"
    + "STRICT RULES FOR LEVEL 2:\n"
    + "- Include practical next steps where supported by notes/evidence (e.g. obtain quotations, further checks).\n"
    + "- Provide moderate explanation, but avoid deep diagnostic speculation unless supported.\n"
)

SYSTEM_PROMPT_L3 = (
    _BASE_SYSTEM_PROMPT
    + "\n\n"
    + "RICS SURVEY LEVEL MODE: LEVEL 3 (Building Survey) β€” DIAGNOSTIC MODE.\n"
    + "You are providing building-expert explanation consistent with a Building Survey.\n"
    + "STRICT RULES FOR LEVEL 3:\n"
    + "- For any material defect discussed, include (within one flowing paragraph):\n"
    + "  (a) what was observed, (b) likely cause/mechanism (only if supported), (c) implications/risks if unaddressed, and (d) options/next steps.\n"
    + "- Use professional, technical language; do not collapse into HomeBuyer-level brevity.\n"
)

_LEVEL1_APPEND = (
    "RICS SURVEY LEVEL MODE: LEVEL 1 (Condition Report) β€” OBSERVATION MODE.\n"
    "You are recording condition and condition ratings; you are NOT advising on repairs.\n"
    "STRICT RULES FOR LEVEL 1:\n"
    "- Do NOT give repair solutions, options, or maintenance advice.\n"
    "- Do NOT use directive/advice phrasing (e.g. 'we recommend', 'you should', 'should be repaired', 'repair', 'replace').\n"
    "- Keep the paragraph concise and factual; focus on what was seen and the condition/limitations.\n"
)

_LEVEL2_APPEND = (
    "RICS SURVEY LEVEL MODE: LEVEL 2 (Home Survey / HomeBuyer-style) β€” ADVICE MODE.\n"
    "You are helping a buyer make an informed decision with practical, proportionate advice.\n"
    "STRICT RULES FOR LEVEL 2:\n"
    "- Include practical next steps where supported by notes/evidence (e.g. obtain quotations, further checks).\n"
    "- Provide moderate explanation, but avoid deep diagnostic speculation unless supported.\n"
)

_LEVEL3_APPEND = (
    "RICS SURVEY LEVEL MODE: LEVEL 3 (Building Survey) β€” DIAGNOSTIC MODE.\n"
    "You are providing building-expert explanation consistent with a Building Survey.\n"
    "STRICT RULES FOR LEVEL 3:\n"
    "- For any material defect discussed, include (within one flowing paragraph):\n"
    "  (a) what was observed, (b) likely cause/mechanism (only if supported), (c) implications/risks if unaddressed, and (d) options/next steps.\n"
    "- Use professional, technical language; do not collapse into HomeBuyer-level brevity.\n"
)

_ASSEMBLY_SYSTEM_CORE = """\
You are a STRUCTURAL ROUTER. Your job is to take the firm's STANDARD SOURCE PASSAGES \
(RAG-retrieved boilerplate from approved templates) and the INSPECTOR'S RAW NOTES \
(observations from this specific property), and produce the standard passages with \
the inspector's note-specific facts woven into the appropriate slots. \
\
ASSEMBLY MODE (AI INVOLVEMENT 0–12%) β€” STRUCTURAL ROUTING ONLY: \
\
1. THE STANDARD PASSAGES define the wording and structure. Their phrasing IS the \
deliverable. Keep the structural sentence frame intact. \
\
2. THE INSPECTOR'S RAW NOTES define the property-specific content (locations, \
materials, conditions, defects, observations). These details MUST appear in the output, \
woven directly into the relevant standard sentences. \
\
3. WEAVING β€” for each STANDARD sentence, find the matching note (if any) and substitute \
the note's specific facts into the sentence's generic slots. Examples: \
- Standard: "Minor cracking was observed to the external render." \
  Note: "minor cracking to render at front elevation" \
  Output: "Minor cracking was observed to the render at the front elevation." \
- Standard: "A failed sealed unit was identified where misting between panes was observed." \
  Note: "failed seal unit in rear bedroom, misted" \
  Output: "A failed sealed unit was identified in the rear bedroom window where misting \
  between panes was observed." \
\
4. ABSOLUTE BANS: \
- DO NOT paraphrase the standard wording. Do not replace standard words with synonyms. \
- DO NOT add new sentences that aren't grounded in a STANDARD passage. \
- DO NOT add diagnostic narrative, causes, recommendations, or implications that go beyond \
  what the STANDARDS or NOTES already state. \
- DO NOT invent property-specific facts (locations, conditions, materials) that are not \
  in the NOTES. If a fact isn't in the notes, omit the slot rather than inventing one. \
- DO NOT generate creative wording from your training data, even if it sounds more polished. \
\
5. ALLOWED OPERATIONS: \
- Substitute note-specific facts into the appropriate generic slots in standard sentences. \
- Light grammar adjustments (verb tense, articles, prepositions) only as needed to keep the \
  woven sentence readable. \
- Drop a STANDARD sentence if it is irrelevant to the notes for this section. \
- If a note doesn't fit any standard sentence's slot, append it as a clause on the closest \
  related standard sentence β€” do NOT create a new free-standing sentence. \
- A UK-spelling correction of an American spelling that appears in a source. \
\
6. PROPERTY-SPECIFIC FACTS (addresses, postcodes, names, dates, prices, condition ratings, \
dimensions, ages, materials) come ONLY from RAW NOTES. The STANDARD PASSAGES are example \
reports for OTHER properties β€” their wording is reusable, their facts are NOT. \
\
7. STRUCTURE: follow the SECTION SKELETON. Do not invent new headings or sections. \
\
""" + _UK_ENGLISH_RULE + """ \
Output plain text only β€” no markdown and no bullet lists unless the skeleton requires headings.\
"""

_LOW_INVOLVEMENT_CORE = """\
You produce RICS section text by EXTENDING a base of woven standards + raw notes. \
LOW AI INVOLVEMENT (13–37%) β€” GROUNDED EXTENSION: \
\
1. THE BASE: produce the same structural-router output an ASSEMBLY-mode (0%) call would \
produce β€” STANDARD SOURCE PASSAGES with the inspector's NOTES facts woven into their slots, \
keeping the standard wording intact. This base must appear in your output. \
\
2. THE EXTENSION: you MAY add at most one short clause per major topic on top of the base β€” \
typically a brief implication, monitoring note, or minor recommendation. Each extension must: \
(a) follow directly from what the STANDARDS or NOTES already state β€” never introduce new \
property-specific facts, causes, or claims; \
(b) be framed as a professional observation (e.g. "...which may require monitoring", \
"...consistent with normal wear and tear"); \
(c) be under ~15 words. \
\
3. ABSOLUTE BANS still apply: do NOT paraphrase the standard wording, do NOT replace standard \
words with synonyms, do NOT invent property-specific facts, do NOT add diagnostic speculation \
that goes beyond the context+notes envelope. \
\
4. PROPERTY-SPECIFIC FACTS (addresses, postcodes, names, dates, prices, dimensions, ages, \
materials) come ONLY from RAW NOTES. Never lift them from STANDARD passages. \
\
""" + _UK_ENGLISH_RULE + """ \
Output plain text only β€” no markdown bullet lists.\
"""

_MID_INVOLVEMENT_CORE = """\
You are an expert RICS surveyor producing section text by EXTENDING a base of woven \
standards + raw notes. MID AI INVOLVEMENT (38–67%) β€” BALANCED ELABORATION: \
\
1. THE BASE: include the structural-router output (STANDARD passages with NOTES facts \
woven in) as the backbone of your text. Keep the standard sentences identifiable. \
\
2. THE ELABORATION: you MAY add up to one short paragraph of professional elaboration per \
major topic β€” diagnostic narrative, recommendations, implications β€” that builds on the base. \
Every claim must be supported by the STANDARDS or NOTES. Stay strictly inside the \
context+notes envelope; do NOT introduce facts, causes, or property-specific claims that \
neither the standards nor the notes support. \
\
3. Light paraphrasing of standard wording is allowed only when needed to weave the notes' \
specifics in cleanly; the standard sentence's meaning and technical terminology must remain. \
\
4. PROPERTY-SPECIFIC FACTS still come ONLY from RAW NOTES, never from STANDARD passages. \
\
""" + _UK_ENGLISH_RULE + """ \
Output plain text only β€” no markdown bullet lists unless the skeleton includes headings.\
"""


def _coerce_ai_percent(pct: int | None) -> int:
    if pct is None:
        return 50
    return max(0, min(100, int(pct)))


def ai_involvement_tier(pct: int | None) -> str:
    """Bucket user slider for prompts and retrieval: assembly | low | mid | high."""
    p = _coerce_ai_percent(pct)
    if p <= 12:
        return "assembly"
    if p <= 37:
        return "low"
    if p <= 67:
        return "mid"
    return "high"


def _survey_level_system_append(survey_level: int | None) -> str:
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3
    if lvl <= 1:
        return "\n\n" + _LEVEL1_APPEND
    if lvl == 2:
        return "\n\n" + _LEVEL2_APPEND
    return "\n\n" + _LEVEL3_APPEND


def _minimum_interference_system_block() -> str:
    return """\
INTERFERENCE MODE β€” MINIMUM (strict document formatter):
You are a strict document formatter. Your only job is to map the content in the user's \
messy notes (RAW NOTES / bullets) onto the structure defined by the SECTION SKELETON and \
the STANDARD SOURCE PASSAGES. Use retrieved uploaded-report excerpts only to resolve \
ambiguous terminology or to confirm section relevance β€” do NOT copy narrative or \
property-specific findings from them. Do NOT invent, infer, expand, or editorialize beyond \
grammar and structural placement. Preserve the surveyor's original wording as closely as \
professional RICS grammar allows.\
"""


def _medium_interference_system_block() -> str:
    return """\
INTERFERENCE MODE β€” MEDIUM (professional report editor):
You are a professional report editor. Map messy notes onto the SECTION SKELETON and \
STANDARD SOURCE PASSAGES. You may improve clarity, add minimal transitions between mapped \
segments, and apply light contextual inference only where the messy notes unambiguously \
imply a point β€” but you must NOT introduce any data point, claim, or finding not traceable \
to the messy notes. Use uploaded-report excerpts for domain language and coherence only; \
do not import their property-specific facts unless the messy notes explicitly corroborate them.\
"""


def _maximum_interference_system_block() -> str:
    return """\
INTERFERENCE MODE β€” MAXIMUM (senior professional report writer):
You are a senior professional report writer. Generate publication-quality prose using \
messy notes as the sole factual authority for this property, standard paragraphs as the \
structural template, and uploaded reports as contextual reference for tone, industry \
framing, and narrative depth only. Never hallucinate or fabricate facts. Never quote raw \
messy-note language that would read as unprofessional. Never present another property's \
findings as belonging to this inspection unless the messy notes explicitly tie them in.\
"""


def resolve_generation_system_prompt(
    survey_level: int | None,
    ai_percent: int | None,
    interference_level: str | None,
) -> str:
    """Generate-mode system prompt: three dedicated interference paths or legacy slider path."""
    il = (interference_level or "").strip().lower()
    if il == "minimum":
        core = _ASSEMBLY_SYSTEM_CORE + "\n\n" + _minimum_interference_system_block()
        return core + _survey_level_system_append(survey_level)
    if il == "medium":
        core = _MID_INVOLVEMENT_CORE + "\n\n" + _medium_interference_system_block()
        return core + _survey_level_system_append(survey_level)
    if il == "maximum":
        core = _BASE_SYSTEM_PROMPT + "\n\n" + _maximum_interference_system_block()
        return core + _survey_level_system_append(survey_level)
    return resolve_generate_system_prompt(survey_level, ai_percent)


def build_minimum_interference_user_directive(min_words: int, max_words: int) -> str:
    return f"""
\n\n--- AI INTERFERENCE LEVEL: MINIMUM ---\n\
INPUT SEMANTICS: [messy_notes] = INSPECTOR'S RAW NOTES below. [standard_paragraphs] = SECTION SKELETON \
plus STANDARD SOURCE PASSAGES. [uploaded_reports] = DOCUMENT/SECTION/PARAGRAPH retrieval blocks.\n\
You are a strict document formatter. Your only job is to map the content in [messy_notes] onto the \
structure of [standard_paragraphs].\n\
Use [uploaded_reports] solely to understand terminology and paragraph context. Do not copy content from them.\n\
Do NOT add any information, interpretation, opinion, transition phrase, or filler sentence that does not \
originate directly from the messy notes.\n\
If a subsection of the standard structure has no corresponding data in the messy notes, leave it blank or \
insert the exact token [DATA NOT PROVIDED] β€” do not fill it.\n\
Preserve the user's original wording as closely as possible; clean grammar and structure only.\n\
Output length: match the density of the input notes β€” no padding. Stay within {min_words}–{max_words} words.\n\
"""


def build_medium_interference_user_directive(min_words: int, max_words: int) -> str:
    return f"""
\n\n--- AI INTERFERENCE LEVEL: MEDIUM ---\n\
INPUT SEMANTICS: [messy_notes] = RAW NOTES below. [standard_paragraphs] = skeleton + standard passages. \
[uploaded_reports] = retrieved upload excerpts.\n\
You are a professional report editor. Map [messy_notes] onto [standard_paragraphs]. You may improve clarity \
and add minimal transitions between sections.\n\
You may make light inferences where the intent of the messy notes is unambiguous, but do NOT introduce any \
data point, claim, or finding not traceable to the messy notes or directly corroborated passages tied to those notes.\n\
Use [uploaded_reports] to provide context and ensure the language matches the domain β€” but do not import findings \
from them unless directly corroborated by the messy notes.\n\
The output should feel professionally written; a subject matter expert reading alongside the messy notes should \
be able to trace every paragraph back to a source note.\n\
Target length: moderately expanded β€” typically about 20–40% more words than a Minimum-mode output for the same \
input, bounded by {min_words}–{max_words} words.\n\
"""


def build_maximum_interference_user_directive(min_words: int, max_words: int) -> str:
    return f"""
\n\n--- AI INTERFERENCE LEVEL: MAXIMUM ---\n\
INPUT SEMANTICS: [messy_notes] = primary factual source (RAW NOTES). [uploaded_reports] = contextual reference only. \
[standard_paragraphs] = structure template + approved boilerplate wording.\n\
You are a senior professional report writer. Deeply analyse all three, cross-reference them, and produce a \
comprehensive expert-grade section.\n\
You may draw on [uploaded_reports] to enrich narrative context, explain background, or provide industry framing β€” \
but treat [messy_notes] as the sole factual authority for this report's specific claims and findings.\n\
Never quote or surface raw messy-note language that is informal, incomplete, or could appear unprofessional.\n\
Every factual claim must be traceable to the messy notes or (only as non-property-specific framing) to generic \
passages; if you cannot trace it, omit it.\n\
Hard bans: no hallucinated metrics, names, dates, or defects; do not import another property's findings as current \
unless messy notes explicitly confirm; never contradict the meaning of the messy notes.\n\
Address every part of the SECTION SKELETON; use [DATA NOT PROVIDED] only where notes truly lack coverage.\n\
Target band for this section: {min_words}–{max_words} words (full reports combine many sections toward longer totals). \
Write executive-quality prose with clear flow; include an internal mini-summary only if the skeleton already implies one.\n\
"""


def append_interference_mode_user_suffix(
    interference_level: str | None,
    min_words: int,
    max_words: int,
) -> str:
    il = (interference_level or "").strip().lower()
    if il == "minimum":
        return build_minimum_interference_user_directive(min_words, max_words)
    if il == "medium":
        return build_medium_interference_user_directive(min_words, max_words)
    if il == "maximum":
        return build_maximum_interference_user_directive(min_words, max_words)
    return ""


def verbatim_ratio_target(pct: int | None) -> tuple[float, float]:
    """Return ``(target_verbatim_fraction, minimum_acceptable_fraction)``.

    The user slider maps directly to this contract:

    * ``ai_percent = 0``   β†’ 100% of output verbatim from sources / notes (1.0, 0.95)
    * ``ai_percent = 25``  β†’ ~75% verbatim, 25% AI bridging (0.75, 0.55)
    * ``ai_percent = 50``  β†’ ~50% verbatim, 50% AI adaptation (0.50, 0.30)
    * ``ai_percent = 75``  β†’ ~25% verbatim, 75% AI drafting (0.25, 0.10)
    * ``ai_percent = 100`` β†’ no verbatim requirement (0.0, 0.0)

    The minimum is the value below which we trigger a regenerate-with-stricter-hint
    pass; LLMs cannot hit a ratio exactly, so we accept a tolerance band but
    refuse to ship outputs that drift far below the slider's promise.
    """
    # Continuous contract: slider p% => (100-p)% of output should be verbatim.
    # This makes each digit meaningful and measurable.
    p = _coerce_ai_percent(pct)
    target = max(0.0, min(1.0, (100 - p) / 100.0))

    # Floors are a tolerance band, not a second target. They prevent infinite
    # retries on sections where the sources don't contain enough applicable
    # boilerplate while still enforcing the per-digit contract tightly at low p.
    if p == 0:
        return 1.0, 0.95
    if p >= 90:
        return target, 0.0

    if p <= 12:
        slack = 0.05
    elif p <= 37:
        slack = 0.12
    elif p <= 67:
        slack = 0.15
    else:
        slack = 0.18
    floor = max(0.0, min(target, target - slack))
    return target, floor


def top_p_for_ai_involvement(pct: int | None) -> float:
    """Narrowest sampling at assembly tier; widens as involvement rises.

    Monotonically increasing. At assembly tier (0–12%) we concentrate
    probability mass tightly so that β€” even if temperature ever rises above
    zero β€” token selection stays close to the source wording the prompt is
    instructing the model to reuse. At high involvement we let the model
    sample broadly for fluent prose.
    """
    p = _coerce_ai_percent(pct)
    if p <= 12:
        return 0.55
    if p <= 37:
        return 0.78
    if p <= 67:
        return 0.92
    return 0.98


def resolve_generate_system_prompt(survey_level: int | None, ai_percent: int | None = None) -> str:
    """Choose generate-mode system prompt by survey tier and AI involvement slider."""
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3

    tier = ai_involvement_tier(ai_percent)
    if tier == "assembly":
        core = _ASSEMBLY_SYSTEM_CORE
    elif tier == "low":
        core = _LOW_INVOLVEMENT_CORE
    elif tier == "mid":
        core = _MID_INVOLVEMENT_CORE
    else:
        core = _BASE_SYSTEM_PROMPT

    if lvl <= 1:
        return core + "\n\n" + _LEVEL1_APPEND
    if lvl == 2:
        return core + "\n\n" + _LEVEL2_APPEND
    return core + "\n\n" + _LEVEL3_APPEND


def _word_target_for_involvement(
    survey_level: int | None,
    ai_percent: int | None,
    *,
    interference_level: str | None = None,
) -> tuple[int, int]:
    """Tighten word caps at low AI involvement to discourage verbose AI filler."""
    mn, mx = _word_target_for_survey_level(survey_level)
    tier = ai_involvement_tier(ai_percent)
    if tier == "assembly":
        out_mn, out_mx = mn, min(mx, int(mx * 0.52) + 35)
    elif tier == "low":
        out_mn, out_mx = mn, min(mx, int(mx * 0.72) + 28)
    elif tier == "mid":
        out_mn, out_mx = mn, mx
    else:
        out_mn, out_mx = mn, min(int(mx * 1.12) + 60, 920)

    il = (interference_level or "").strip().lower()
    if il == "maximum":
        out_mx = min(int(out_mx * 2.45) + 140, 1650)
        out_mn = int(out_mn * 1.1)
    elif il == "medium":
        out_mx = min(int(out_mx * 1.14) + 35, 780)

    return out_mn, out_mx


def _involvement_override_block(
    tier: str,
    reference_only_context: bool,
    min_words: int,
    max_words: int,
    ai_percent: int | None = None,
    interference_level: str | None = None,
) -> str:
    """Strong, tier-specific instructions appended after the main user template."""
    il = (interference_level or "").strip().lower()
    pct_value = _coerce_ai_percent(ai_percent) if ai_percent is not None else None
    target, floor = verbatim_ratio_target(pct_value if pct_value is not None else 50)
    target_pct = int(round(target * 100))
    floor_pct = int(round(floor * 100))
    slider_pct = pct_value if pct_value is not None else None
    if il in ("minimum", "medium", "maximum"):
        slider_clause = (
            f" The user selected AI INTERFERENCE LEVEL: {il.upper()}. "
            "Follow the dedicated MODE CONTRACT in the system prompt and the USER directives below. "
            "Qualitative rules control; ignore any numeric slider wording.\n"
        )
    else:
        slider_clause = (
            f" The user has set the AI INVOLVEMENT slider to {slider_pct}%. "
            f"This means approximately {target_pct}% of the words in your output MUST be "
            f"verbatim quotes from the STANDARD SOURCE PASSAGES, the SECTION SKELETON, or "
            f"the RAW NOTES; at most {100 - target_pct}% may be your own connecting prose. "
            f"If your output falls below {floor_pct}% verbatim, it will be rejected and "
            f"regenerated with stricter constraints."
            if slider_pct is not None
            else ""
        )
    if tier == "assembly":
        ref_note = (
            " The REFERENCE / DOCUMENT-LEVEL / PARAGRAPH-LEVEL blocks below are the FIRM'S APPROVED "
            "STANDARD WORDING. Their phrasing IS the deliverable β€” quote it verbatim. Their "
            "property-specific facts (addresses, postcodes, names, dates, prices, dimensions) belong "
            "to OTHER properties and MUST NOT appear in your output; substitute those slots with the "
            "RAW NOTES facts (or omit if missing)."
            if reference_only_context
            else ""
        )
        return (
            "\n\n--- AI INVOLVEMENT CONSTRAINTS (MANDATORY β€” read carefully, this overrides everything else) ---\n"
            "Tier: ASSEMBLY / STANDARD TEXT (0–12%). You are a TEMPLATE ASSEMBLER, not a writer.\n"
            + slider_clause + "\n"
            "- The wording of every output clause MUST be a verbatim quote from a retrieved passage, the "
            "SECTION SKELETON, or the RAW NOTES.\n"
            "- DO NOT paraphrase. DO NOT replace any source word with a synonym (this includes common "
            "adjectives and connectors, not just technical terms).\n"
            "- DO NOT 'reorder for flow', 'tighten for clarity', or 'polish' the source.\n"
            "- DO NOT add narrative, causes, implications, or recommendations that are not already in a "
            "source or note.\n"
            "- New tokens you may introduce: short joining connectors ('and', 'however', 'Additionally', "
            "'The', 'This'), capped at 12 new words across the entire output; UK-spelling corrections; "
            "and property-specific values copied in from RAW NOTES.\n"
            f"- Length: do not exceed {max_words} words. If the verbatim source material runs short, "
            f"output a short result β€” do not pad.\n"
            "- If retrieved passages do not cover what the skeleton asks for, write nothing for that "
            "subsection rather than inventing prose.\n"
            + ref_note
        )
    if tier == "low":
        ref_note = (
            " The REFERENCE blocks below contain the firm's approved standard phrasing β€” quote it verbatim "
            "where it covers what this section needs. Their property-specific facts (addresses, postcodes, "
            "names, dates, prices, dimensions) belong to OTHER properties and MUST NOT appear in your "
            "output; substitute those slots with RAW NOTES facts or omit."
            if reference_only_context
            else ""
        )
        return (
            "\n\n--- AI INVOLVEMENT CONSTRAINTS (MANDATORY) ---\n"
            "Tier: LOW (13–37%).\n"
            + slider_clause + "\n"
            "- Preserve source phrasing wherever it covers what the section needs; quote verbatim by default.\n"
            "- Edits permitted only for grammar, tense, or removing a clause that doesn't apply.\n"
            "- DO NOT replace technical terms or standard phrases with synonyms.\n"
            "- New prose limited to short bridging sentences (under 15 words) linking two source passages.\n"
            f"- Target length {min_words}–{max_words} words.\n"
            + ref_note
        )
    if tier == "mid":
        return (
            "\n\n--- AI INVOLVEMENT CONSTRAINTS (MANDATORY) ---\n"
            "Tier: MODERATE (38–67%).\n"
            + slider_clause + "\n"
            "- Roughly half of the wording should still be verbatim from the standard source passages β€” "
            "blend retrieved approved phrasing with your own bridging prose.\n"
            "- Property-specific facts come ONLY from RAW NOTES; never copy them from source passages.\n"
            f"- Target length {min_words}–{max_words} words.\n"
        )
    return (
        "\n\n--- AI INVOLVEMENT CONSTRAINTS (MANDATORY) ---\n"
        "Tier: HIGH (68–100%).\n"
        + slider_clause + "\n"
        "- Full drafting allowed: summarise, expand, and refine while respecting facts from RAW NOTES and evidence.\n"
        "- Still do not invent property-specific facts.\n"
        f"- Target length {min_words}–{max_words} words.\n"
    )

_GENERATE_USER_TEMPLATE = """\
PROPERTY IDENTITY (NON-NEGOTIABLE; must be consistent throughout):
{identity_facts}

WRITING STYLE PROFILE (match this voice):
- Tone: {tone}
- Formality: {formality_level}
- Sentence complexity: {avg_sentence_complexity}
- Vocabulary: {vocabulary_level}
- Common phrases to echo: {common_phrases}
- Style summary: {writing_style_summary}
{style_examples_block}
SECTION SKELETON (structure to follow):
{skeleton}

INSPECTOR'S RAW NOTES (may be rough, abbreviated, or incomplete β€” interpret and expand these):
{bullets}

DOCUMENT-LEVEL CONTEXT (whole-file / routing excerpts β€” overall intent, tone, and how a complete RICS report reads):
{document_context}

SECTION-LEVEL CONTEXT (broader passages, e.g. page- or part-level scope within your uploads):
{section_context}

PARAGRAPH-LEVEL EVIDENCE (fine-grained retrieved chunks β€” primary factual support alongside the bullets):
{paragraph_evidence}
{style_anchor_block}

TASK: Transform the RAW NOTES into polished RICS report section text of {min_words} to {max_words} words. \
Preserve all numeric facts exactly. Interpret informal observations using professional \
RICS language. Use DOCUMENT-LEVEL CONTEXT for global coherence; SECTION-LEVEL CONTEXT for local structure; \
PARAGRAPH-LEVEL EVIDENCE and the bullets for factual grounding. \
If STYLE REFERENCE PARAGRAPHS are provided above, mirror that voice, sentence rhythm, and vocabulary \
without introducing facts that are not also supported by the RAW NOTES or the evidence blocks. \
If a SURVEYOR'S DRAFT PARAGRAPH is provided above, mirror its sentence length, rhythm, and professional approach \
without introducing facts that are not also supported by the RAW NOTES or the evidence blocks. \
If information is missing or cannot be verified from the notes or evidence, omit the unsupported claim. \
Follow the SECTION SKELETON structure exactly. Use subsection headings only if they appear in the skeleton. \
Use strict British English spellings throughout (e.g. colour, centre, storey, metre, aluminium, mould, analyse).\
"""

_GENERATE_USER_TEMPLATE_REFERENCE_ONLY = """\
PROPERTY IDENTITY (NON-NEGOTIABLE; must be consistent throughout):
{identity_facts}

WRITING STYLE PROFILE (match this voice):
- Tone: {tone}
- Formality: {formality_level}
- Sentence complexity: {avg_sentence_complexity}
- Vocabulary: {vocabulary_level}
- Common phrases to echo: {common_phrases}
- Style summary: {writing_style_summary}
{style_examples_block}
SECTION SKELETON (structure to follow):
{skeleton}

INSPECTOR'S RAW NOTES (these are the ONLY factual source for this property):
{bullets}

STANDARD SOURCE PASSAGES β€” DOCUMENT LEVEL (firm's approved boilerplate wording from prior reports; reuse the WORDING verbatim where relevant, but do NOT copy property-specific facts β€” those belong to other properties):
{document_context}

STANDARD SOURCE PASSAGES β€” SECTION LEVEL (firm's approved boilerplate; reuse WORDING verbatim, never copy property-specific facts):
{section_context}

STANDARD SOURCE PASSAGES β€” PARAGRAPH LEVEL (firm's approved phrasing; reuse the WORDING verbatim where it covers what this section needs, never copy property-specific facts such as addresses, postcodes, names, dates, prices, dimensions, condition ratings):
{paragraph_evidence}
{style_anchor_block}

TASK: Produce RICS report section text of {min_words} to {max_words} words by assembling \
verbatim wording from the STANDARD SOURCE PASSAGES above (the firm's approved phrasing) and \
inserting property-specific facts from the RAW NOTES. \
The wording IS the deliverable β€” quote the source passages where they cover what the section needs, \
and only introduce new wording for short connectors or to splice passages together. \
Property-specific facts (numbers, names, addresses, postcodes, dates, prices, specifications, condition \
ratings) MUST come from the RAW NOTES; never copy property-specific facts from the source passages \
because those belong to other properties. \
If a STYLE REFERENCE PARAGRAPHS block is provided above, the AI INVOLVEMENT CONSTRAINTS at the end of \
this prompt govern whether you mirror that voice or assemble verbatim from the standard passages. \
If a SURVEYOR'S DRAFT PARAGRAPH is provided above, treat it the same way. \
If a fact is missing from the notes, omit the unsupported claim β€” do not invent. \
Follow the SECTION SKELETON structure exactly. Use subsection headings only if they appear in the skeleton. \
Use strict British English spellings throughout (e.g. colour, centre, storey, metre, aluminium, mould, analyse).\
"""

_GENERATE_USER_TEMPLATE_PLAIN = """\
PROPERTY IDENTITY (NON-NEGOTIABLE; must be consistent throughout):
{identity_facts}

SECTION SKELETON (structure to follow):
{skeleton}

INSPECTOR'S RAW NOTES (may be rough, abbreviated, or incomplete β€” interpret and expand these):
{bullets}

DOCUMENT-LEVEL CONTEXT (whole-report narrative from your uploads):
{document_context}

SECTION-LEVEL CONTEXT (broader passages from your uploads):
{section_context}

PARAGRAPH-LEVEL EVIDENCE (closest fine-grained matches for this section):
{paragraph_evidence}
{style_anchor_block}

TASK: Transform the RAW NOTES into polished RICS report section text of {min_words} to {max_words} words. \
Preserve all numeric facts exactly. Interpret informal observations using professional \
RICS language. Combine DOCUMENT-LEVEL CONTEXT for tone, SECTION-LEVEL CONTEXT for local scope, \
and PARAGRAPH-LEVEL EVIDENCE for detail. \
If a SURVEYOR'S DRAFT PARAGRAPH is provided, mirror its style while keeping facts tied to the notes and evidence. \
If information is missing or cannot be verified from the notes or evidence, omit the unsupported claim. \
Follow the SECTION SKELETON structure exactly. Use subsection headings only if they appear in the skeleton. \
Use strict British English spellings throughout (e.g. colour, centre, storey, metre, aluminium, mould, analyse).\
"""

_GENERATE_USER_TEMPLATE_PLAIN_REFERENCE_ONLY = """\
PROPERTY IDENTITY (NON-NEGOTIABLE; must be consistent throughout):
{identity_facts}

SECTION SKELETON (structure to follow):
{skeleton}

INSPECTOR'S RAW NOTES (these are the ONLY factual source for this property):
{bullets}

STANDARD SOURCE PASSAGES β€” DOCUMENT LEVEL (firm's approved boilerplate wording from prior reports; reuse the WORDING verbatim where relevant, but do NOT copy property-specific facts β€” those belong to other properties):
{document_context}

STANDARD SOURCE PASSAGES β€” SECTION LEVEL (firm's approved boilerplate; reuse WORDING verbatim, never copy property-specific facts):
{section_context}

STANDARD SOURCE PASSAGES β€” PARAGRAPH LEVEL (firm's approved phrasing; reuse the WORDING verbatim where it covers what this section needs, never copy property-specific facts such as addresses, postcodes, names, dates, prices, dimensions, condition ratings):
{paragraph_evidence}
{style_anchor_block}

TASK: Produce RICS report section text of {min_words} to {max_words} words by assembling \
verbatim wording from the STANDARD SOURCE PASSAGES above (the firm's approved phrasing) and \
inserting property-specific facts from the RAW NOTES. \
The wording IS the deliverable β€” quote the source passages where they cover what the section needs, \
and only introduce new wording for short connectors or to splice passages together. \
Property-specific facts (numbers, names, addresses, postcodes, dates, prices, specifications, condition \
ratings) MUST come from the RAW NOTES; never copy property-specific facts from the source passages \
because those belong to other properties. \
If a SURVEYOR'S DRAFT PARAGRAPH is provided, treat it as additional approved phrasing under the same \
rules; the AI INVOLVEMENT CONSTRAINTS at the end of this prompt govern how strictly to assemble vs adapt. \
If a fact is missing from the notes, omit the unsupported claim β€” do not invent. \
Follow the SECTION SKELETON structure exactly. Use subsection headings only if they appear in the skeleton. \
Use strict British English spellings throughout (e.g. colour, centre, storey, metre, aluminium, mould, analyse).\
"""

# LangChain LCEL (generate mode): per-tier system + full user block from ``build_user_prompt``.
RICS_PROMPT = ChatPromptTemplate.from_messages(
    [
        ("system", "{system_content}"),
        ("human", "{user_content}"),
    ]
)


def _word_target_for_survey_level(survey_level: int | None) -> tuple[int, int]:
    """Return (min_words, max_words) per generated section by survey tier.

    IMPORTANT: these are **per-section** targets, not whole-report totals.
    They are calibrated to the user's observed full-report ranges:

    - L3 Building Survey ~7,928 words across 46 sections  -> ~172 words/section
    - L2 Home Survey     ~4,000–5,000 across ~33 sections -> ~121–152/section
    - L1 Condition       ~1,500–3,000 across 5 sections   -> ~300–600/section

    This means per-section L1 can legitimately be longer than L2 because L1 has
    far fewer sections in total.
    """
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3
    if lvl <= 1:
        return 280, 520
    if lvl == 2:
        return 110, 180
    return 140, 230


def max_output_tokens_for_survey_level(
    survey_level: int | None,
    *,
    fallback: int = 300,
    interference_level: str | None = None,
) -> int:
    """Token budget for generate-mode output by RICS product tier.

    Mirrors :func:`_word_target_for_survey_level` but in tokens. Real RICS L3
    element sections regularly run 500–700 words (~700–1000 tokens) when
    reporting cause/implications/options for a single defect; a 300-token
    `settings.max_output_tokens` ceiling silently caps every L3 generation
    well below the prompt's stated word target. We honour any explicit user
    override that's *higher* than the tier ceiling (so power users can still
    bump the global setting), but we never go *below* the tier ceiling.

    Args:
        survey_level: Numeric RICS product tier (1, 2, 3); ``None`` is treated as L3.
        fallback: Floor used when ``survey_level`` is unrecognised.

    Returns:
        Output token cap appropriate for ``survey_level``.
    """
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3
    if lvl <= 1:
        floor = 950
    elif lvl == 2:
        floor = 700
    else:
        floor = 900
    try:
        from app.config import settings  # local import: avoids cycle in module load
        user_override = int(settings.max_output_tokens)
    except Exception:  # noqa: BLE001
        user_override = fallback
    base = max(floor, user_override)
    il = (interference_level or "").strip().lower()
    if il == "maximum":
        return min(int(base * 2.35) + 500, 5200)
    return base


def max_context_tokens_for_survey_level(
    survey_level: int | None,
    *,
    fallback: int = 400,
    interference_level: str | None = None,
) -> int:
    """Snippet (input-context) token budget by RICS product tier.

    The default ``settings.max_context_tokens`` of 400 is enough for L1
    bullet-style sections but starves L3, where the LLM needs to read 1500+
    tokens of retrieved evidence (cause/condition tables, manufacturer
    specs, prior-section narrative) to write a faithful diagnostic
    paragraph. Without enough context the model drops back to its
    pre-training defaults β€” the most common driver of the "robust steel
    frame" / "10 Kingsley Avenue" hallucinations users have reported.
    """
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3
    if lvl <= 1:
        floor = 1000
    elif lvl == 2:
        floor = 1200
    else:
        floor = 1800
    try:
        from app.config import settings
        user_override = int(settings.max_context_tokens)
    except Exception:  # noqa: BLE001
        user_override = fallback
    base = max(floor, user_override)
    il = (interference_level or "").strip().lower()
    if il == "maximum":
        return min(int(base * 1.45) + 200, 6000)
    return base


# --- PROOFREAD mode ---

PROOFREAD_SYSTEM_PROMPT = """\
You are a professional RICS survey report editor and proofreader. \
Your role is to review generated content for grammatical correctness, \
clarity, readability, and consistency with the author's detected writing style. \
Do not add new facts. Only improve language quality. \
""" + _UK_ENGLISH_RULE + """ \
As part of proofreading, you MUST also correct any American English spellings \
to their British equivalents (e.g. "color" β†’ "colour", "center" β†’ "centre", \
"story" β†’ "storey" for building floors, "meter" β†’ "metre" for measurements, \
"aluminum" β†’ "aluminium", "mold" β†’ "mould", "analyze" β†’ "analyse"). \
Output plain text only β€” no markdown, no bullet points.\
"""

_PROOFREAD_USER_TEMPLATE = """\
AUTHOR'S WRITING STYLE PROFILE:
- Tone: {tone}
- Formality: {formality_level}
- Sentence complexity: {avg_sentence_complexity}
- Vocabulary: {vocabulary_level}
- Style summary: {writing_style_summary}

ORIGINAL FACT BULLETS:
{bullets}

TEXT TO PROOFREAD:
{text}

TASK: Proofread and improve the text above. \
Fix any grammatical errors, awkward phrasing, or unclear sentences. \
Correct ALL American English spellings to British English \
(colour, centre, storey, metre, aluminium, mould, analyse, organise, grey, draught, kerb, neighbour, \
behaviour, fibre, programme, licence (noun), -ise/-isation suffixes throughout). \
Align the language with the WRITING STYLE PROFILE. \
Do NOT add new facts or change the meaning. \
Do NOT introduce placeholders or bracketed tokens (e.g. [VERIFY], [TBC]). \
Return the corrected text followed by a separator line "---NOTES---" \
and then 1–3 brief editor notes explaining the main changes made.\
"""


# --- ENHANCE mode ---

ENHANCE_SYSTEM_PROMPT = """\
You are an expert RICS surveyor and technical writer. \
Your role is to expand and enrich a generated report section by adding \
technically accurate detail sourced from the supplied evidence. \
Do not invent facts. If a claim cannot be verified from BULLETS or the supplied evidence, \
omit that unsupported claim (no placeholder sentences). \
""" + _UK_ENGLISH_RULE + """ \
Output plain text only.\
"""


# --- VALIDATE mode (generate compliance) ---

VALIDATE_SYSTEM_PROMPT = """\
You are a strict RICS compliance validator for generated survey report sections. \
You must be critical and precise. \
Return either PASS or FAIL with short reasons. \
Output plain text only.\
"""

_VALIDATE_USER_TEMPLATE = """\
Validate this generated section for RICS survey_level compliance.

survey_level: {survey_level}
section_code: {section_code}

RULES TO CHECK (GENERAL):
- Does the text invent facts not present in RAW NOTES or the evidence blocks? If yes: FAIL.
- Does it include placeholders, brackets, or template tokens? If yes: FAIL.

LEVEL 1 (Condition Report):
- Any repair/maintenance advice or recommendations? If yes: FAIL.
- Risks should be listed only (no explanation). If it explains risks: FAIL.

LEVEL 2 (Home Survey Level 2):
- Should contain practical, proportionate advice when defects are present. If defects are present but no next step is given: FAIL.
- Must not become deeply diagnostic/technical beyond evidence. If it speculates heavily: FAIL.

LEVEL 3 (Building Survey):
- If defects are present, must include diagnostic layering: observation + likely cause (if supported) + implications + options/next steps. If missing: FAIL.
- Must not read like a short HomeBuyer paragraph when evidence supports detail. If too shallow: FAIL.

RAW NOTES:
{bullets}

EVIDENCE (retrieved):
{evidence}

GENERATED TEXT:
{text}

Return exactly one of:
PASS
FAIL: <1–5 short reasons>
"""


def build_validate_prompt(
    *,
    survey_level: int | None,
    section_code: str,
    bullets: list[str],
    evidence_snippets: list[str],
    text: str,
) -> str:
    bullets_text = "\n".join(f"- {b}" for b in bullets) if bullets else "(none)"
    evidence_text = _trim_snippets([s for s in evidence_snippets if s and s.strip()], 380) or "(none)"
    try:
        lvl = int(survey_level or 3)
    except Exception:  # noqa: BLE001
        lvl = 3
    return _VALIDATE_USER_TEMPLATE.format(
        survey_level=lvl,
        section_code=(section_code or "").strip() or "(unknown)",
        bullets=bullets_text,
        evidence=evidence_text,
        text=(text or "").strip(),
    )

_ENHANCE_USER_TEMPLATE = """\
AUTHOR'S WRITING STYLE PROFILE:
- Tone: {tone}
- Formality: {formality_level}
- Vocabulary: {vocabulary_level}
- Style summary: {writing_style_summary}

ORIGINAL FACT BULLETS:
{bullets}

ADDITIONAL EVIDENCE (from your uploaded documents for this tenant):
{examples}

CURRENT TEXT (to be enhanced):
{text}

TASK: Rewrite and expand the CURRENT TEXT to be more detailed and technically \
authoritative. Incorporate relevant technical insights from the ADDITIONAL EVIDENCE \
where they are clearly relevant. Aim for 80 to 200 words. \
Preserve all numeric facts. Match the WRITING STYLE PROFILE. \
If information is missing or cannot be verified from BULLETS or ADDITIONAL EVIDENCE, omit the unsupported claim. \
Use strict British English spellings throughout (e.g. colour, centre, storey, metre, aluminium, mould, analyse). \
Output only the enhanced text β€” no headings, no bullets.\
"""


# --- Public builders ---

def _creativity_suffix(hint: str) -> str:
    return f"\n\nAI LEVEL INSTRUCTION: {hint}" if hint else ""

def _style_anchor_block(anchor: str | None, max_anchor_tokens: int) -> str:
    """Return a user-prompt subsection for an optional draft/style anchor."""
    text = (anchor or "").strip()
    if not text:
        return ""
    if count_tokens(text) > max_anchor_tokens:
        encoded = _ENCODING.encode(text)[:max_anchor_tokens]
        text = _ENCODING.decode(encoded)
    return (
        "\n\nSURVEYOR'S DRAFT PARAGRAPH (optional style anchor β€” mirror tone, rhythm, and professional "
        "approach; facts must still match RAW NOTES and the evidence above):\n"
        f"{text}\n"
    )


def _layered_retrieval_blocks(
    document_snippets: list[str],
    hierarchy_section_snippets: list[str],
    paragraph_snippets: list[str],
    max_context_tokens: int,
    style_anchor: str | None,
    involvement_tier: str = "mid",
) -> tuple[str, str, str, str]:
    """Split token budget across document, section (mid), paragraph (fine), and style anchor."""
    anchor_cap = min(280, max(80, max_context_tokens // 5))
    anchor_block = _style_anchor_block(style_anchor, anchor_cap)
    anchor_used = count_tokens(anchor_block) if anchor_block else 0
    budget = max_context_tokens - anchor_used
    if budget < 80:
        budget = max_context_tokens

    doc_list = [s for s in document_snippets if s and s.strip()]
    mid_list = [s for s in hierarchy_section_snippets if s and s.strip()]
    para_list = [s for s in paragraph_snippets if s and s.strip()]

    # Retrieval-first at low involvement: favour document + paragraph standard wording.
    if involvement_tier in ("assembly", "low"):
        if mid_list:
            doc_pct, mid_pct, para_pct = 0.34, 0.26, 0.40
        else:
            doc_pct, mid_pct, para_pct = 0.42, 0.0, 0.58
    elif mid_list:
        doc_pct, mid_pct, para_pct = 0.28, 0.32, 0.40
    else:
        doc_pct, mid_pct, para_pct = 0.38, 0.0, 0.62

    doc_budget = min(int(budget * doc_pct), budget) if doc_list else 0
    remaining = max(0, budget - doc_budget)
    mid_budget = min(int(budget * mid_pct), remaining) if mid_list else 0
    para_budget = max(0, remaining - mid_budget)

    doc_text = _trim_snippets(doc_list, doc_budget) if doc_list else ""
    if not doc_text:
        doc_text = "(No document-level excerpts retrieved.)"
    mid_text = _trim_snippets(mid_list, mid_budget) if mid_list else ""
    if not mid_text:
        mid_text = "(No section-level context retrieved.)"
    para_text = _trim_snippets(para_list, para_budget) if para_list else ""
    if not para_text:
        para_text = "(No paragraph-level evidence retrieved.)"
    return doc_text, mid_text, para_text, anchor_block


_MAX_STYLE_EXAMPLE_TOKENS = 120  # per example paragraph
_MAX_STYLE_EXAMPLES = 2          # inject at most 2 examples to keep prompt lean


def _build_style_examples_block(example_paragraphs: list[str]) -> str:
    """Build the few-shot style reference block from the profile's example paragraphs.

    Each paragraph is token-capped and the block is only included when at least
    one example is available.  Returns an empty string otherwise.
    """
    if not example_paragraphs:
        return ""
    parts: list[str] = []
    for para in example_paragraphs[:_MAX_STYLE_EXAMPLES]:
        para = para.strip()
        if not para:
            continue
        toks = count_tokens(para)
        if toks > _MAX_STYLE_EXAMPLE_TOKENS:
            encoded = _ENCODING.encode(para)[:_MAX_STYLE_EXAMPLE_TOKENS]
            para = _ENCODING.decode(encoded)
        parts.append(para)
    if not parts:
        return ""
    joined = "\n\n".join(f'"{p}"' for p in parts)
    return (
        "\nSTYLE REFERENCE PARAGRAPHS (from this surveyor's own completed reports β€” "
        "mirror this exact voice, sentence rhythm, and phrasing):\n"
        + joined
        + "\n"
    )
def build_user_prompt(
    skeleton: str,
    bullets: list[str],
    snippets: list[str] | None = None,
    max_context_tokens: int = 400,
    style_profile: WritingStyleProfile | None = None,
    creativity_hint: str = "",
    document_snippets: list[str] | None = None,
    section_snippets: list[str] | None = None,
    hierarchy_section_snippets: list[str] | None = None,
    paragraph_snippets: list[str] | None = None,
    style_anchor: str | None = None,
    identity_facts: str | None = None,
    survey_level: int | None = None,
    reference_only_context: bool = False,
    ai_percent: int | None = None,
    interference_level: str | None = None,
) -> str:
    """Assemble the generate-mode user-turn prompt.

    Fine-grained evidence resolution order: ``paragraph_snippets`` β†’ ``section_snippets`` β†’ ``snippets``.
    Mid-level ``hierarchy_section_snippets`` is optional (pages / broader passages).
    """
    bullets_text = "\n".join(f"- {b}" for b in bullets)
    fine = (
        paragraph_snippets
        if paragraph_snippets is not None
        else (section_snippets if section_snippets is not None else (snippets or []))
    )
    mid = list(hierarchy_section_snippets or [])
    doc = list(document_snippets or [])
    tier = ai_involvement_tier(ai_percent)
    doc_ctx, sec_ctx, para_evid, anchor_block = _layered_retrieval_blocks(
        doc, mid, fine, max_context_tokens, style_anchor, involvement_tier=tier
    )
    suffix = _creativity_suffix(creativity_hint)
    identity = (identity_facts or "").strip() or "(Not provided.)"
    min_words, max_words = _word_target_for_involvement(
        survey_level, ai_percent, interference_level=interference_level
    )
    tail = (
        suffix
        + _involvement_override_block(
            tier,
            reference_only_context,
            min_words,
            max_words,
            ai_percent=ai_percent,
            interference_level=interference_level,
        )
        + append_interference_mode_user_suffix(interference_level, min_words, max_words)
    )

    if style_profile is not None:
        style_examples_block = _build_style_examples_block(
            getattr(style_profile, "example_paragraphs", [])
        )
        template = _GENERATE_USER_TEMPLATE_REFERENCE_ONLY if reference_only_context else _GENERATE_USER_TEMPLATE
        return template.format(
            identity_facts=identity,
            tone=style_profile.tone,
            formality_level=style_profile.formality_level,
            avg_sentence_complexity=style_profile.avg_sentence_complexity,
            vocabulary_level=style_profile.vocabulary_level,
            common_phrases=", ".join(style_profile.common_phrases[:4]) or "N/A",
            writing_style_summary=style_profile.writing_style_summary,
            style_examples_block=style_examples_block,
            skeleton=skeleton.strip(),
            bullets=bullets_text,
            document_context=doc_ctx,
            section_context=sec_ctx,
            paragraph_evidence=para_evid,
            style_anchor_block=anchor_block,
            min_words=min_words,
            max_words=max_words,
        ) + tail

    template = _GENERATE_USER_TEMPLATE_PLAIN_REFERENCE_ONLY if reference_only_context else _GENERATE_USER_TEMPLATE_PLAIN
    return template.format(
        identity_facts=identity,
        skeleton=skeleton.strip(),
        bullets=bullets_text,
        document_context=doc_ctx,
        section_context=sec_ctx,
        paragraph_evidence=para_evid,
        style_anchor_block=anchor_block,
        min_words=min_words,
        max_words=max_words,
    ) + tail


def build_lcel_invoke_vars(
    skeleton: str,
    bullets: list[str],
    snippets: list[str] | None = None,
    max_context_tokens: int = 400,
    style_profile: WritingStyleProfile | None = None,
    creativity_hint: str = "",
    document_snippets: list[str] | None = None,
    section_snippets: list[str] | None = None,
    hierarchy_section_snippets: list[str] | None = None,
    paragraph_snippets: list[str] | None = None,
    style_anchor: str | None = None,
    identity_facts: str | None = None,
    survey_level: int | None = None,
    reference_only_context: bool = False,
    ai_percent: int | None = None,
    interference_level: str | None = None,
) -> dict[str, str]:
    """Variables for ``RICS_PROMPT | ChatOpenAI | StrOutputParser`` (generate mode)."""
    return {
        "system_content": resolve_generation_system_prompt(survey_level, ai_percent, interference_level),
        "user_content": build_user_prompt(
            skeleton=skeleton,
            bullets=bullets,
            snippets=snippets,
            max_context_tokens=max_context_tokens,
            style_profile=style_profile,
            creativity_hint=creativity_hint,
            document_snippets=document_snippets,
            section_snippets=section_snippets,
            hierarchy_section_snippets=hierarchy_section_snippets,
            paragraph_snippets=paragraph_snippets,
            style_anchor=style_anchor,
            identity_facts=identity_facts,
            survey_level=survey_level,
            reference_only_context=reference_only_context,
            ai_percent=ai_percent,
            interference_level=interference_level,
        ),
    }


def build_proofread_prompt(
    text: str,
    bullets: list[str],
    style_profile: WritingStyleProfile | None = None,
    creativity_hint: str = "",
) -> str:
    """Assemble the proofread-mode user-turn prompt."""
    from app.generator.style_analyzer import _MOCK_PROFILE
    profile = style_profile or _MOCK_PROFILE
    bullets_text = "\n".join(f"- {b}" for b in bullets) if bullets else "(none)"
    return _PROOFREAD_USER_TEMPLATE.format(
        tone=profile.tone,
        formality_level=profile.formality_level,
        avg_sentence_complexity=profile.avg_sentence_complexity,
        vocabulary_level=profile.vocabulary_level,
        writing_style_summary=profile.writing_style_summary,
        bullets=bullets_text,
        text=text.strip(),
    ) + _creativity_suffix(creativity_hint)


def build_enhance_prompt(
    text: str,
    bullets: list[str],
    snippets: list[str],
    max_context_tokens: int,
    style_profile: WritingStyleProfile | None = None,
    creativity_hint: str = "",
) -> str:
    """Assemble the enhance-mode user-turn prompt."""
    from app.generator.style_analyzer import _MOCK_PROFILE
    profile = style_profile or _MOCK_PROFILE
    bullets_text = "\n".join(f"- {b}" for b in bullets) if bullets else "(none)"
    examples_text = _trim_snippets(snippets, max_context_tokens)
    return _ENHANCE_USER_TEMPLATE.format(
        tone=profile.tone,
        formality_level=profile.formality_level,
        vocabulary_level=profile.vocabulary_level,
        writing_style_summary=profile.writing_style_summary,
        bullets=bullets_text,
        examples=examples_text or "(No additional evidence available.)",
        text=text.strip(),
    ) + _creativity_suffix(creativity_hint)


def _trim_snippets(snippets: list[str], max_tokens: int) -> str:
    """Concatenate snippets until the token budget is exhausted.

    The first (best-ranked) snippet is always included; if it exceeds
    ``max_tokens`` it is truncated to fit rather than discarded entirely.
    Subsequent snippets are included whole only if they fit in the remaining
    budget.

    Args:
        snippets: List of text strings (best-first).
        max_tokens: Maximum total token count for the examples block.

    Returns:
        Single string with as many snippets as fit within ``max_tokens``.
    """
    parts: list[str] = []
    used = 0
    for i, snippet in enumerate(snippets):
        snippet_tokens = count_tokens(snippet)
        if used + snippet_tokens > max_tokens:
            if i == 0:
                remaining = max_tokens - used
                if remaining > 0:
                    encoded = _ENCODING.encode(snippet)[:remaining]
                    snippet = _ENCODING.decode(encoded)
                    parts.append(f"[Example {i + 1}]: {snippet}")
            break
        parts.append(f"[Example {i + 1}]: {snippet}")
        used += snippet_tokens
    return "\n\n".join(parts)