Spaces:
Runtime error
Runtime error
File size: 12,340 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 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 | """Pure RAG semantic classification for surveyor notes.
Each observation is embedded once and compared against 54 canonical RICS L3
subsection anchor vectors using strict L2-normalized cosine similarity. The top
match must meet ``settings.confidence_threshold`` (default 0.72) and lead the
runner-up by at least ``settings.note_rag_ambiguity_margin`` (default 0.05),
otherwise the note is ``UNASSIGNED``.
"""
from __future__ import annotations
import logging
import re
import threading
import numpy as np
from backend.config import settings
from backend.core import embeddings as _embeddings
from backend.core.notes_routing import RAG_CLASSIFICATION_GUIDANCE, parse_explicit_section_prefix
from backend.core.rics_canonical_l3 import (
build_section_anchor_text,
iter_canonical_leaf_sections,
valid_leaf_section_ids,
)
from backend.models.schema import TemplateSchema
from backend.models.section import SectionNote
logger = logging.getLogger(__name__)
UNASSIGNED = "UNASSIGNED"
class _SectionAnchorCache:
"""In-memory matrix of normalized anchor embeddings keyed by leaf section id."""
def __init__(self) -> None:
self.section_ids: list[str] = []
self.matrix: np.ndarray | None = None
@property
def ready(self) -> bool:
return self.matrix is not None and len(self.section_ids) > 0
def clear(self) -> None:
self.section_ids = []
self.matrix = None
_cache = _SectionAnchorCache()
_anchor_lock = threading.Lock()
def anchors_ready() -> bool:
"""True when the 54-leaf anchor matrix is loaded in memory."""
return _cache.ready
def initialize_section_anchors() -> int:
"""Embed and cache canonical anchors once (startup or first lazy load)."""
if _cache.ready:
return len(_cache.section_ids)
with _anchor_lock:
if _cache.ready:
return len(_cache.section_ids)
leaves = iter_canonical_leaf_sections()
section_ids = [row[0] for row in leaves]
anchor_texts = [build_section_anchor_text(sid) for sid in section_ids]
embedder = _embeddings.get_embedder()
vectors = embedder.embed_documents(anchor_texts)
matrix = _normalize_anchor_matrix(np.asarray(vectors, dtype=np.float32))
_cache.section_ids = section_ids
_cache.matrix = matrix
logger.info(
"Initialized %d canonical section anchor vectors (dim=%d, cached)",
len(section_ids),
matrix.shape[1],
)
return len(section_ids)
def reset_section_anchors() -> None:
"""Clear cached anchors (tests / embedder reload)."""
with _anchor_lock:
_cache.clear()
def _ensure_anchors() -> None:
"""Lazy load — never re-embed when the startup cache is warm."""
if not _cache.ready:
initialize_section_anchors()
def _l2_normalize(vec: np.ndarray) -> np.ndarray:
"""Return a unit vector; zero vectors are returned unchanged."""
norm = float(np.linalg.norm(vec))
if norm == 0.0:
return vec
return vec / norm
def _normalize_anchor_matrix(matrix: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
norms = np.where(norms == 0, 1.0, norms)
return matrix / norms
def _cosine_similarity_scores(note_vec: np.ndarray, anchor_matrix: np.ndarray) -> np.ndarray:
"""Strict cosine similarity between one note vector and all anchor rows."""
note_unit = _l2_normalize(note_vec.astype(np.float32, copy=False))
anchor_unit = _normalize_anchor_matrix(anchor_matrix.astype(np.float32, copy=False))
return anchor_unit @ note_unit
def _top_two_scores(scores: np.ndarray) -> tuple[int, float, float]:
best_idx = int(np.argmax(scores))
best_score = float(scores[best_idx])
if scores.size <= 1:
return best_idx, best_score, -1.0
masked = scores.copy()
masked[best_idx] = -np.inf
second_idx = int(np.argmax(masked))
return best_idx, best_score, float(scores[second_idx])
def classify_note_semantically(text: str) -> tuple[str, float]:
"""Return ``(section_id | UNASSIGNED, score)`` for one note."""
from backend.core.notes_keyword_router import classify_note_cascade
section_id, score, _ = classify_note_cascade(text)
if section_id != UNASSIGNED:
return section_id, score
if settings.note_routing_mode.strip().lower() == "rag":
return _classify_note_rag(text)
return UNASSIGNED, 0.0
def _classify_note_rag(text: str) -> tuple[str, float]:
"""Embedding anchor classifier with ambiguity margin (Priority 3 fallback)."""
_ensure_anchors()
matrix = _cache.matrix
if matrix is None or not _cache.section_ids:
return UNASSIGNED, 0.0
# Anchor the query with strict L3 disambiguation guidance (prepended, not embedded
# in user-visible note text).
query_text = f"{RAG_CLASSIFICATION_GUIDANCE}\n\nNote: {text or ''}"
note_vec = np.asarray(_embeddings.get_embedder().embed_query(query_text), dtype=np.float32)
if float(np.linalg.norm(note_vec)) == 0.0:
return UNASSIGNED, 0.0
scores = _cosine_similarity_scores(note_vec, matrix)
best_idx, best_score, second_score = _top_two_scores(scores)
threshold = settings.confidence_threshold
margin = settings.note_rag_ambiguity_margin
score_gap = best_score - second_score if second_score >= 0.0 else margin
if best_score < threshold or score_gap < margin:
if best_score >= threshold and score_gap < margin:
logger.debug(
"Ambiguous note routing (top=%s %.3f second=%.3f gap=%.3f): %s",
_cache.section_ids[best_idx],
best_score,
second_score,
score_gap,
(text or "")[:120],
)
return UNASSIGNED, best_score
return _cache.section_ids[best_idx], best_score
def route_note_to_section(text: str, schema: TemplateSchema | None = None) -> str:
"""Classify one note → canonical leaf id or ``UNASSIGNED``."""
_ = schema
section_id, _ = classify_note_semantically(text)
return section_id
def _is_hazard_section(section_id: str) -> bool:
"""True for I-group leaves (risks to building / people / grounds).
Hazards are safety-critical: a hazard mention is always filed in its own
section and is never silently absorbed into the surrounding element via
context inheritance.
"""
return bool(section_id) and section_id.upper().startswith("I")
def _iter_note_lines(raw_notes: str) -> list[str]:
"""Split raw notes into individual lines for per-line cascade routing."""
raw = (raw_notes or "").replace("\r\n", "\n").strip()
if not raw:
return []
blocks = [b.strip() for b in re.split(r"\n{2,}", raw) if b.strip()]
if len(blocks) <= 1:
blocks = [b.strip(" \t-*•") for b in raw.split("\n") if b.strip(" \t-*•")]
lines: list[str] = []
for block in blocks:
lines.extend(_extract_observations(block))
return lines
def _extract_observations(block: str) -> list[str]:
raw_lines = re.split(r"\n|(?<=\.)\s+(?=[A-Z])", block)
observations: list[str] = []
for line in raw_lines:
line = line.strip().lstrip("•-*·▸▹◆►").strip()
if line and len(line) > 5:
observations.append(line)
return observations or ([block.strip()] if block.strip() else [])
def _extract_rating_value(text: str, schema: TemplateSchema) -> str | None:
if not schema.rating_system.detected:
return None
for rv in schema.rating_system.values:
pattern = r"(?<!\w)" + re.escape(rv.value) + r"(?!\w)"
if re.search(pattern, text, re.IGNORECASE):
return rv.value
if schema.rating_system.type == "numeric" or any(
v.value.isdigit() for v in schema.rating_system.values
):
for rv in schema.rating_system.values:
if rv.value.isdigit():
patterns = [
r"(?i)\bcr\s*" + rv.value + r"\b",
r"(?i)\brating\s+" + rv.value + r"\b",
r"(?i)\bcondition\s+" + rv.value + r"\b",
]
for p in patterns:
if re.search(p, text):
return rv.value
return None
def _split_blocks(raw_notes: str) -> list[str]:
raw = (raw_notes or "").replace("\r\n", "\n")
blocks = [b.strip() for b in re.split(r"\n{2,}", raw.strip()) if b.strip()]
if len(blocks) <= 1:
blocks = [b.strip(" \t-*•") for b in raw.split("\n") if b.strip(" \t-*•")]
return blocks
def parse_notes_to_sections(raw_notes: str, schema: TemplateSchema) -> list[SectionNote]:
"""Route surveyor notes into per-leaf buckets with block-level context.
Surveyors write one element per paragraph but pack several sentences into it
(``Chimney stack: ... Flashing is cracked. Repairs and repointing required.``).
Only the first sentence carries the section label/code, so we route each block
left to right and apply a *tier lock*:
* A **strong** signal — an explicit code (``F6,``), an element label
(``Main roof:``), or curated high-confidence vocabulary (``ceilings``) —
establishes or overrides the block's running section.
* A **weak** general-keyword match (bare ``repointing``, ``cracking``) does
NOT override an already-established context: it inherits the block section.
It only sets the context when none exists yet (e.g. the block's first line).
* A **hazard** (I-group) match is always filed in its own section but never
hijacks the running element context for later sentences.
* A sentence with no signal inherits the block's running section.
This stops generic boilerplate (``repairs and repointing``) inside a chimney
block from being dragged to the walls, without hardcoding any note wording.
"""
from backend.core.notes_keyword_router import (
STRONG_TIERS,
classify_note_cascade_with_tier,
)
allowed = valid_leaf_section_ids()
section_accumulator: dict[str, list[str]] = {}
section_ratings: dict[str, str | None] = {}
unassigned_blocks: list[str] = []
for block in _split_blocks(raw_notes):
current_context = UNASSIGNED
for sentence in _extract_observations(block):
section_id, tier, obs_text = classify_note_cascade_with_tier(sentence)
routed = (
section_id
if section_id != UNASSIGNED and section_id.upper() in allowed
else UNASSIGNED
)
if routed != UNASSIGNED and (tier in STRONG_TIERS or current_context == UNASSIGNED):
# Strong signal, or the first signal in the block → set/override context.
current_context = routed
target = routed
elif routed != UNASSIGNED and _is_hazard_section(routed):
# Weak hazard mid-block: file it as a hazard, keep the element flow.
target = routed
elif current_context != UNASSIGNED:
# Weak (general) keyword or no signal → inherit the running section.
target = current_context
else:
target = UNASSIGNED
if target == UNASSIGNED:
unassigned_blocks.append(obs_text)
continue
section_accumulator.setdefault(target, []).append(obs_text)
if target not in section_ratings:
rating = _extract_rating_value(obs_text, schema)
if rating:
section_ratings[target] = rating
notes: list[SectionNote] = []
for sec in schema.ordered_sections():
if sec.id.upper() not in allowed:
continue
if sec.id in section_accumulator:
obs = section_accumulator[sec.id]
notes.append(SectionNote(
section_id=sec.id,
raw_observations=obs,
text="\n".join(obs).strip(),
rating_value=section_ratings.get(sec.id),
))
if unassigned_blocks:
notes.append(SectionNote(
section_id=UNASSIGNED,
raw_observations=unassigned_blocks,
text="\n".join(unassigned_blocks).strip(),
))
return notes
|