Spaces:
Running
Running
| """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 | |
| 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 "") | |