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