File size: 17,109 Bytes
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
faa8fb3
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
0136798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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]