File size: 10,628 Bytes
e561e67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fa723a
e561e67
 
7fa723a
e561e67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
334
335
"""LLM + regex sanitisation before tenant uploads are indexed for RAG / style learning.

On-disk uploads are unchanged; only text written to the vector index is sanitised.
"""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING

from app.config import settings

if TYPE_CHECKING:
    from langchain_core.documents import Document

logger = logging.getLogger(__name__)


class DocumentSanitisationError(RuntimeError):
    """Raised when sanitisation is required but cannot complete safely."""


RAG_UPLOAD_SANITISATION_SYSTEM_PROMPT = """\
You are a document sanitisation assistant for a professional property report-writing platform.

The purpose of this process is to prepare users' previously written reports for storage in a private user-specific RAG system. These reports will be used solely to learn the user's preferred writing style, report structure, technical reasoning, and professional wording.

Your task is to remove all personal, confidential, identifying, financial, legal, and transaction-specific information while preserving the technical content, report format, headings, narrative style, and professional observations.

IMPORTANT INSTRUCTIONS

- Preserve the report's structure, headings, section order, and formatting wherever possible.
- Preserve the user's writing style, sentence structure, tone, and terminology.
- Preserve all technical observations, building pathology descriptions, assumptions, caveats, and recommendations.
- Preserve generic market commentary and valuation reasoning.
- Preserve standard boilerplate text and report templates.
- Remove sensitive information entirely rather than replacing it with placeholders.
- Do not summarise, rewrite, shorten, or paraphrase unless necessary to remove identifying information.
- Do not invent any new information.
- Ensure that the resulting document contains no information that could identify a specific individual, property, company, or transaction.

REMOVE THE FOLLOWING INFORMATION

PERSONAL INFORMATION
- Client names
- Occupier names
- Tenant names
- Vendor names
- Purchaser names
- Solicitor names
- Estate agent names
- Surveyor names
- Witness names
- Signatures
- Initials where they identify a person
- Dates of birth

CONTACT INFORMATION
- Email addresses
- Telephone numbers
- Mobile numbers
- Fax numbers
- Website URLs where they identify a specific business or individual

ADDRESS INFORMATION
- Full property addresses
- Flat numbers
- House numbers
- Building names
- Street names
- Towns and cities where they identify the subject property
- Postcodes
- Correspondence addresses

PROPERTY IDENTIFIERS
- Land Registry title numbers
- UPRNs
- EPC certificate numbers
- Planning application numbers
- Building regulation references
- Local authority references
- Council tax account numbers

PROFESSIONAL IDENTIFIERS
- RICS membership numbers
- Registration numbers
- Company registration numbers
- VAT numbers
- Insurance policy numbers
- Certificate numbers
- Job numbers
- File references
- Invoice numbers

FINANCIAL INFORMATION
- Valuation figures
- Purchase prices
- Sale prices
- Premiums
- Mortgage balances
- Ground rent amounts
- Service charge amounts
- Insurance claim amounts
- Bank account details
- Sort codes
- IBAN numbers

LEGAL INFORMATION
- Tribunal case numbers
- Court claim numbers
- Lease numbers
- Policy claim references
- Solicitor reference numbers

DATES
- Inspection dates
- Report dates
- Exchange dates
- Completion dates
- Any specific dates linked to an identifiable matter or transaction

DIGITAL IDENTIFIERS
- IP addresses
- GPS coordinates
- Metadata references
- QR code content

SPECIAL CATEGORY OR CONFIDENTIAL INFORMATION
- Medical or health information
- Disability information
- Family circumstances
- Complaints
- Litigation details
- Insurance disputes
- Employment information
- Any other confidential notes

OUTPUT REQUIREMENTS

- Return only the sanitised report text.
- Preserve as much useful stylistic and technical content as possible.
- Remove all identifying and confidential information.
- Ensure the resulting text is suitable for storage in a private user-specific RAG system for personalised AI report generation.\
"""

_POSTCODE_RE = re.compile(
    r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", re.IGNORECASE
)
_ADDRESS_LINE_RE = re.compile(
    r"\b(\d{1,4}\s+[A-Za-z][A-Za-z'\-]*(?:\s+[A-Za-z][A-Za-z'\-]*){0,6}\s+"
    r"(Road|Rd|Street|St|Avenue|Ave|Lane|Ln|Drive|Dr|Crescent|Close|Place|Way|Gardens|Gdns|Court|Ct|Terrace|Terr))\b",
    re.IGNORECASE,
)
_MONEY_RE = re.compile(
    r"(£\s*\d[\d,]*(?:\.\d+)?|\b\d[\d,]*(?:\.\d+)?\s*(?:gbp|pounds)\b)",
    re.IGNORECASE,
)
_DATE_RE = re.compile(
    r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}\s+"
    r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{2,4})\b",
    re.IGNORECASE,
)
_LONG_NUMBER_RE = re.compile(r"\b\d{5,}\b")
_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
_PHONE_RE = re.compile(
    r"\b(?:\+44\s?|0)(?:\d[\s-]?){9,12}\b|\b\d{3,4}[\s-]\d{3,4}[\s-]\d{3,4}\b"
)
_URL_RE = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)


def should_sanitise_for_rag(tenant_id: str) -> bool:
    """Whether uploads for this tenant should be sanitised before vector indexing."""
    if not settings.enable_rag_upload_sanitisation:
        return False
    if (
        settings.rag_sanitisation_skip_kb_tenant
        and tenant_id == settings.knowledge_base_tenant_id
    ):
        return False
    return True


def regex_sanitise_text(text: str) -> str:
    """Deterministic redaction when the LLM path is unavailable or as a safety net."""
    t = (text or "").strip()
    if not t:
        return ""
    t = _EMAIL_RE.sub("", t)
    t = _URL_RE.sub("", t)
    t = _PHONE_RE.sub("", t)
    t = _POSTCODE_RE.sub("", t)
    t = _ADDRESS_LINE_RE.sub("", t)
    t = _MONEY_RE.sub("", t)
    t = _DATE_RE.sub("", t)
    t = _LONG_NUMBER_RE.sub("", t)
    t = re.sub(r"[ \t]{2,}", " ", t)
    t = re.sub(r"\n{3,}", "\n\n", t)
    return t.strip()


def _split_text_chunks(text: str, max_chars: int) -> list[str]:
    body = (text or "").strip()
    if not body:
        return []
    if len(body) <= max_chars:
        return [body]
    parts = re.split(r"(\n\s*\n)", body)
    chunks: list[str] = []
    current = ""
    for part in parts:
        if not part:
            continue
        candidate = current + part
        if len(candidate) <= max_chars:
            current = candidate
            continue
        if current.strip():
            chunks.append(current.strip())
        if len(part) <= max_chars:
            current = part
        else:
            for i in range(0, len(part), max_chars):
                segment = part[i : i + max_chars].strip()
                if segment:
                    chunks.append(segment)
            current = ""
    if current.strip():
        chunks.append(current.strip())
    return chunks or [body[:max_chars]]


def _llm_sanitise_chunk_sync(chunk: str, *, part: int, total: int) -> str:
    from app.llm.openai_chat import chat_completions_create

    import asyncio

    prefix = (
        f"Sanitise this excerpt from a RICS property survey report "
        f"(part {part} of {total}).\n\n"
    )
    user_content = prefix + chunk

    async def _run() -> str:
        return await chat_completions_create(
            messages=[
                {"role": "system", "content": RAG_UPLOAD_SANITISATION_SYSTEM_PROMPT},
                {"role": "user", "content": user_content},
            ],
            model=settings.chat_model,
            max_tokens=int(settings.rag_sanitisation_max_output_tokens),
            temperature=0.0,
            phase="rag_upload_sanitise",
        )

    try:
        return asyncio.run(_run())
    except RuntimeError:
        # Already inside a running loop (should not happen from ingest thread).
        import concurrent.futures

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
            return pool.submit(asyncio.run, _run()).result()


def sanitise_text_for_rag_sync(text: str, *, tenant_id: str) -> str:
    """Sanitise plain text before it is embedded into the tenant vector index."""
    if not should_sanitise_for_rag(tenant_id):
        return (text or "").strip()

    raw = (text or "").strip()
    if not raw:
        return ""

    max_chars = int(settings.rag_sanitisation_chunk_chars)
    chunks = _split_text_chunks(raw, max_chars)
    has_key = bool((settings.openai_api_key or "").strip())
    out_parts: list[str] = []

    use_llm = bool(settings.rag_sanitisation_use_llm)
    for i, chunk in enumerate(chunks, start=1):
        cleaned = ""
        if has_key and use_llm:
            try:
                cleaned = (_llm_sanitise_chunk_sync(chunk, part=i, total=len(chunks)) or "").strip()
            except Exception as exc:
                logger.warning(
                    "RAG sanitisation LLM failed tenant=%s part=%d/%d: %s",
                    tenant_id,
                    i,
                    len(chunks),
                    exc,
                )
        if not cleaned:
            cleaned = regex_sanitise_text(chunk)
            if has_key:
                logger.info(
                    "RAG sanitisation using regex fallback tenant=%s part=%d/%d",
                    tenant_id,
                    i,
                    len(chunks),
                )
        if not cleaned and settings.rag_sanitisation_fail_closed:
            raise DocumentSanitisationError(
                f"Sanitisation produced empty text for tenant={tenant_id} part={i}/{len(chunks)}"
            )
        out_parts.append(cleaned)

    combined = "\n\n".join(p for p in out_parts if p).strip()
    if not combined and settings.rag_sanitisation_fail_closed:
        raise DocumentSanitisationError(
            f"Sanitisation produced empty document for tenant={tenant_id}"
        )
    return combined or regex_sanitise_text(raw)


def sanitise_langchain_documents_sync(
    documents: list[Document],
    *,
    tenant_id: str,
) -> list[Document]:
    """Return documents whose ``page_content`` has been sanitised for RAG indexing."""
    if not should_sanitise_for_rag(tenant_id):
        return documents

    from langchain_core.documents import Document as LCDocument

    out: list[LCDocument] = []
    for doc in documents:
        meta = dict(doc.metadata or {})
        cleaned = sanitise_text_for_rag_sync(doc.page_content or "", tenant_id=tenant_id)
        meta["rag_sanitised"] = True
        out.append(LCDocument(page_content=cleaned, metadata=meta))
    return out