File size: 20,224 Bytes
330d570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
"""Content-first topic classification for the content-based report mode.

Classifies a piece of text (a note line, a past-report paragraph, or a standard
paragraph) into the fixed topic taxonomy in :mod:`backend.content_based.taxonomy`
*by meaning*, so reports that do not follow the RICS layout are still understood.

Decision order (per text):
  1. Exact RICS leaf code hint (e.g. ``D2``) -> trust it (structure prior).
  2. LLM pass over everything else β€” the model reads the taxonomy and places the
     text by meaning. This is the primary classifier: content mode exists for
     documents that carry no structural codes, so there is usually nothing to
     match on but the words themselves.
  3. Deterministic fallback for whatever step 2 did not resolve (LLM disabled,
     no API key, call failed, or the model stayed silent on a snippet):
     RICS parent prior -> room heading -> embedding-anchor cosine.
  4. Fall back to "Other / General Observations", flagged ``needs_review``.

Only the *ingest* path supplies ``section_id_hints`` (a chunk's segmented RICS
code), so steps 1 and the parent prior are inert when routing surveyor notes β€”
by design, since those notes have no codes to trust.

Embeddings reuse the shared embedder (:mod:`backend.llm.embeddings`); anchor
vectors are built once and cached.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass

from backend.config import settings
from backend.content_based import taxonomy

logger = logging.getLogger(__name__)

_MIN_CLASSIFY_CHARS = 3
_ROOM_HEADING_MAX_CHARS = 64


@dataclass(frozen=True)
class TopicClassification:
    """Result of classifying one text into the content taxonomy."""

    topic_id: str
    subtopic_id: str
    confidence: float
    method: str  # rics_leaf | rics_parent | room | embedding | llm | catch_all | too_short
    needs_review: bool = False
    """True when no positive identification was made (catch-all), or when the
    classifier's own confidence was too low to rely on. Callers may surface this
    to the surveyor rather than silently filing the text under 'Other'."""
    theme_tags: tuple[str, ...] = ()
    """Cross-cutting themes (damp, movement, ...), orthogonal to the topic. Always
    populated lexically; the LLM's own suggestions are unioned in when it ran."""


# One validated model assignment: (topic_id, subtopic_id, confidence, theme_tags).
_Assignment = tuple[str, str, float, tuple[str, ...]]

# Below this the LLM's self-reported confidence is treated as a guess.
_LLM_LOW_CONFIDENCE = 0.5
# Used when the model places a snippet but omits (or mangles) its confidence.
_LLM_DEFAULT_CONFIDENCE = 0.7


# ── Anchor embedding cache ────────────────────────────────────────────────────
# (cache_key, matrix[N, dim], labels[(topic_id, subtopic_id)])
_anchor_cache: tuple[tuple, object, list[tuple[str, str]]] | None = None


def reset_cache() -> None:
    """Drop the cached anchor matrix (tests / embedder reconfiguration)."""
    global _anchor_cache
    _anchor_cache = None


def _cache_key() -> tuple:
    return (
        (settings.embedding_provider or "local").lower(),
        settings.local_embedding_model,
        settings.openai_embedding_model,
        taxonomy.CONTENT_TAXONOMY_VERSION,
    )


def _normalize(arr):
    import numpy as np

    a = np.asarray(arr, dtype="float32")
    if a.ndim == 1:
        a = a.reshape(1, -1)
    norms = np.linalg.norm(a, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    return a / norms


def _get_anchor_matrix():
    """Return ``(matrix, labels)`` of normalized anchor vectors.

    Dynamic-sub-topic topics (Rooms Described) are excluded β€” rooms are detected
    by heading, not by anchor similarity, to avoid stealing element observations.
    """
    global _anchor_cache
    key = _cache_key()
    if _anchor_cache is not None and _anchor_cache[0] == key:
        return _anchor_cache[1], _anchor_cache[2]

    from backend.llm.embeddings import get_embedder

    rows = [
        (tid, sid, anchor)
        for (tid, sid, anchor) in taxonomy.iter_anchor_units()
        if not taxonomy.has_dynamic_subtopics(tid)
    ]
    texts = [taxonomy.build_topic_anchor_text(tid, sid) for tid, sid, _ in rows]
    labels = [(tid, sid) for tid, sid, _ in rows]
    vecs = get_embedder().embed_documents(texts)
    matrix = _normalize(vecs)
    _anchor_cache = (key, matrix, labels)
    return matrix, labels


# ── Room detection ────────────────────────────────────────────────────────────
_ROOM_TRIGGER_RE = re.compile(
    r"\b(" + "|".join(
        re.escape(trig)
        for triggers in taxonomy.ROOM_LEXICON.values()
        for trig in triggers
    ) + r")\b",
    re.IGNORECASE,
)


def _detect_room_subtopic(text: str) -> str:
    """Return a room sub-topic slug when the text opens with a room heading.

    Room-by-room descriptions lead with the room name ("Kitchen: ...",
    "Front First Floor Bedroom - ..."); inline element mentions ("the kitchen
    units are cracked") are long, non-heading lines and are not routed here.
    """
    if not settings.content_room_detection_enabled:
        return ""
    first_line = (text or "").strip().splitlines()[0] if text.strip() else ""
    # Heading = text before the first colon/dash if short, else the whole first line.
    head = re.split(r"[:\-\u2013\u2014]", first_line, maxsplit=1)[0].strip()
    candidate = head if head else first_line
    if not candidate or len(candidate) > _ROOM_HEADING_MAX_CHARS:
        return ""
    # Strip leading list markers ("1.", "-", "*", bullets).
    candidate = re.sub(r"^[\s\-*\u2022\d.\)]+", "", candidate).strip()
    if not candidate or not _ROOM_TRIGGER_RE.search(candidate):
        return ""
    # Resolve to a stable seed room id (kitchen, bathroom, bedroom, ...) so the
    # taxonomy, note router, and catalog stay aligned.
    return taxonomy.base_room_for(candidate) or taxonomy.normalize_room_subtopic_id(candidate)


# ── Embedding classification ────────────────────────────────────────────────
def _score_rows(query_vecs):
    """Return the full [n_texts, n_anchors] cosine score matrix."""
    import numpy as np

    matrix, labels = _get_anchor_matrix()
    q = _normalize(query_vecs)
    scores = q @ np.asarray(matrix).T  # cosine (all normalized)
    return scores, labels


def _best_for_row(scores_row, labels, *, topic_id: str = "") -> tuple[str, str, float]:
    """Argmax anchor for one score row, optionally restricted to a topic."""
    import numpy as np

    row = np.asarray(scores_row)
    if topic_id:
        idxs = [i for i, (tid, _sid) in enumerate(labels) if tid == topic_id]
        if not idxs:
            return topic_id, "", 0.0
        sub = row[idxs]
        j = int(np.argmax(sub))
        best_i = idxs[j]
        return labels[best_i][0], labels[best_i][1], float(row[best_i])
    best_i = int(np.argmax(row))
    return labels[best_i][0], labels[best_i][1], float(row[best_i])


# ── Public API ────────────────────────────────────────────────────────────────
def classify_text(text: str, *, section_id_hint: str = "") -> TopicClassification:
    """Classify a single text into ``(topic_id, subtopic_id)``."""
    return classify_batch([text], section_id_hints=[section_id_hint])[0]


def classify_batch(
    texts: list[str],
    *,
    section_id_hints: list[str] | None = None,
) -> list[TopicClassification]:
    """Classify many texts at once (windowed LLM call, batched embedding fallback)."""
    n = len(texts)
    hints = list(section_id_hints or [])
    hints += [""] * (n - len(hints))
    results: list[TopicClassification | None] = [None] * n
    # Themes are independent of the topic decision, so they are resolved for every
    # snippet up front β€” including the many that short-circuit on a leaf code.
    lexical_tags = [tuple(taxonomy.theme_tags_for_text(t or "")) for t in texts]

    # Pass 1 β€” structural certainties. An exact leaf code is better than anything
    # we could buy from the model, so those never reach it.
    pending: list[int] = []
    for i, raw in enumerate(texts):
        text = (raw or "").strip()
        if len(text) < _MIN_CLASSIFY_CHARS:
            results[i] = TopicClassification(*taxonomy.catch_all(), 0.0, "too_short")
            continue
        prior = taxonomy.section_prior(hints[i])
        if prior and prior[2] == "leaf":
            results[i] = TopicClassification(
                prior[0], prior[1], 0.99, "rics_leaf", theme_tags=lexical_tags[i]
            )
            continue
        pending.append(i)

    # Pass 2 β€” the LLM reads the taxonomy and places the rest by meaning.
    unresolved = pending
    if pending and settings.content_classifier_llm_enabled:
        assignments = _llm_classify_batch([(texts[i] or "").strip() for i in pending])
        still: list[int] = []
        for k, i in enumerate(pending):
            assigned = assignments[k] if k < len(assignments) else None
            if assigned is None:
                still.append(i)
                continue
            topic_id, subtopic_id, confidence, llm_tags = assigned
            results[i] = TopicClassification(
                topic_id,
                subtopic_id,
                confidence,
                "llm",
                needs_review=confidence < _LLM_LOW_CONFIDENCE,
                theme_tags=_merge_tags(lexical_tags[i], llm_tags),
            )
        unresolved = still

    # Pass 3 β€” deterministic fallback for whatever the model did not place.
    if unresolved:
        _fallback_classify(texts, hints, unresolved, results, lexical_tags)

    return [
        r or TopicClassification(*taxonomy.catch_all(), 0.0, "catch_all", needs_review=True)
        for r in results
    ]


def _merge_tags(lexical: tuple[str, ...], model: tuple[str, ...]) -> tuple[str, ...]:
    """Union two tag sets back into canonical vocabulary order."""
    return tuple(taxonomy.normalize_theme_tags([*lexical, *model]))


def _fallback_classify(
    texts: list[str],
    hints: list[str],
    indices: list[int],
    results: list[TopicClassification | None],
    lexical_tags: list[tuple[str, ...]],
) -> None:
    """Place texts the LLM did not resolve, writing into ``results`` in place.

    This is also the whole classifier when the LLM is disabled or no API key is
    configured, which is what keeps content mode usable offline.
    """
    scores = labels = None
    try:
        from backend.llm.embeddings import get_embedder

        batch_texts = [(texts[i] or "").strip() for i in indices]
        qvecs = get_embedder().embed_documents(batch_texts)
        scores, labels = _score_rows(qvecs)
    except Exception:  # noqa: BLE001 - never fail ingest on classification
        logger.warning(
            "Topic embedding classification unavailable; using priors/catch-all.",
            exc_info=True,
        )

    min_score = float(settings.content_topic_min_score)
    for pos, i in enumerate(indices):
        text = (texts[i] or "").strip()
        prior = taxonomy.section_prior(hints[i])

        emb_topic = emb_sub = ""
        emb_score = 0.0
        if scores is not None and labels is not None:
            emb_topic, emb_sub, emb_score = _best_for_row(scores[pos], labels)

        # Structural topic prior (e.g. parent C/D/E/F/G): keep the topic, refine
        # the sub-topic by content within that topic.
        if prior and prior[2] == "parent":
            topic_id = prior[0]
            if scores is not None and labels is not None:
                _t, sub, sub_score = _best_for_row(scores[pos], labels, topic_id=topic_id)
                sub = sub or prior[1]
                conf = max(sub_score, 0.6)
            else:
                sub, conf = prior[1], 0.6
            results[i] = TopicClassification(
                topic_id, sub, conf, "rics_parent", theme_tags=lexical_tags[i]
            )
            continue

        # No structural prior (typical for non-RICS reports) β€” content decides.
        room_sub = _detect_room_subtopic(text)
        if room_sub:
            results[i] = TopicClassification(
                taxonomy.TOPIC_ROOMS_DESCRIBED,
                room_sub,
                0.9,
                "room",
                theme_tags=lexical_tags[i],
            )
            continue
        if emb_score >= min_score and emb_topic:
            results[i] = TopicClassification(
                emb_topic, emb_sub, emb_score, "embedding", theme_tags=lexical_tags[i]
            )
            continue
        results[i] = TopicClassification(
            *taxonomy.catch_all(),
            0.0,
            "catch_all",
            needs_review=True,
            theme_tags=lexical_tags[i],
        )


# ── Optional LLM classifier ────────────────────────────────────────────────
def _taxonomy_prompt_block() -> str:
    lines: list[str] = []
    for tid in taxonomy.ORDERED_TOPIC_IDS:
        subs = taxonomy.subtopics_for_topic(tid)
        sub_str = ", ".join(f"{sid} ({label})" for sid, label in subs) or "(free-form)"
        lines.append(f"- {tid} ({taxonomy.topic_label(tid)}): {sub_str}")
    return "\n".join(lines)


_LLM_SYSTEM = (
    "You classify UK residential building-survey text into a FIXED topic taxonomy. "
    "Decide from the CONTENT of each snippet β€” the source documents do not follow a "
    "standard layout, so headings and numbering are unreliable. For each numbered "
    "snippet choose the single best topic id and sub-topic id from the list. "
    "If a snippet describes an individual room (kitchen, bathroom, a named bedroom, "
    "conservatory, ...), use topic 'rooms_described' and set subtopic to a short "
    "snake_case room slug. If a snippet is address, tenure, locality or general "
    "property-description text, it belongs to 'location_facilities', not to a "
    "building-element topic. Only use topic 'other' / subtopic 'general' when the "
    "snippet genuinely fits nothing else. "
    "Separately, list any cross-cutting THEMES the snippet raises, chosen only from "
    "the theme list. Themes are independent of the topic: a damp patch on a bedroom "
    "wall is topic 'inside'/'walls_partitions' with theme 'damp'. Use [] when the "
    "snippet raises none. "
    "Return one item for EVERY snippet, with 'confidence' between 0 and 1 reflecting "
    "how sure you are. Return ONLY JSON: {\"items\":[{\"i\":<int>,\"topic\":\"<id>\","
    "\"subtopic\":\"<id>\",\"confidence\":<number>,\"tags\":[\"<theme>\"]}]}."
)


def _theme_tag_prompt_block() -> str:
    return "\n".join(
        f"- {tag}: {desc}" for tag, desc in taxonomy.CONTENT_THEME_TAGS.items()
    )


def _llm_classify_batch(texts: list[str]) -> list[_Assignment | None]:
    """Classify snippets with the LLM, windowed so a large ingest still fits.

    Returns one ``(topic_id, subtopic_id, confidence, theme_tags)`` per input, or
    ``None`` where the model gave nothing usable so the caller can fall back. A
    failing window degrades only its own snippets.
    """
    from backend.llm import openai_client

    if not texts or not openai_client.is_available():
        return [None] * len(texts)

    out: list[_Assignment | None] = [None] * len(texts)
    size = max(1, int(settings.content_classification_window_size))
    for lo in range(0, len(texts), size):
        window = texts[lo : lo + size]
        try:
            assigned = _classify_window(window)
        except Exception:  # noqa: BLE001 - classification must never break ingest
            logger.warning(
                "LLM topic classification window [%d,%d) failed; falling back for those snippets.",
                lo,
                lo + len(window),
                exc_info=True,
            )
            continue
        for k, item in enumerate(assigned):
            out[lo + k] = item
    return out


def _classify_window(texts: list[str]) -> list[_Assignment | None]:
    """One classification call; retry once when the model returns nothing usable."""
    from backend.llm import openai_client

    cap = max(200, int(settings.content_classification_max_chars))
    numbered = "\n".join(f"[{i}] {t[:cap]}" for i, t in enumerate(texts))
    messages = [
        {"role": "system", "content": _LLM_SYSTEM},
        {
            "role": "user",
            "content": (
                f"Topics and sub-topics:\n{_taxonomy_prompt_block()}\n\n"
                f"Themes:\n{_theme_tag_prompt_block()}\n\n"
                f"Snippets:\n{numbered}"
            ),
        },
    ]
    effort = (settings.content_classification_reasoning_effort or "minimal").strip()
    for attempt in (0, 1):
        raw = openai_client.chat_json(
            messages,
            model=settings.content_classification_model or settings.discovery_model,
            temperature=0.0,
            timeout=float(settings.content_classification_timeout_seconds),
            max_tokens=int(settings.content_classification_max_tokens),
            reasoning_effort=effort,
            call_label="content_classification",
        )
        parsed = _parse_assignments(raw, len(texts))
        if any(p is not None for p in parsed):
            return parsed
        if attempt == 0:
            logger.info(
                "LLM topic classification returned nothing usable for %d snippets; retrying once.",
                len(texts),
            )
    logger.warning(
        "LLM topic classification empty after retry for %d snippets; using fallback.",
        len(texts),
    )
    return [None] * len(texts)


def _parse_assignments(raw: object, count: int) -> list[_Assignment | None]:
    """Validate a model payload into per-snippet assignments."""
    out: list[_Assignment | None] = [None] * count
    items = raw.get("items") if isinstance(raw, dict) else None
    if not isinstance(items, list):
        return out
    for item in items:
        if not isinstance(item, dict):
            continue
        try:
            idx = int(item.get("i"))
        except (TypeError, ValueError):
            continue
        if not (0 <= idx < count):
            continue
        validated = _validate_assignment(
            str(item.get("topic") or "").strip(),
            str(item.get("subtopic") or "").strip(),
        )
        if validated is None:
            continue
        out[idx] = (
            *validated,
            _coerce_confidence(item.get("confidence")),
            tuple(taxonomy.normalize_theme_tags(item.get("tags"))),
        )
    return out


def _coerce_confidence(value: object) -> float:
    """Clamp a model-reported confidence into [0, 1]; default when unusable."""
    try:
        conf = float(value)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        return _LLM_DEFAULT_CONFIDENCE
    if conf != conf:  # NaN
        return _LLM_DEFAULT_CONFIDENCE
    return max(0.0, min(1.0, conf))


def _validate_assignment(topic: str, subtopic: str) -> tuple[str, str] | None:
    if topic not in taxonomy.valid_topic_ids():
        return None
    if taxonomy.has_dynamic_subtopics(topic):
        # Rooms Described: resolve to a seed room id when recognisable, else slug.
        if not subtopic:
            return topic, taxonomy.CATCH_ALL_SUBTOPIC
        return topic, (
            taxonomy.base_room_for(subtopic) or taxonomy.normalize_room_subtopic_id(subtopic)
        )
    valid_subs = taxonomy.valid_subtopic_ids(topic)
    if subtopic in valid_subs:
        return topic, subtopic
    # Topic is valid but sub-topic isn't β€” keep the topic, drop to its catch-all-ish first sub.
    subs = taxonomy.subtopics_for_topic(topic)
    return topic, (subs[0][0] if subs else "")