Spaces:
Sleeping
Sleeping
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)
|