Spaces:
Sleeping
Sleeping
| """Post-processor that enforces the non-invention guarantee. | |
| Two-layer approach: | |
| 1. **Regex pass** (fast, synchronous) — catches numeric facts, postcodes, and | |
| named entities not present verbatim in source material. | |
| 2. **LLM grounding pass** (async, context-aware) — asks the model to identify | |
| specific invented claims that the regex cannot catch (e.g. negated context, | |
| implied measurements, misattributed property types). Falls back to regex-only | |
| when no OpenAI key is configured. | |
| Unsupported or contradictory fragments are removed from the text. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import re | |
| from dataclasses import dataclass | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Regex patterns (fast first pass) | |
| # --------------------------------------------------------------------------- | |
| _NUMBER_RE = re.compile( | |
| r""" | |
| \b # word boundary | |
| \d[\d,]* # integer or comma-separated digits | |
| (?:\.\d+)? # optional decimal part | |
| \s* # optional whitespace before unit | |
| (?: # optional unit | |
| sq\s*(?:ft|m|meters?|metres?) | | |
| sqm | m² | sqft | | |
| \% | | |
| £[\d,\.]+| # currency values | |
| (?:bed(?:room)?s?|floors?|storey|stories|storeys|years?|months?|km|m|ft) | |
| )? | |
| \b | |
| """, | |
| re.VERBOSE | re.IGNORECASE, | |
| ) | |
| # Named entity: sequences of 2+ capitalised words | |
| _NAMED_ENTITY_RE = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b") | |
| # UK postcode | |
| _POSTCODE_RE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", re.IGNORECASE) | |
| _VERIFY_TAG_RE = re.compile(r"\[VERIFY:\s*[^\]]+\]") | |
| # Domain allowlist — standard RICS/property terminology never treated as invented facts | |
| _ENTITY_ALLOWLIST: frozenset[str] = frozenset({ | |
| "ground floor", "first floor", "second floor", "third floor", "loft conversion", | |
| "the property", "this property", "subject property", | |
| "condition rating", "condition ratings", | |
| "cavity wall", "cavity walls", "solid wall", "solid walls", | |
| "flat roof", "pitched roof", "hipped roof", "gable end", | |
| "party wall", "party walls", | |
| "rising damp", "penetrating damp", | |
| "damp proof course", "damp proof membrane", | |
| "double glazing", "single glazing", "triple glazing", | |
| "central heating", "gas central heating", "oil central heating", | |
| "consumer unit", "fuse board", | |
| "mains gas", "mains water", "mains electricity", | |
| "building regulations", "planning permission", "planning consent", | |
| "energy performance certificate", | |
| "structural engineer", "building surveyor", | |
| "royal institution", "chartered surveyors", | |
| "home survey", "home buyer", | |
| "not inspected", "not applicable", | |
| "british standard", "british standards", | |
| "local authority", | |
| "north elevation", "south elevation", "east elevation", "west elevation", | |
| "front elevation", "rear elevation", "side elevation", | |
| "left hand", "right hand", | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # LLM grounding — structured output schema | |
| # --------------------------------------------------------------------------- | |
| _GROUNDING_SYSTEM = """\ | |
| You are a non-invention auditor for RICS property survey reports written by a Chartered Building Surveyor. | |
| Your task: identify specific factual claims in the GENERATED TEXT that are NOT grounded in the SOURCE MATERIAL \ | |
| (bullets + snippets), with PARTICULAR EMPHASIS on contradictions. | |
| Two violation tiers (both must be flagged): | |
| TIER 1 — CONTRADICTIONS (highest priority — flag every instance): | |
| - Any claim that disagrees with a fact in the source. Examples: text says "steel frame" but source \ | |
| says "cavity brick wall"; text says "10 Kingsley Avenue" but source says "2 Cunliffe Road"; \ | |
| text says "vacant" but source says "tenanted"; text says "single-glazed" but source says \ | |
| "double-glazed". Contradictions are NEVER acceptable, regardless of plausibility. | |
| TIER 2 — INVENTIONS (flag specific ungrounded facts): | |
| - Specific measurements, sizes, distances not in the source. | |
| - Postcodes, addresses, dates, named people, named firms, brand names, model numbers not in the source. | |
| - Specific repair costs, valuation figures, reinstatement figures not in the source. | |
| - Product/material specifications stated as fact when the source is silent. | |
| Acceptable (do NOT flag): | |
| - A claim is GROUNDED if the core fact appears in the sources, even if worded differently \ | |
| (e.g. "cavity wall" in source vs "cavity brick walls" in text — same fact). | |
| - Standard property/survey terminology ("cavity walls", "ground floor", "condition rating", \ | |
| "consumer unit", "rising damp", etc.). | |
| - General professional opinion / reasonable inference based on the observation \ | |
| ("the roof appeared in fair condition", "consistent with the property's age"). | |
| - Negated observations ("no signs of damp", "no evidence of movement") — absence of evidence is \ | |
| valid surveyor language. | |
| - Tier-appropriate hedging ("further investigation may be required", "subject to inspection"). | |
| CALIBRATION: | |
| - For TIER 1 contradictions: ALWAYS flag — no leniency. The source is authoritative; the text is wrong. | |
| - For TIER 2 inventions: prefer flagging when in doubt. A false negative (invention slipping through) \ | |
| is worse than a false positive (a borderline claim removed). The RICS report is a legal document. | |
| Return ONLY a JSON object — no markdown, no commentary: | |
| { | |
| "violations": [ | |
| { | |
| "original": "<exact substring from text to replace — must match verbatim>", | |
| "replacement": "", | |
| "reason": "<TIER 1: contradicts source 'X'> | <TIER 2: invented fact 'X' has no basis in source>" | |
| } | |
| ], | |
| "grounding_score": <float 0.0–1.0, where 1.0 = fully grounded> | |
| } | |
| If no violations are found, return: {"violations": [], "grounding_score": 1.0} | |
| """ | |
| class GroundingViolation: | |
| original: str | |
| replacement: str | |
| reason: str | |
| class GroundingResult: | |
| violations: list[GroundingViolation] | |
| grounding_score: float | |
| method: str # "llm" | "regex_fallback" | |
| # --------------------------------------------------------------------------- | |
| # LLM grounding pass (async) | |
| # --------------------------------------------------------------------------- | |
| async def _llm_grounding_check( | |
| text: str, | |
| bullets: list[str], | |
| snippets: list[str], | |
| openai_api_key: str, | |
| model: str = "gpt-4o-mini", | |
| ) -> GroundingResult: | |
| """Ask the LLM to identify claims not grounded in source material.""" | |
| bullets_block = "\n".join(f"• {b}" for b in bullets if b.strip()) or "(none)" | |
| snippets_block = "\n\n".join(s[:400] for s in snippets[:8] if s.strip()) or "(none)" | |
| user_msg = ( | |
| f"GENERATED TEXT:\n{text}\n\n" | |
| f"SOURCE BULLETS (surveyor notes — trusted):\n{bullets_block}\n\n" | |
| f"RETRIEVED SNIPPETS (from uploaded documents — trusted):\n{snippets_block}\n\n" | |
| "Identify violations. Be conservative — only flag genuine ungrounded specific facts." | |
| ) | |
| try: | |
| from app.llm.openai_chat import chat_completions_create | |
| raw = await chat_completions_create( | |
| messages=[ | |
| {"role": "system", "content": _GROUNDING_SYSTEM}, | |
| {"role": "user", "content": user_msg}, | |
| ], | |
| model=model, | |
| max_tokens=800, | |
| temperature=0.0, | |
| response_format={"type": "json_object"}, | |
| phase="grounding", | |
| ) | |
| if not raw: | |
| raw = "{}" | |
| data = json.loads(raw) | |
| violations = [ | |
| GroundingViolation( | |
| original=str(v.get("original", "")), | |
| replacement=str(v.get("replacement", "")), | |
| reason=str(v.get("reason", "")), | |
| ) | |
| for v in data.get("violations", []) | |
| if v.get("original") | |
| ] | |
| score = float(data.get("grounding_score", 1.0)) | |
| return GroundingResult(violations=violations, grounding_score=score, method="llm") | |
| except Exception as exc: | |
| logger.warning("LLM grounding check failed (%s) — using regex-only pass", exc) | |
| return GroundingResult(violations=[], grounding_score=1.0, method="regex_fallback") | |
| def _apply_violations(text: str, violations: list[GroundingViolation]) -> str: | |
| """Apply grounding violations as literal string replacements.""" | |
| for v in violations: | |
| if not v.original or v.original not in text: | |
| continue | |
| logger.info( | |
| "Non-invention (LLM): replacing %r — %s", | |
| v.original[:60], | |
| v.reason, | |
| ) | |
| text = text.replace(v.original, v.replacement, 1) | |
| return text | |
| # --------------------------------------------------------------------------- | |
| # Primary public API | |
| # --------------------------------------------------------------------------- | |
| async def async_enforce_verify( | |
| text: str, | |
| bullets: list[str], | |
| snippets: list[str], | |
| pinned_identity: dict[str, str] | None = None, | |
| openai_api_key: str = "", | |
| model: str = "gpt-4o-mini", | |
| ) -> str: | |
| """Full two-layer grounding check (regex + LLM). | |
| Layer 1 — regex: catches postcodes, bare numbers, and capitalised named | |
| entities not found verbatim in the source. Fast and free. | |
| Layer 2 — LLM: context-aware grounding. Understands negation ("no signs | |
| of damp"), property-type contradictions, and invented measurements that | |
| slip past the regex. Skipped when ``openai_api_key`` is empty. | |
| Args: | |
| text: Raw LLM-generated section text. | |
| bullets: Surveyor's inspection notes (trusted source). | |
| snippets: Retrieved document chunks (trusted source). | |
| openai_api_key: When non-empty, enables the LLM grounding pass. | |
| model: OpenAI model used for the LLM pass. | |
| Returns: | |
| Text with unverifiable claims replaced by approved phrases. | |
| """ | |
| # Layer 1 — fast regex pass (always runs) | |
| text = enforce_verify( | |
| text=text, | |
| bullets=bullets, | |
| snippets=snippets, | |
| pinned_identity=pinned_identity, | |
| ) | |
| if not openai_api_key or not text.strip(): | |
| return _strip_missing_fact_phrases(text) | |
| # Layer 2 — LLM grounding pass | |
| result = await _llm_grounding_check( | |
| text=text, | |
| bullets=bullets, | |
| snippets=snippets, | |
| openai_api_key=openai_api_key, | |
| model=model, | |
| ) | |
| if result.violations: | |
| logger.info( | |
| "LLM grounding: %d violation(s) found, score=%.2f", | |
| len(result.violations), | |
| result.grounding_score, | |
| ) | |
| text = _apply_violations(text, result.violations) | |
| else: | |
| logger.debug("LLM grounding: fully grounded (score=%.2f)", result.grounding_score) | |
| return _strip_missing_fact_phrases(text) | |
| def enforce_verify( | |
| text: str, | |
| bullets: list[str], | |
| snippets: list[str], | |
| pinned_identity: dict[str, str] | None = None, | |
| ) -> str: | |
| """Synchronous regex-only grounding pass. | |
| Use :func:`async_enforce_verify` in async contexts for the full | |
| two-layer check (regex + LLM). | |
| All intermediate replacements use null-byte slot tokens so that | |
| replacement phrases (which contain capitalised words) are never | |
| re-processed by subsequent regex passes. | |
| Checks: | |
| 1. Legacy [VERIFY: …] tags | |
| 2. UK postcodes | |
| 3. Numeric facts (numbers + units, with digit-only fallback) | |
| 4. Multi-word capitalised named entities (with RICS domain allowlist | |
| and sub-phrase matching) | |
| """ | |
| # Null-byte tokens: replacement text is stashed here and resolved at the | |
| # end, preventing replacement phrases from being matched again. | |
| _slots: dict[str, str] = {} | |
| _ctr = [0] | |
| def _slot(final: str) -> str: | |
| key = f"\x00S{_ctr[0]}\x00" | |
| _slots[key] = final | |
| _ctr[0] += 1 | |
| return key | |
| # Stash legacy [VERIFY: …] tags. These are internal scaffolding tokens from | |
| # older prompts; they should not leak to users. | |
| text = _VERIFY_TAG_RE.sub(lambda _: _slot(""), text) | |
| pinned_values = [ | |
| str(v).strip() | |
| for v in (pinned_identity or {}).values() | |
| if isinstance(v, str) and str(v).strip() | |
| ] | |
| source = " ".join(bullets + snippets + pinned_values).lower() | |
| # --- Pass 1: UK postcodes --- | |
| def _pc(m: re.Match[str]) -> str: | |
| raw = m.group(1) | |
| normalised = re.sub(r"\s+", " ", raw).strip().upper() | |
| if _word_boundary_present(normalised.lower(), source): | |
| return raw | |
| logger.debug("Non-invention (regex): replacing postcode '%s'", raw) | |
| return _slot("") | |
| text = _POSTCODE_RE.sub(_pc, text) | |
| # --- Pass 2: numeric facts --- | |
| def _num(m: re.Match[str]) -> str: | |
| raw = m.group(0) | |
| normalised = re.sub(r"\s+", " ", raw).strip().lower() | |
| if _word_boundary_present(normalised, source): | |
| return raw | |
| # Fallback: check bare digit(s) — handles unit pluralisation | |
| # (e.g. "3 bedrooms" in text vs "3 bedroom" in source). | |
| digit_m = re.match(r"[\d,]+(?:\.\d+)?", normalised) | |
| if digit_m and _word_boundary_present(digit_m.group(0), source): | |
| return raw | |
| logger.debug("Non-invention (regex): replacing numeric fact '%s'", raw) | |
| return _slot("") | |
| text = _NUMBER_RE.sub(_num, text) | |
| # --- Pass 3: named entities --- | |
| def _ent(m: re.Match[str]) -> str: | |
| raw = m.group(0) | |
| lower = raw.lower() | |
| # Direct allowlist hit | |
| if lower in _ENTITY_ALLOWLIST: | |
| return raw | |
| # Sub-phrase allowlist — e.g. "The Ground Floor" → check "ground floor" | |
| words = lower.split() | |
| for i in range(len(words)): | |
| for j in range(i + 2, len(words) + 1): | |
| if " ".join(words[i:j]) in _ENTITY_ALLOWLIST: | |
| return raw | |
| if _word_boundary_present(lower, source): | |
| return raw | |
| logger.debug("Non-invention (regex): replacing named entity '%s'", raw) | |
| return _slot("") | |
| text = _NAMED_ENTITY_RE.sub(_ent, text) | |
| # Resolve all slots to their final text | |
| for key, val in _slots.items(): | |
| text = text.replace(key, val) | |
| return text | |
| # --------------------------------------------------------------------------- | |
| # Internal helpers | |
| # --------------------------------------------------------------------------- | |
| def _word_boundary_present(needle: str, haystack: str) -> bool: | |
| pattern = r"(?<!\w)" + re.escape(needle) + r"(?!\w)" | |
| return re.search(pattern, haystack) is not None | |
| _TOKEN_RE = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?") | |
| def _ngrams(tokens: list[str], n: int) -> set[tuple[str, ...]]: | |
| if n <= 0 or len(tokens) < n: | |
| return set() | |
| return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)} | |
| def verbatim_overlap_ratio(text: str, sources: list[str], n: int = 6) -> float: | |
| """Fraction of n-token windows in ``text`` that appear verbatim in any | |
| source. | |
| Returns 0.0 when ``text`` has fewer than ``n`` tokens or when ``sources`` | |
| is empty. Used as a soft audit signal at low AI-involvement tiers — a low | |
| overlap there means the model paraphrased instead of quoting the firm's | |
| standard wording. We log a warning rather than regenerate to avoid | |
| doubling cost on every section; the metric is mainly for observability | |
| and tuning. | |
| """ | |
| if not text or not sources: | |
| return 0.0 | |
| text_tokens = [t.lower() for t in _TOKEN_RE.findall(text)] | |
| if len(text_tokens) < n: | |
| return 0.0 | |
| text_grams = _ngrams(text_tokens, n) | |
| if not text_grams: | |
| return 0.0 | |
| src_grams: set[tuple[str, ...]] = set() | |
| for src in sources: | |
| if not src: | |
| continue | |
| src_tokens = [t.lower() for t in _TOKEN_RE.findall(src)] | |
| src_grams.update(_ngrams(src_tokens, n)) | |
| if not src_grams: | |
| return 0.0 | |
| matched = sum(1 for g in text_grams if g in src_grams) | |
| return matched / len(text_grams) | |
| def _strip_missing_fact_phrases(text: str) -> str: | |
| """Remove hard-coded missing-data filler phrases from output text.""" | |
| if not text: | |
| return text | |
| cleaned = re.sub( | |
| r"\bInformation not provided in source document\b[.,;:!?]*", | |
| "", | |
| text, | |
| flags=re.IGNORECASE, | |
| ) | |
| cleaned = re.sub( | |
| r"\bWe were unable to verify this during inspection\b[.,;:!?]*", | |
| "", | |
| cleaned, | |
| flags=re.IGNORECASE, | |
| ) | |
| cleaned = re.sub(r"\s{2,}", " ", cleaned) | |
| cleaned = re.sub(r"\s+([,.;:])", r"\1", cleaned) | |
| return cleaned.strip() | |
| def strip_verify_tags(text: str) -> list[str]: | |
| """Legacy helper kept for compatibility (always returns empty list).""" | |
| return [] | |
| # --------------------------------------------------------------------------- | |
| # Level-1 (Condition Report) behavioural sanitiser | |
| # --------------------------------------------------------------------------- | |
| # | |
| # RICS Level 1 is observation-only — the surveyor records condition and | |
| # condition ratings; advising on repairs/replacements is the job of a Level 2 | |
| # (HomeBuyer) or Level 3 (Building Survey). The system prompt for L1 forbids | |
| # directive phrasing, but gpt-4o-mini occasionally leaks "we recommend …", | |
| # "should be replaced", "obtain a specialist report" — language that | |
| # turns the L1 product into a partial L2. | |
| # | |
| # `_tier_validation_issues` in `app.services.generation` flags this in the | |
| # legacy LCEL path with a one-shot retry. The agentic inspector loop has no | |
| # equivalent retry — by the time the structured report is built, regenerating | |
| # would require a second tool-calling round trip per section. A regex | |
| # sanitiser is the right tool here: deterministic, free, and safe (worst | |
| # case it strips a borderline sentence, which is professionally safer than | |
| # leaking advice on a Level 1 product). | |
| _L1_ADVICE_SENTENCE_RE = re.compile( | |
| r""" | |
| [^.!?]*? # sentence body up to its terminator | |
| \b(?: # forbidden L1 advice markers | |
| we\s+(?:would\s+|strongly\s+)?recommend(?:ed|ation|ations|s)? | | |
| we\s+(?:would\s+)?advise(?:d|s)? | | |
| we\s+suggest(?:ed|s)? | | |
| you\s+should | | |
| should\s+be\s+(?:repaired|replaced|inspected|investigated|considered|undertaken|installed|carried\s+out|commissioned) | | |
| it\s+is\s+(?:recommended|advised|suggested) | | |
| consideration\s+should\s+be\s+given | | |
| further\s+investigation\s+(?:is|should\s+be)\s+(?:recommended|commissioned|undertaken|considered) | | |
| # Loose "obtain/seek/consult ... <expert noun>" within one sentence: | |
| # matches e.g. "obtain a structural engineer's report" / "seek | |
| # specialist advice" / "consult a Gas Safe engineer". | |
| (?:obtain|seek|consult)\s+(?:[\w'’-]+\s+){0,5}(?:quotation|quote|inspection|specialist|report|engineer|surveyor|advice|review) | |
| )\b | |
| [^.!?]* # rest of the sentence | |
| [.!?] # terminator | |
| \s* # trailing whitespace | |
| """, | |
| re.IGNORECASE | re.VERBOSE, | |
| ) | |
| # Residual-leakage detector. Run AFTER the sentence-level strip; if anything | |
| # advisory still remains we emit the L1 placeholder so the field is never | |
| # user-visible with leftover advice. Note: the placeholder text itself must | |
| # NOT contain any of these tokens, or the function would fall into a | |
| # self-rejecting loop. | |
| _L1_ADVICE_KEYWORDS_RE = re.compile( | |
| r""" | |
| \b(?: | |
| we\s+recommend | | |
| we\s+advise | | |
| we\s+suggest | | |
| recommend(?:ed|ation|ations|s)? | | |
| should\s+be\s+(?:repaired|replaced|inspected|investigated|undertaken|commissioned) | | |
| it\s+is\s+(?:recommended|advised|suggested) | | |
| further\s+investigation\s+(?:is|should\s+be) | | |
| you\s+should | | |
| consult\s+(?:a|an|the\s+)?(?:specialist|engineer|surveyor) | | |
| obtain\s+(?:a|an|the\s+)?(?:specialist|quotation) | | |
| seek\s+specialist | |
| )\b | |
| """, | |
| re.IGNORECASE | re.VERBOSE, | |
| ) | |
| # Approved L1 placeholder. Cannot contain any token in | |
| # `_L1_ADVICE_KEYWORDS_RE` (otherwise the leakage check would reject our | |
| # own placeholder). Wording mirrors the L1 system-prompt directive. | |
| _L1_PLACEHOLDER = "Observation only — no repair advice issued at Level 1." | |
| def strip_l1_advice(text: str) -> str: | |
| """Remove repair/advice sentences from Level-1 (Condition Report) outputs. | |
| Level 1 is observation-only by RICS definition. This sanitiser drops | |
| whole sentences that contain advice markers ("we recommend", "should be | |
| replaced", "obtain a specialist report"), preserving the surrounding | |
| observation prose. If every sentence is stripped, returns an | |
| L1-appropriate placeholder so the field is never silently empty. | |
| Used by both the legacy LCEL pipeline (post-`_tier_validation_issues`) | |
| and the agentic inspector loop (post-grounding-guard) to give the | |
| agentic path the same tier behavioural enforcement the legacy path | |
| has had since the original ai-level work. | |
| Args: | |
| text: Raw LLM-generated text for a Level-1 section/field. | |
| Returns: | |
| Text with advice sentences removed; or an L1 placeholder when | |
| the input was entirely advisory. | |
| """ | |
| if not text or not text.strip(): | |
| return text | |
| # Replace each matched advice sentence with a single space — NOT an empty | |
| # string. The non-greedy `[^.!?]*?` prefix consumes the whitespace that | |
| # follows the previous sentence's terminator (so the engine can position | |
| # at the `\b` before the advice marker), and the trailing `\s*` consumes | |
| # whitespace after this sentence's terminator. Replacing with `""` would | |
| # collapse adjacent sentences into one (e.g. "Walls are sound.Floors are | |
| # level."); the `\s{2,}` cleanup below only fixes 2+ spaces, not zero | |
| # spaces. The single-space replacement always preserves a separator, | |
| # and the `\s{2,}` collapse + `.strip()` clean up any doubled spaces or | |
| # leading/trailing whitespace introduced at the boundaries of the text. | |
| cleaned = _L1_ADVICE_SENTENCE_RE.sub(" ", text) | |
| cleaned = re.sub(r"\s{2,}", " ", cleaned).strip() | |
| if not cleaned or _L1_ADVICE_KEYWORDS_RE.search(cleaned): | |
| return _L1_PLACEHOLDER | |
| return cleaned | |
| def strip_l1_advice_payload(payload: dict[str, str]) -> dict[str, str]: | |
| """Apply :func:`strip_l1_advice` to every string field of a submit payload. | |
| Mirrors the field set used by the agentic ``submit_inspection_section`` | |
| tool: ``executive_summary``, ``property_description``, | |
| ``condition_assessment``, ``defects_and_risks``, ``recommendations``. | |
| Unknown / non-string fields are passed through unchanged so this stays | |
| safe for partial payloads. | |
| """ | |
| out: dict[str, str] = {} | |
| for key, value in payload.items(): | |
| if isinstance(value, str) and value.strip(): | |
| out[key] = strip_l1_advice(value) | |
| else: | |
| out[key] = value | |
| return out | |