Spaces:
Runtime error
Runtime error
| """Note pre-processing: typo normalisation + unverified-term flagging. | |
| Runs BEFORE the LLM sees the notes. Two jobs: | |
| 1. Normalise a small set of known transcription errors (lather -> lath, etc.). | |
| 2. Flag genuinely unrecognised technical terms so the model does not publish a | |
| transcription artefact ("astratin support") as if it were valid RICS | |
| terminology. | |
| Design choice: we deliberately do NOT spell-check every word against a general | |
| English dictionary. Surveying prose is full of legitimate low-frequency terms | |
| (efflorescence, flaunching, bressummer) that a generic dictionary flags as | |
| errors, which would be worse than the disease. Instead we flag conservatively: | |
| a long, non-approved word is only flagged when it is used in a *structural-term | |
| position* (immediately followed by a structural element noun such as | |
| "support", "beam", "lintel"), which is exactly the "astratin support" pattern, | |
| or when it is an obvious non-word (no vowels, repeated triples). This keeps the | |
| false-positive rate near zero. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from functools import lru_cache | |
| from pathlib import Path | |
| _DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "surveying_approved_terms.json" | |
| # Structural-element nouns that, when preceded by an unknown long word, strongly | |
| # imply the unknown word was meant to be a (mis-transcribed) technical term. | |
| _STRUCTURAL_NOUNS: frozenset[str] = frozenset( | |
| { | |
| "support", "supports", "beam", "beams", "lintel", "lintels", "joist", | |
| "joists", "prop", "props", "member", "members", "bracket", "brackets", | |
| "truss", "trusses", "purlin", "purlins", "rafter", "rafters", | |
| "padstone", "wallplate", "plate", "girder", "column", "post", | |
| } | |
| ) | |
| # Known transcription errors -> canonical form. Applied case-insensitively as | |
| # whole words. Keep this list tight and unambiguous. | |
| _TYPO_NORMALISATIONS: dict[str, str] = { | |
| "lather": "lath", | |
| "wasteness": "deterioration", | |
| } | |
| _WORD_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") | |
| def approved_terms() -> frozenset[str]: | |
| """Load the approved surveying glossary (lowercased tokens).""" | |
| try: | |
| raw = json.loads(_DATA_PATH.read_text(encoding="utf-8")) | |
| terms = raw.get("terms", []) if isinstance(raw, dict) else [] | |
| return frozenset(str(t).strip().lower() for t in terms if str(t).strip()) | |
| except Exception: # noqa: BLE001 — never block generation on a bad glossary | |
| return frozenset() | |
| def _is_obvious_nonword(token: str) -> bool: | |
| """Heuristic for a token that cannot be a real English/technical word.""" | |
| low = token.lower() | |
| if len(low) < 6: | |
| return False | |
| if not re.search(r"[aeiou]", low): # no vowels at all | |
| return True | |
| if re.search(r"(.)\1\1", low): # three identical letters in a row | |
| return True | |
| return False | |
| def is_unverified_term(token: str, *, next_token: str | None = None) -> bool: | |
| """Return True if ``token`` should be flagged as unverified terminology.""" | |
| low = token.lower().strip("-'") | |
| if len(low) <= 5 or not low.isalpha(): | |
| return False | |
| if low in approved_terms(): | |
| return False | |
| if _is_obvious_nonword(low): | |
| return True | |
| if next_token and next_token.lower().strip(".,;:-'") in _STRUCTURAL_NOUNS: | |
| return True | |
| return False | |
| def preprocess_notes(text: str) -> str: | |
| """Normalise known typos and wrap unverified terms with a flag token. | |
| The flag format matches the UNKNOWN TERM RULE in the generation prompt: | |
| ``[UNVERIFIED_TERM: "astratin"]``. | |
| """ | |
| if not text or not text.strip(): | |
| return text | |
| # 1. Typo normalisation (whole-word, case-insensitive, preserve nothing | |
| # fancy — these are unambiguous corrections). | |
| def _norm(m: re.Match[str]) -> str: | |
| return _TYPO_NORMALISATIONS[m.group(0).lower()] | |
| if _TYPO_NORMALISATIONS: | |
| pattern = re.compile( | |
| r"\b(" + "|".join(re.escape(k) for k in _TYPO_NORMALISATIONS) + r")\b", | |
| re.IGNORECASE, | |
| ) | |
| text = pattern.sub(_norm, text) | |
| # 2. Unverified-term flagging. Walk tokens with lookahead at the next token. | |
| matches = list(_WORD_RE.finditer(text)) | |
| if not matches: | |
| return text | |
| out: list[str] = [] | |
| cursor = 0 | |
| for i, m in enumerate(matches): | |
| tok = m.group(0) | |
| nxt = matches[i + 1].group(0) if i + 1 < len(matches) else None | |
| if is_unverified_term(tok, next_token=nxt): | |
| out.append(text[cursor : m.start()]) | |
| out.append(f'[UNVERIFIED_TERM: "{tok}"]') | |
| cursor = m.end() | |
| out.append(text[cursor:]) | |
| return "".join(out) | |
| def preprocess_bullets(bullets: list[str]) -> list[str]: | |
| """Apply :func:`preprocess_notes` to each bullet.""" | |
| return [preprocess_notes(b) if isinstance(b, str) else b for b in bullets] | |