File size: 9,794 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Structured extraction of surveyor field notes (Fix 3).

Converts a raw notes string into a :class:`SurveyNotes` object: property
identity, section-mapped findings, cost estimates and special-risk flags. This
is an *additive* layer — it does not replace the existing note→section routing
in :mod:`backend.core.notes_parser`; it enriches the pipeline with a
``property_context`` (consumed by the property-type retrieval guard) and makes
raw findings available for notes-first fallbacks.

Pure functions only β€” no LLM, no network, no embeddings.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

_WORD_NUMBERS = {
    "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
}

_BOILER_BRANDS = (
    "viessmann", "worcester", "vaillant", "baxi", "ideal", "glow-worm",
    "glow worm", "potterton", "alpha", "ferroli", "intergas", "vokera",
)

_TREE_SPECIES = (
    "london plane", "plane", "oak", "willow", "cypress", "leylandii",
    "ash", "beech", "birch", "sycamore", "horse chestnut", "poplar",
    "lime", "magnolia", "conifer", "eucalyptus",
)

_PROPERTY_TYPE_HINTS = (
    "mid-terraced", "end-terraced", "end of terrace", "end-of-terrace",
    "terraced", "semi-detached", "detached", "townhouse", "town house",
    "maisonette", "apartment", "flat", "bungalow", "cottage",
)

_ERA_HINTS = (
    "victorian", "edwardian", "georgian", "interwar", "inter-war",
    "post-war", "modern", "new build", "1930s", "1960s",
)


@dataclass
class SurveyNotes:
    # Property identity
    property_type: str = ""           # "mid-terraced Victorian townhouse"
    construction_year: int | None = None
    storeys: int | None = None
    tenure: str = ""
    is_conservation_area: bool = False
    flood_zone: str = ""
    epc_band: str = ""
    epc_score: int | None = None

    # Section-mapped findings (keyed by RICS section code)
    section_findings: dict[str, list[str]] = field(default_factory=dict)

    # Cost estimates
    cost_estimates: dict[str, tuple[int, int]] = field(default_factory=dict)
    cost_total_essential: tuple[int, int] = (0, 0)
    cost_total_with_insulation: tuple[int, int] = (0, 0)

    # Special flags
    has_asbestos: bool = False
    asbestos_location: str = ""
    has_structural_crack: bool = False
    crack_description: str = ""
    tree_species: str = ""
    tree_distance_m: float = 0.0


# ── Field regexes (module level) ─────────────────────────────────────────────
_RE_YEAR = re.compile(r"\b(1[6-9]\d{2}|20[0-2]\d)\b")
_RE_BUILT_YEAR = re.compile(
    r"(?:built|constructed|circa|c\.?|dating from|erected)\s*(?:in\s*)?(1[6-9]\d{2}|20[0-2]\d)",
    re.IGNORECASE,
)
_RE_STOREYS_DIGIT = re.compile(r"\b(\d{1,2})\s*[-\s]?(?:storey|story|stories|storeys|floors?)\b", re.IGNORECASE)
_RE_STOREYS_WORD = re.compile(
    r"\b(" + "|".join(_WORD_NUMBERS) + r")[-\s]?(?:storey|story|stories|storeys)\b",
    re.IGNORECASE,
)
_RE_FREEHOLD = re.compile(r"\b(share of freehold|freehold|leasehold|commonhold)\b", re.IGNORECASE)
_RE_CONSERVATION = re.compile(r"conservation area", re.IGNORECASE)
_RE_CONSERVATION_NEG = re.compile(r"(?:not|isn't|is not|outside)[^.\n]{0,40}conservation area", re.IGNORECASE)
_RE_FLOOD_ZONE = re.compile(r"flood\s*zone\s*([0-9a-z]+)", re.IGNORECASE)
_RE_EPC_BAND = re.compile(r"\bEPC\b[^.\n]{0,30}?\b([A-G])\b", re.IGNORECASE)
_RE_EPC_SCORE = re.compile(r"\bEPC\b[^.\n]{0,40}?\b(\d{1,3})\b", re.IGNORECASE)
_RE_RADIATORS = re.compile(r"\b(\d{1,3})\s*radiators?\b", re.IGNORECASE)
_RE_CYLINDER = re.compile(r"\b(\d{2,4})\s*(?:l|litre|liter|ltr)\b", re.IGNORECASE)
_RE_CRACK_MM = re.compile(r"(\d+(?:\.\d+)?)\s*mm", re.IGNORECASE)
_RE_DISTANCE_M = re.compile(r"(\d+(?:\.\d+)?)\s*m(?:etre|eter)?s?\b", re.IGNORECASE)
_RE_COST_RANGE = re.compile(
    r"Β£\s?([\d,]+)\s*(k)?\s*(?:[-–—]|to)\s*Β£?\s?([\d,]+)\s*(k)?",
    re.IGNORECASE,
)


def _money_to_int(value: str, has_k: bool) -> int:
    n = int(value.replace(",", "").strip() or "0")
    return n * 1000 if has_k else n


def _split_sentences(text: str) -> list[str]:
    parts = re.split(r"(?<=[.!?])\s+|\n+", text)
    return [p.strip() for p in parts if p and p.strip()]


def _first(pattern: re.Pattern[str], text: str) -> str | None:
    m = pattern.search(text)
    return m.group(1) if m else None


def _extract_property_type(text: str) -> str:
    lowered = text.lower()
    type_hit = next((h for h in _PROPERTY_TYPE_HINTS if h in lowered), "")
    era_hit = next((h for h in _ERA_HINTS if h in lowered), "")
    pieces = [p for p in (era_hit, type_hit) if p]
    return " ".join(pieces).strip()


def _extract_costs(text: str) -> tuple[dict[str, tuple[int, int]], tuple[int, int], tuple[int, int]]:
    estimates: dict[str, tuple[int, int]] = {}
    total_essential = (0, 0)
    total_insulation = (0, 0)
    for sentence in _split_sentences(text):
        m = _RE_COST_RANGE.search(sentence)
        if not m:
            continue
        low = _money_to_int(m.group(1), bool(m.group(2)))
        high = _money_to_int(m.group(3), bool(m.group(4)))
        rng = (min(low, high), max(low, high))
        low_sentence = sentence.lower()
        if "total" in low_sentence and ("insulation" in low_sentence or "with" in low_sentence):
            total_insulation = rng
            continue
        if "total" in low_sentence:
            total_essential = rng
            continue
        # Key the estimate by a nearby subject noun (first content word before 'Β£').
        prefix = sentence[: m.start()]
        words = re.findall(r"[A-Za-z]{4,}", prefix.lower())
        key = words[-1] if words else f"item_{len(estimates) + 1}"
        estimates[key] = rng
    return estimates, total_essential, total_insulation


def _extract_flags(notes: SurveyNotes, text: str) -> None:
    for sentence in _split_sentences(text):
        low = sentence.lower()
        if "asbestos" in low and not notes.has_asbestos:
            notes.has_asbestos = True
            notes.asbestos_location = sentence
        if "crack" in low and not notes.has_structural_crack:
            mm = _RE_CRACK_MM.search(sentence)
            if mm or any(w in low for w in ("structural", "diagonal", "stepped", "subsidence")):
                notes.has_structural_crack = True
                notes.crack_description = sentence
        if not notes.tree_species:
            species = next((s for s in _TREE_SPECIES if s in low), "")
            if species and ("tree" in low or "m " in low or "metre" in low):
                notes.tree_species = species
                dist = _RE_DISTANCE_M.search(sentence)
                if dist:
                    try:
                        notes.tree_distance_m = float(dist.group(1))
                    except ValueError:
                        pass


def _map_section_findings(text: str) -> dict[str, list[str]]:
    """Route each note line to a section code via the existing cascade classifier."""
    from backend.core.notes_keyword_router import classify_note_cascade
    from backend.core.notes_parser import UNASSIGNED, _iter_note_lines

    findings: dict[str, list[str]] = {}
    for line in _iter_note_lines(text):
        section_id, _score, obs = classify_note_cascade(line)
        if section_id == UNASSIGNED:
            continue
        findings.setdefault(section_id.upper(), []).append((obs or line).strip())
    return findings


def parse_notes(raw_notes: str) -> SurveyNotes:
    """Extract a structured :class:`SurveyNotes` object from raw field-note text."""
    notes = SurveyNotes()
    text = (raw_notes or "").replace("\r\n", "\n")
    if not text.strip():
        return notes

    notes.property_type = _extract_property_type(text)

    year = _first(_RE_BUILT_YEAR, text) or _first(_RE_YEAR, text)
    if year:
        notes.construction_year = int(year)

    storey_digit = _first(_RE_STOREYS_DIGIT, text)
    if storey_digit:
        notes.storeys = int(storey_digit)
    else:
        storey_word = _first(_RE_STOREYS_WORD, text)
        if storey_word:
            notes.storeys = _WORD_NUMBERS.get(storey_word.lower())

    tenure = _first(_RE_FREEHOLD, text)
    if tenure:
        notes.tenure = tenure.lower()

    notes.is_conservation_area = bool(
        _RE_CONSERVATION.search(text) and not _RE_CONSERVATION_NEG.search(text)
    )

    flood = _first(_RE_FLOOD_ZONE, text)
    if flood:
        notes.flood_zone = f"Zone {flood.upper()}"

    band = _first(_RE_EPC_BAND, text)
    if band:
        notes.epc_band = band.upper()
    score = _first(_RE_EPC_SCORE, text)
    if score:
        notes.epc_score = int(score)

    notes.cost_estimates, notes.cost_total_essential, notes.cost_total_with_insulation = _extract_costs(text)
    _extract_flags(notes, text)
    notes.section_findings = _map_section_findings(text)
    return notes


def build_property_context(
    notes: SurveyNotes,
    *,
    property_type: str = "",
    tenure: str = "",
) -> dict:
    """Merge parsed notes with explicit session values into a retrieval context.

    Explicit (UI/session) ``property_type``/``tenure`` take precedence over the
    values inferred from the notes.
    """
    return {
        "property_type": (property_type or notes.property_type or "").strip(),
        "tenure": (tenure or notes.tenure or "").strip(),
        "construction_year": notes.construction_year,
        "storeys": notes.storeys,
        "is_conservation_area": notes.is_conservation_area,
        "flood_zone": notes.flood_zone,
        "epc_band": notes.epc_band,
        "has_asbestos": notes.has_asbestos,
        "has_structural_crack": notes.has_structural_crack,
    }