Spaces:
Sleeping
Sleeping
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
|