File size: 7,928 Bytes
865bc90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Section-domain scoping to prevent cross-section contamination (STEP 6).

Roofing prompts must only see roofing evidence; drainage only drainage, etc.
This module is a pure, deterministic keyword classifier — no embeddings, no
network — so the scoping decision is reproducible and unit-testable.

Usage::

    domain = classify_section("Roofing", template_id="E1")
    roofing_only = scope_chunks(domain, retrieved_chunks)

The classifier is intentionally conservative: a chunk with no clear domain
signal is treated as ``GENERAL`` and is admissible to any section (so we never
starve a section of context), but a chunk that clearly belongs to a *different*
domain is excluded.
"""

from __future__ import annotations

from app.extraction.citation_validator import normalize_for_match

GENERAL = "general"

# Domain → indicative terms. Order matters for deterministic tie-breaking
# (earlier domains win ties). Terms are matched as normalized substrings, so
# multi-word phrases are supported.
DOMAIN_LEXICON: dict[str, frozenset[str]] = {
    "roofing": frozenset({
        "roof", "roofing", "slate", "tile", "tiling", "ridge", "hip", "valley",
        "verge", "eaves", "soffit", "fascia", "felt", "flashing", "parapet",
        "covering", "rafter", "purlin", "truss", "flat roof", "pitched roof",
    }),
    "chimney": frozenset({
        "chimney", "chimney stack", "flaunching", "pot", "cowl", "flue",
        "breast", "corbel", "pointing to the stack",
    }),
    "rainwater": frozenset({
        "gutter", "guttering", "downpipe", "rainwater", "hopper", "rwp",
        "rainwater goods", "fall pipe",
    }),
    "drainage": frozenset({
        "drain", "drainage", "sewer", "manhole", "inspection chamber", "gully",
        "soil pipe", "foul", "surface water", "septic", "soakaway", "below ground",
    }),
    "walls": frozenset({
        "wall", "masonry", "brickwork", "blockwork", "render", "rendering",
        "pointing", "cavity", "spalling", "cracking", "lintel", "dpc",
        "damp proof course", "external wall", "load bearing",
    }),
    "dampness": frozenset({
        "damp", "dampness", "moisture", "rising damp", "penetrating damp",
        "condensation", "mould", "mold", "hygroscopic", "salts", "tide mark",
    }),
    "timber": frozenset({
        "timber", "woodworm", "beetle", "wet rot", "dry rot", "joist", "decay",
        "fungal", "infestation", "rot to the",
    }),
    "windows_doors": frozenset({
        "window", "windows", "door", "doors", "glazing", "double glazing",
        "frame", "casement", "sash", "joinery", "fenestration", "sill",
    }),
    "ceilings_floors": frozenset({
        "ceiling", "floor", "flooring", "floorboard", "screed", "plaster",
        "lath", "cornice", "skirting", "subfloor",
    }),
    "electrical": frozenset({
        "electric", "electrical", "wiring", "consumer unit", "fuse box",
        "rcd", "socket", "circuit", "earthing", "eicr", "fixed wiring",
        "distribution board",
    }),
    "heating": frozenset({
        "boiler", "heating", "radiator", "central heating", "flue", "gas",
        "thermostat", "hot water", "cylinder", "underfloor heating", "combi",
    }),
    "plumbing": frozenset({
        "plumbing", "pipework", "water supply", "stopcock", "waste pipe",
        "mains water", "lead pipe", "tank", "overflow", "sanitary",
    }),
    "insulation_energy": frozenset({
        "insulation", "epc", "energy performance", "sap", "u-value",
        "thermal", "loft insulation", "cavity insulation", "energy efficiency",
    }),
    "grounds": frozenset({
        "garden", "boundary", "fence", "fencing", "patio", "driveway", "path",
        "tree", "hedge", "retaining wall", "outbuilding", "grounds",
    }),
    "services_other": frozenset({
        "ventilation", "extractor", "smoke alarm", "carbon monoxide",
        "asbestos", "fire", "security",
    }),
}

# Map common RICS section titles / template hints to a canonical domain.
_SECTION_ALIASES: dict[str, str] = {
    "roof": "roofing", "roof coverings": "roofing", "main roof": "roofing",
    "chimney stacks": "chimney", "chimneys": "chimney",
    "rainwater pipes and gutters": "rainwater", "gutters": "rainwater",
    "drainage": "drainage",
    "main walls": "walls", "external walls": "walls", "walls": "walls",
    "dampness": "dampness", "damp": "dampness",
    "windows": "windows_doors", "doors": "windows_doors",
    "ceilings": "ceilings_floors", "floors": "ceilings_floors",
    "electricity": "electrical", "electrical": "electrical",
    "heating": "heating", "gas": "heating",
    "water": "plumbing", "plumbing": "plumbing",
    "insulation": "insulation_energy", "energy efficiency": "insulation_energy",
    "grounds": "grounds", "gardens": "grounds", "boundaries": "grounds",
}


def _score_domains(text: str) -> dict[str, int]:
    norm = normalize_for_match(text)
    if not norm:
        return {}
    scores: dict[str, int] = {}
    for domain, terms in DOMAIN_LEXICON.items():
        hits = 0
        for term in terms:
            # Count occurrences; multi-word terms matched as substrings.
            if " " in term:
                hits += norm.count(term)
            else:
                # word-ish boundary check to avoid 'gas' in 'gasket' etc.
                hits += _count_word(norm, term)
        if hits:
            scores[domain] = hits
    return scores


def _count_word(haystack: str, word: str) -> int:
    count = 0
    start = 0
    n = len(word)
    while True:
        idx = haystack.find(word, start)
        if idx == -1:
            break
        before = haystack[idx - 1] if idx > 0 else " "
        after = haystack[idx + n] if idx + n < len(haystack) else " "
        if not before.isalnum() and not after.isalnum():
            count += 1
        start = idx + n
    return count


def classify_text(text: str) -> str:
    """Return the dominant domain for ``text`` or :data:`GENERAL`."""
    scores = _score_domains(text)
    if not scores:
        return GENERAL
    # Highest score wins; ties broken by lexicon insertion order (stable).
    best = max(DOMAIN_LEXICON.keys(), key=lambda d: scores.get(d, 0))
    return best if scores.get(best, 0) > 0 else GENERAL


def classify_section(section_name: str | None, template_id: str | None = None) -> str:
    """Resolve a section title/template into a canonical domain.

    Falls back to keyword classification of the section name, then
    :data:`GENERAL`.
    """
    name = normalize_for_match(section_name or "")
    if name in _SECTION_ALIASES:
        return _SECTION_ALIASES[name]
    for alias, domain in _SECTION_ALIASES.items():
        if alias in name:
            return domain
    guessed = classify_text(section_name or "")
    return guessed


def scope_chunks(
    section_domain: str,
    chunks: list[object],
    *,
    strict: bool = True,
) -> list[object]:
    """Filter retrieved chunks to those admissible for ``section_domain``.

    A chunk is admissible when its dominant domain equals ``section_domain`` or
    is :data:`GENERAL`. Chunks clearly belonging to a *different* domain are
    excluded. If filtering would remove everything (e.g. weak classification),
    the original list is returned to avoid starving extraction — unless
    ``strict`` is False, in which case the filtered (possibly empty) list is
    always returned.

    ``chunks`` are SearchResult-like objects exposing ``.text``.
    """
    if section_domain == GENERAL or not chunks:
        return chunks
    kept: list[object] = []
    for c in chunks:
        text = getattr(c, "text", "") or ""
        cdom = classify_text(text)
        if cdom == section_domain or cdom == GENERAL:
            kept.append(c)
    if not kept and strict:
        # Never starve the extractor on a misclassification; better to extract
        # from broader context than to silently drop the whole section.
        return chunks
    return kept