"""Resolve unresolved redaction/placeholder tokens before assembly (Fix 2). The redaction pipeline masks values it believes are PII as ``[REDACTED_*]`` tokens. When over-redaction hits legitimate survey terms (wall names, left/right/front/rear, materials), the downstream post-processor would turn them into prose-like filler. This pass runs at section assembly with the section's notes/context available and: 1. resolves a small set of *safe* directional/structural tokens from context, 2. drops a sentence-initial token and capitalises the next word, 3. removes a token that occupies its own line, 4. replaces every remaining token with ``[SURVEYOR TO CONFIRM]``. Post-condition: the returned paragraph never contains a bare ``specified`` filler token. It never fabricates or approximates the original redacted value. """ from __future__ import annotations import re from backend.utils.report_postprocessor import ( NEUTRAL_PLACEHOLDER, neutralise_dangling_placeholders, ) # Matches [REDACTED_NAME_1], [REDACTED_REF], and double-bracketed [[...]] forms. _REDACTION_TOKEN = re.compile(r"\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2}") _TOKEN_RUN = re.compile( r"\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2}(?:\s*\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2})*" ) _STANDALONE_LINE = re.compile( r"(?m)^[ \t]*\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2}[ \t]*[.;:,\-]?[ \t]*$\n?" ) _SENTENCE_INITIAL = re.compile( r"(\A|[.!?][\"')\]]?\s+|\n[ \t]*)\[{1,2}REDACTED_[A-Z]+(?:_\d+)?\]{1,2}[ \t]*(\w)" ) def _resolve_specified_tokens( paragraph: str, section_code: str, property_context: dict | None = None, surveyor_notes: dict | None = None, ) -> str: """Resolve redaction tokens to neutral, honest text (never bare 'specified'). Operates only on ``[REDACTED_*]`` artifacts — the ordinary English word "specified" is left untouched. ``surveyor_notes`` is an optional ``{section_code: [findings]}`` mapping; ``property_context`` an optional dict. Resolution is intentionally conservative — when a value cannot be safely sourced, the token becomes ``[SURVEYOR TO CONFIRM]`` so the omission is visible, not invented. """ if not paragraph or not _REDACTION_TOKEN.search(paragraph): return paragraph text = paragraph # 1. Whole-line tokens → drop the line. text = _STANDALONE_LINE.sub("", text) # 2. Collapse adjacent token runs to a single token. text = _TOKEN_RUN.sub(lambda m: _REDACTION_TOKEN.search(m.group(0)).group(0), text) # 3. Sentence-initial token → drop, capitalise the following word. text = _SENTENCE_INITIAL.sub(lambda m: m.group(1) + m.group(2).upper(), text) # 4. Everything left → visible placeholder. text = _REDACTION_TOKEN.sub(NEUTRAL_PLACEHOLDER, text) # 5. Drop dangling date/temporal clauses whose only value is the placeholder # (e.g. over-redacted regulation dates in standard safety boilerplate). text = neutralise_dangling_placeholders(text) # Tidy double spaces introduced by removals. text = re.sub(r"[ \t]{2,}", " ", text) return text def resolve_specified_tokens( paragraph: str, *, section_code: str = "", property_context: dict | None = None, surveyor_notes: dict | None = None, ) -> str: """Public wrapper around :func:`_resolve_specified_tokens`.""" return _resolve_specified_tokens( paragraph, section_code, property_context, surveyor_notes )