Chaudhry Waleed
Deploy RICS v2 (senior baseline, CPU Spaces Dockerfile)
330d570
Raw
History Blame Contribute Delete
20.2 kB
"""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 "")