Spaces:
Sleeping
Sleeping
| """Notes expander β transforms raw inspection field notes into professional statements. | |
| Users typically enter short, informal, incomplete bullet notes such as: | |
| "roof bad maybe 10yr felt tiles needs work" | |
| "semi det nw3 1965ish 95sqm" | |
| "gas elec mains ok heating old boiler" | |
| This module pre-processes those notes into clearer, structured fact statements | |
| *before* they reach the main generation LLM, giving the generator higher-quality | |
| input to work with. | |
| Rules observed in every path: | |
| - Exact numbers, dates, addresses, and postcodes are never altered. | |
| - Vague condition words are mapped to professional RICS equivalents. | |
| - Common abbreviations are expanded. | |
| - Any content that is inferred rather than stated is tagged [INFERRED] so the | |
| downstream enforce_verify pass can flag it for the user to confirm. | |
| - When no OpenAI key is configured, a fast rule-based mock path is used so the | |
| pipeline stays fully functional offline. | |
| """ | |
| import json | |
| import logging | |
| import re | |
| logger = logging.getLogger(__name__) | |
| # ββ Abbreviation dictionary ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Multi-word service phrases MUST come before single-letter direction | |
| # abbreviations so "mains e" is never corrupted to "mains east" by the | |
| # single-letter "e" rule firing first. Longer / more-specific patterns | |
| # are always listed before their shorter substrings. | |
| _ABBREV: dict[str, str] = { | |
| # ββ services (multi-word first β must precede single-letter directions) ββ | |
| "mains g": "mains gas", | |
| "mains e": "mains electricity", | |
| "mains w": "mains water", | |
| "elec.": "electricity", | |
| "elec": "electricity", | |
| "c/h": "central heating", | |
| "ch": "central heating", | |
| "u/flr": "underfloor", | |
| "u/f": "underfloor", | |
| # ββ property types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "semi det": "semi-detached", | |
| "semi-det": "semi-detached", | |
| "det": "detached", | |
| "terr": "terraced", | |
| "ter": "terraced", | |
| "flat": "flat", | |
| "maisonette": "maisonette", | |
| "bungalow": "bungalow", | |
| # ββ construction βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "rcc": "reinforced concrete", | |
| "rc": "reinforced concrete", | |
| "br": "brick", | |
| "bk": "brick", | |
| "upvc": "uPVC", | |
| "t/g": "triple-glazed", | |
| "d/g": "double-glazed", | |
| "s/g": "single-glazed", | |
| "tg": "triple-glazed", | |
| "dg": "double-glazed", | |
| "sg": "single-glazed", | |
| "c/w": "complete with", | |
| "w/o": "without", | |
| "w/": "with", | |
| # ββ compass directions (single letters last β after all multi-word rules) β | |
| "ne": "north-east", "nw": "north-west", | |
| "se": "south-east", "sw": "south-west", | |
| "n": "north", "s": "south", "e": "east", "w": "west", | |
| # ββ area / measurement ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "sq.m": "sq m", "sqm": "sq m", "m2": "sq m", | |
| "sqft": "sq ft", | |
| # ββ approximate age markers βββββββββββββββββββββββββββββββββββββββββββββββ | |
| "ish": "", "approx": "approximately", "c.": "circa", "ca.": "circa", | |
| # ββ condition shorthand βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # "poor" MUST come first: "v bad", "vbad", and "pf" all expand to phrases | |
| # containing the word "poor". If "poor" were processed after any of those, | |
| # the _ABBREV loop (which is inherently cascading) would re-expand "poor" | |
| # inside the already-expanded text and produce garbled output like | |
| # "in very in poor condition condition and requiring urgent attention". | |
| "poor": "in poor condition", | |
| "v gd": "in very good condition", | |
| "vgd": "in very good condition", | |
| "v bad": "in very poor condition and requiring urgent attention", | |
| "vbad": "in very poor condition and requiring urgent attention", | |
| "ok": "in satisfactory condition", | |
| "gd": "in good condition", | |
| "fair": "in fair condition", | |
| "pf": "in poor/fair condition", | |
| "bad": "in poor condition and requiring attention", | |
| "nr": "not readily visible", | |
| "nv": "not visible", | |
| "ni": "not inspected", | |
| "nm": "not measured", | |
| # ββ time / age ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "yrs": "years old", "yr": "years old", | |
| # ββ general βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "re:": "regarding", | |
| "fyi": "", | |
| "poss": "possibly", | |
| "prob": "probably", | |
| "nec": "necessary", | |
| "reqs": "requires", | |
| "req": "required", | |
| "recs": "recommends", | |
| "rec": "recommended", | |
| "asap": "urgently", | |
| "immed": "immediately", | |
| } | |
| # ββ Condition-word mapping βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _CONDITION_WORDS: dict[str, str] = { | |
| "bad": "in poor condition, requiring attention", | |
| "terrible": "in very poor condition, requiring urgent attention", | |
| "awful": "in very poor condition and beyond reasonable repair without major work", | |
| "horrible": "in very poor condition", | |
| "cracked": "exhibiting cracking", | |
| "cracking": "exhibiting cracking which should be monitored", | |
| "damp": "showing evidence of dampness", | |
| "wet": "showing evidence of water ingress", | |
| "leaking": "exhibiting signs of water ingress", | |
| "rotten": "exhibiting decay/rot requiring repair", | |
| "rotting": "exhibiting active decay requiring repair", | |
| "loose": "loose and requiring re-fixing", | |
| "missing": "with some elements missing, requiring reinstatement", | |
| "rusty": "exhibiting corrosion/rust", | |
| "stained": "exhibiting staining, the cause of which should be investigated", | |
| "peeling": "with peeling/flaking finish requiring redecoration", | |
| "tired": "in aged condition requiring general maintenance", | |
| "needs work": "requires attention and further investigation", | |
| "needs repair": "requires repair", | |
| # NOTE: "urgent" is intentionally absent here. The word appears inside | |
| # several _ABBREV expansions (e.g. "v bad" β "β¦requiring urgent attention"). | |
| # Including it here would cause the condition-words pass to re-expand the | |
| # already-expanded output, producing garbled repetition like | |
| # "requiring requiring urgent attention attention". | |
| } | |
| def _rule_based_expand(bullet: str) -> str: | |
| """Apply a fast rule-based expansion to a single bullet string. | |
| Expands common abbreviations and maps condition words to professional | |
| equivalents without making any external API calls. | |
| Args: | |
| bullet: Raw single-line inspector note. | |
| Returns: | |
| Expanded string (may still contain gaps β the LLM will fill those). | |
| """ | |
| text = bullet.strip() | |
| # Expand abbreviations. The lookbehind uses (?<![a-zA-Z]) rather than | |
| # (?<!\w) so that unit/age suffixes like "95sqm" or "10yr" are still caught | |
| # (the digit before the abbrev is not a letter, just not a word boundary). | |
| # When the match is immediately preceded by a digit a space is inserted so | |
| # that "95sqm" β "95 sq m" and "10yr" β "10 years old", not "95sq m" / "10years old". | |
| for abbrev, expansion in _ABBREV.items(): | |
| pattern = r"(?<![a-zA-Z])" + re.escape(abbrev) + r"(?!\w)" | |
| src = text | |
| def _abbrev_repl(m: re.Match[str], exp: str = expansion, hay: str = src) -> str: | |
| prefix = " " if m.start() > 0 and hay[m.start() - 1].isdigit() else "" | |
| return prefix + exp | |
| text = re.sub(pattern, _abbrev_repl, text, flags=re.IGNORECASE) | |
| # Normalise multiple spaces | |
| text = re.sub(r" +", " ", text).strip() | |
| # Expand condition words in a single non-cascading pass: build a combined | |
| # pattern and replace all matches at once using a dispatch dict. This | |
| # prevents "cracked" β "exhibiting cracking" from then re-triggering the | |
| # "cracking" rule, and stops "terrible" β "...urgent..." from re-firing | |
| # the "urgent" rule on its own output. | |
| sorted_conditions = sorted(_CONDITION_WORDS.keys(), key=len, reverse=True) | |
| combined = re.compile( | |
| r"(?<!\w)(" + "|".join(re.escape(k) for k in sorted_conditions) + r")(?!\w)", | |
| re.IGNORECASE, | |
| ) | |
| text = combined.sub(lambda m: _CONDITION_WORDS[m.group(0).lower()], text) | |
| return text | |
| def _llm_expand_raw( | |
| *, | |
| system_prompt: str, | |
| user_prompt: str, | |
| openai_api_key: str, | |
| chat_model: str, | |
| ) -> str: | |
| from openai import OpenAI | |
| client = OpenAI(api_key=openai_api_key) | |
| response = client.chat.completions.create( | |
| model=chat_model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| max_tokens=600, | |
| temperature=0.1, | |
| ) | |
| return (response.choices[0].message.content or "").strip() | |
| async def expand_notes_async( | |
| bullets: list[str], | |
| section_code: str = "", | |
| openai_api_key: str = "", | |
| chat_model: str = "gpt-4o-mini", | |
| ) -> list[str]: | |
| """Async notes expansion using the throttled OpenAI chat helper.""" | |
| if not bullets: | |
| return [] | |
| if not (openai_api_key or "").strip(): | |
| return [_rule_based_expand(b) for b in bullets] | |
| from app.config import settings | |
| from app.llm.openai_chat import chat_completions_create | |
| system_prompt = ( | |
| "You are a UK RICS surveyor assistant. Expand raw inspector field notes into " | |
| "professional fact statements suitable for a Home Survey report.\n" | |
| "Rules:\n" | |
| "1. Preserve every number, measurement, date, postcode, and proper noun exactly.\n" | |
| "2. Expand abbreviations (e.g. det β detached, sqm β square metres).\n" | |
| "3. Map vague condition words to professional equivalents " | |
| " (e.g. 'bad' β 'in poor condition requiring attention').\n" | |
| "4. Fill in standard professional context for clearly incomplete observations " | |
| " but tag anything inferred with [INFERRED].\n" | |
| "5. Do NOT invent measurements, costs, or specific facts not implied by the notes.\n" | |
| "6. Return a JSON array of strings β one expanded statement per input bullet.\n" | |
| "7. Maintain the same order as the input.\n" | |
| "8. Output ONLY the JSON array, no commentary, no markdown fences.\n" | |
| "9. STRICT BRITISH ENGLISH ONLY: use British spellings at all times β " | |
| " colour (not color), centre (not center), storey/storeys (not story/stories for floors), " | |
| " metre/metres (not meter/meters), aluminium (not aluminum), mould (not mold), " | |
| " analyse (not analyze), organise (not organize), grey (not gray), " | |
| " draught (not draft for air), kerb (not curb), -ise/-isation suffixes (not -ize/-ization). " | |
| " Never use American English spellings." | |
| ) | |
| section_context = f" for RICS section {section_code}" if section_code else "" | |
| user_prompt = ( | |
| f"Expand these raw inspector field notes{section_context} into " | |
| f"professional RICS fact statements:\n\n" | |
| + "\n".join(f"- {b}" for b in bullets) | |
| ) | |
| try: | |
| raw = await chat_completions_create( | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| model=chat_model or settings.chat_model, | |
| max_tokens=600, | |
| temperature=0.1, | |
| phase="notes_expand", | |
| section_id=section_code or None, | |
| ) | |
| expanded: list[str] = json.loads(raw or "[]") | |
| if isinstance(expanded, list) and len(expanded) == len(bullets): | |
| return [str(e) for e in expanded] | |
| except json.JSONDecodeError as exc: | |
| logger.warning("Notes expander returned non-JSON (%s) β using rule-based fallback", exc) | |
| except Exception as exc: | |
| logger.warning("Notes expander failed (%s) β using rule-based fallback", exc) | |
| return [_rule_based_expand(b) for b in bullets] | |
| def expand_notes( | |
| bullets: list[str], | |
| section_code: str = "", | |
| openai_api_key: str = "", | |
| chat_model: str = "gpt-4o-mini", | |
| ) -> list[str]: | |
| """Expand raw inspector notes into professional, structured fact statements. | |
| When ``openai_api_key`` is provided the LLM path is used; otherwise a fast | |
| rule-based mock path is applied so the pipeline functions offline. | |
| The expanded bullets are passed to the generation LLM as richer input. | |
| Exact numbers, postcodes, and dates are never modified by this function. | |
| Args: | |
| bullets: Raw bullet strings as entered by the user (may be informal). | |
| section_code: RICS section code (e.g. ``"E2"`` for Roof coverings), used for context. | |
| openai_api_key: OpenAI API key; empty string triggers mock fallback. | |
| chat_model: Chat model to use for LLM-based expansion. | |
| Returns: | |
| List of expanded bullet strings, one per original bullet. | |
| Returns the original list unchanged on error or empty input. | |
| """ | |
| if not bullets: | |
| return bullets | |
| if not openai_api_key: | |
| logger.debug("No OpenAI key β using rule-based note expansion") | |
| return [_rule_based_expand(b) for b in bullets] | |
| system_prompt = ( | |
| "You are an expert RICS surveyor acting as an inspection notes interpreter. " | |
| "Your task is to expand informal, shorthand inspection notes into clear, " | |
| "professional fact statements ready for a formal RICS survey report. " | |
| "\n\nRules you MUST follow:\n" | |
| "1. Preserve ALL exact numbers, measurements, dates, postcodes, and addresses exactly as given.\n" | |
| "2. Expand abbreviations (e.g. 'semi det' β 'semi-detached', 'dg' β 'double-glazed').\n" | |
| "3. Map informal condition words to professional RICS equivalents " | |
| " (e.g. 'bad' β 'in poor condition requiring attention').\n" | |
| "4. Fill in standard professional context for clearly incomplete observations " | |
| " but tag anything inferred with [INFERRED].\n" | |
| "5. Do NOT invent measurements, costs, or specific facts not implied by the notes.\n" | |
| "6. Return a JSON array of strings β one expanded statement per input bullet.\n" | |
| "7. Maintain the same order as the input.\n" | |
| "8. Output ONLY the JSON array, no commentary, no markdown fences.\n" | |
| "9. STRICT BRITISH ENGLISH ONLY: use British spellings at all times β " | |
| " colour (not color), centre (not center), storey/storeys (not story/stories for floors), " | |
| " metre/metres (not meter/meters), aluminium (not aluminum), mould (not mold), " | |
| " analyse (not analyze), organise (not organize), grey (not gray), " | |
| " draught (not draft for air), kerb (not curb), -ise/-isation suffixes (not -ize/-ization). " | |
| " Never use American English spellings." | |
| ) | |
| section_context = f" for RICS section {section_code}" if section_code else "" | |
| user_prompt = ( | |
| f"Expand these raw inspector field notes{section_context} into " | |
| f"professional RICS fact statements:\n\n" | |
| + "\n".join(f"- {b}" for b in bullets) | |
| ) | |
| try: | |
| raw = _llm_expand_raw( | |
| system_prompt=system_prompt, | |
| user_prompt=user_prompt, | |
| openai_api_key=openai_api_key, | |
| chat_model=chat_model, | |
| ) | |
| expanded: list[str] = json.loads(raw) | |
| if isinstance(expanded, list) and len(expanded) == len(bullets): | |
| logger.info( | |
| "Notes expansion complete: %d bullets expanded for section=%s", | |
| len(bullets), section_code, | |
| ) | |
| return [str(e) for e in expanded] | |
| logger.warning("Notes expander returned unexpected shape β using rule-based fallback") | |
| return [_rule_based_expand(b) for b in bullets] | |
| except json.JSONDecodeError as exc: | |
| logger.warning("Notes expander returned non-JSON (%s) β using rule-based fallback", exc) | |
| return [_rule_based_expand(b) for b in bullets] | |
| except Exception as exc: | |
| logger.warning("Notes expander failed (%s) β using rule-based fallback", exc) | |
| return [_rule_based_expand(b) for b in bullets] | |