StormShadow308 Cursor commited on
Commit
e561e67
·
1 Parent(s): a1e5b42

feat: RAG upload sanitisation layer before vector indexing

Browse files

LLM + regex strip of PII/confidential data from tenant uploads and runtime RAG sync before embedding. On-disk files unchanged. Configurable via ENABLE_RAG_UPLOAD_SANITISATION.

Co-authored-by: Cursor <cursoragent@cursor.com>

.env.example CHANGED
@@ -48,6 +48,13 @@ OPENAI_API_KEY=sk-replace-me
48
  # GENERATION_TIMEOUT_SECONDS=3600
49
  # GENERATION_STALE_SWEEP_SECONDS=120
50
 
 
 
 
 
 
 
 
51
  # ── Database ──────────────────────────────────────────────────────────────────
52
  DATABASE_URL=sqlite+aiosqlite:///./dev.db
53
 
 
48
  # GENERATION_TIMEOUT_SECONDS=3600
49
  # GENERATION_STALE_SWEEP_SECONDS=120
50
 
51
+ # ── RAG upload sanitisation (strip PII before vector indexing; on-disk files unchanged) ─
52
+ # ENABLE_RAG_UPLOAD_SANITISATION=true
53
+ # RAG_SANITISATION_SKIP_KB_TENANT=true
54
+ # RAG_SANITISATION_CHUNK_CHARS=12000
55
+ # RAG_SANITISATION_MAX_OUTPUT_TOKENS=4096
56
+ # RAG_SANITISATION_FAIL_CLOSED=true
57
+
58
  # ── Database ──────────────────────────────────────────────────────────────────
59
  DATABASE_URL=sqlite+aiosqlite:///./dev.db
60
 
app/config.py CHANGED
@@ -504,6 +504,38 @@ class Settings(BaseSettings):
504
  ),
505
  )
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  # ── Rate limiting ────────────────────────────────────────────────────────
508
  rate_limit_generate_rpm: int = Field(
509
  default=20,
 
504
  ),
505
  )
506
 
507
+ # ── RAG upload sanitisation (PII/confidential stripping before indexing) ─
508
+ enable_rag_upload_sanitisation: bool = Field(
509
+ default=True,
510
+ description=(
511
+ "When true, tenant uploads are sanitised (LLM + regex fallback) before "
512
+ "text is embedded into the vector index for style/RAG retrieval. On-disk files are not rewritten."
513
+ ),
514
+ )
515
+ rag_sanitisation_skip_kb_tenant: bool = Field(
516
+ default=True,
517
+ description="Skip sanitisation for the reserved knowledge-base tenant (e.g. __rics_kb__).",
518
+ )
519
+ rag_sanitisation_chunk_chars: int = Field(
520
+ default=12_000,
521
+ ge=2_000,
522
+ le=50_000,
523
+ description="Max characters per LLM sanitisation call for long reports.",
524
+ )
525
+ rag_sanitisation_max_output_tokens: int = Field(
526
+ default=4096,
527
+ ge=512,
528
+ le=16_384,
529
+ description="Max tokens per sanitisation LLM response chunk.",
530
+ )
531
+ rag_sanitisation_fail_closed: bool = Field(
532
+ default=True,
533
+ description=(
534
+ "If true, ingestion/runtime RAG sync fails when sanitisation would store empty text. "
535
+ "When false, regex fallback is used even when the LLM returns nothing."
536
+ ),
537
+ )
538
+
539
  # ── Rate limiting ────────────────────────────────────────────────────────
540
  rate_limit_generate_rpm: int = Field(
541
  default=20,
app/ingest/pipeline.py CHANGED
@@ -30,6 +30,7 @@ from app.db.models import IngestStatus
30
  from app.ingest.hierarchy import build_hierarchical_documents
31
  from app.ingest.parser_docx import parse_docx
32
  from app.ingest.parser_pdf import parse_pdf
 
33
  from app.vectorstore.factory import get_vectorstore
34
 
35
  logger = logging.getLogger(__name__)
@@ -73,6 +74,21 @@ async def ingest_document(doc_id: str, file_path: Path) -> None:
73
  raw_docs = _load_file(file_path)
74
  logger.debug("doc=%s loaded %d raw document(s)", doc_id, len(raw_docs))
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ── 2. Hierarchical chunks (document → section → paragraph) ───
77
  hier = build_hierarchical_documents(
78
  raw_docs,
@@ -109,6 +125,15 @@ async def ingest_document(doc_id: str, file_path: Path) -> None:
109
  chunks_count,
110
  )
111
 
 
 
 
 
 
 
 
 
 
112
  except TimeoutError:
113
  logger.error(
114
  "Ingestion timed out doc=%s tenant=%s after %ss",
 
30
  from app.ingest.hierarchy import build_hierarchical_documents
31
  from app.ingest.parser_docx import parse_docx
32
  from app.ingest.parser_pdf import parse_pdf
33
+ from app.services.document_sanitiser import DocumentSanitisationError
34
  from app.vectorstore.factory import get_vectorstore
35
 
36
  logger = logging.getLogger(__name__)
 
74
  raw_docs = _load_file(file_path)
75
  logger.debug("doc=%s loaded %d raw document(s)", doc_id, len(raw_docs))
76
 
77
+ from app.services.document_sanitiser import (
78
+ sanitise_langchain_documents_sync,
79
+ should_sanitise_for_rag,
80
+ )
81
+
82
+ if should_sanitise_for_rag(doc.tenant_id):
83
+ logger.info(
84
+ "Sanitising doc=%s tenant=%s before RAG indexing",
85
+ doc_id,
86
+ doc.tenant_id,
87
+ )
88
+ raw_docs = sanitise_langchain_documents_sync(
89
+ raw_docs, tenant_id=doc.tenant_id
90
+ )
91
+
92
  # ── 2. Hierarchical chunks (document → section → paragraph) ───
93
  hier = build_hierarchical_documents(
94
  raw_docs,
 
125
  chunks_count,
126
  )
127
 
128
+ except DocumentSanitisationError as exc:
129
+ logger.error(
130
+ "Ingestion blocked by sanitisation doc=%s tenant=%s: %s",
131
+ doc_id,
132
+ doc.tenant_id,
133
+ exc,
134
+ )
135
+ doc.status = IngestStatus.failed
136
+ doc.error_message = f"Document sanitisation failed: {exc}"
137
  except TimeoutError:
138
  logger.error(
139
  "Ingestion timed out doc=%s tenant=%s after %ss",
app/optimization/health_warnings.py CHANGED
@@ -64,6 +64,11 @@ def collect_optimization_warnings() -> list[str]:
64
  "ENABLE_JOB_QUEUE=true requires REDIS_URL; "
65
  "generation will fall back to in-process tasks until Redis is configured."
66
  )
 
 
 
 
 
67
  return warnings
68
 
69
 
 
64
  "ENABLE_JOB_QUEUE=true requires REDIS_URL; "
65
  "generation will fall back to in-process tasks until Redis is configured."
66
  )
67
+ if settings.enable_rag_upload_sanitisation and not (settings.openai_api_key or "").strip():
68
+ warnings.append(
69
+ "ENABLE_RAG_UPLOAD_SANITISATION=true but OPENAI_API_KEY is unset; "
70
+ "uploads are indexed using regex-only redaction (weaker than the LLM sanitiser)."
71
+ )
72
  return warnings
73
 
74
 
app/services/document_sanitiser.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM + regex sanitisation before tenant uploads are indexed for RAG / style learning.
2
+
3
+ On-disk uploads are unchanged; only text written to the vector index is sanitised.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ from typing import TYPE_CHECKING
11
+
12
+ from app.config import settings
13
+
14
+ if TYPE_CHECKING:
15
+ from langchain_core.documents import Document
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class DocumentSanitisationError(RuntimeError):
21
+ """Raised when sanitisation is required but cannot complete safely."""
22
+
23
+
24
+ RAG_UPLOAD_SANITISATION_SYSTEM_PROMPT = """\
25
+ You are a document sanitisation assistant for a professional property report-writing platform.
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ IMPORTANT INSTRUCTIONS
32
+
33
+ - Preserve the report's structure, headings, section order, and formatting wherever possible.
34
+ - Preserve the user's writing style, sentence structure, tone, and terminology.
35
+ - Preserve all technical observations, building pathology descriptions, assumptions, caveats, and recommendations.
36
+ - Preserve generic market commentary and valuation reasoning.
37
+ - Preserve standard boilerplate text and report templates.
38
+ - Remove sensitive information entirely rather than replacing it with placeholders.
39
+ - Do not summarise, rewrite, shorten, or paraphrase unless necessary to remove identifying information.
40
+ - Do not invent any new information.
41
+ - Ensure that the resulting document contains no information that could identify a specific individual, property, company, or transaction.
42
+
43
+ REMOVE THE FOLLOWING INFORMATION
44
+
45
+ PERSONAL INFORMATION
46
+ - Client names
47
+ - Occupier names
48
+ - Tenant names
49
+ - Vendor names
50
+ - Purchaser names
51
+ - Solicitor names
52
+ - Estate agent names
53
+ - Surveyor names
54
+ - Witness names
55
+ - Signatures
56
+ - Initials where they identify a person
57
+ - Dates of birth
58
+
59
+ CONTACT INFORMATION
60
+ - Email addresses
61
+ - Telephone numbers
62
+ - Mobile numbers
63
+ - Fax numbers
64
+ - Website URLs where they identify a specific business or individual
65
+
66
+ ADDRESS INFORMATION
67
+ - Full property addresses
68
+ - Flat numbers
69
+ - House numbers
70
+ - Building names
71
+ - Street names
72
+ - Towns and cities where they identify the subject property
73
+ - Postcodes
74
+ - Correspondence addresses
75
+
76
+ PROPERTY IDENTIFIERS
77
+ - Land Registry title numbers
78
+ - UPRNs
79
+ - EPC certificate numbers
80
+ - Planning application numbers
81
+ - Building regulation references
82
+ - Local authority references
83
+ - Council tax account numbers
84
+
85
+ PROFESSIONAL IDENTIFIERS
86
+ - RICS membership numbers
87
+ - Registration numbers
88
+ - Company registration numbers
89
+ - VAT numbers
90
+ - Insurance policy numbers
91
+ - Certificate numbers
92
+ - Job numbers
93
+ - File references
94
+ - Invoice numbers
95
+
96
+ FINANCIAL INFORMATION
97
+ - Valuation figures
98
+ - Purchase prices
99
+ - Sale prices
100
+ - Premiums
101
+ - Mortgage balances
102
+ - Ground rent amounts
103
+ - Service charge amounts
104
+ - Insurance claim amounts
105
+ - Bank account details
106
+ - Sort codes
107
+ - IBAN numbers
108
+
109
+ LEGAL INFORMATION
110
+ - Tribunal case numbers
111
+ - Court claim numbers
112
+ - Lease numbers
113
+ - Policy claim references
114
+ - Solicitor reference numbers
115
+
116
+ DATES
117
+ - Inspection dates
118
+ - Report dates
119
+ - Exchange dates
120
+ - Completion dates
121
+ - Any specific dates linked to an identifiable matter or transaction
122
+
123
+ DIGITAL IDENTIFIERS
124
+ - IP addresses
125
+ - GPS coordinates
126
+ - Metadata references
127
+ - QR code content
128
+
129
+ SPECIAL CATEGORY OR CONFIDENTIAL INFORMATION
130
+ - Medical or health information
131
+ - Disability information
132
+ - Family circumstances
133
+ - Complaints
134
+ - Litigation details
135
+ - Insurance disputes
136
+ - Employment information
137
+ - Any other confidential notes
138
+
139
+ OUTPUT REQUIREMENTS
140
+
141
+ - Return only the sanitised report text.
142
+ - Preserve as much useful stylistic and technical content as possible.
143
+ - Remove all identifying and confidential information.
144
+ - Ensure the resulting text is suitable for storage in a private user-specific RAG system for personalised AI report generation.\
145
+ """
146
+
147
+ _POSTCODE_RE = re.compile(
148
+ r"\b([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2})\b", re.IGNORECASE
149
+ )
150
+ _ADDRESS_LINE_RE = re.compile(
151
+ r"\b(\d{1,4}\s+[A-Za-z][A-Za-z'\-]*(?:\s+[A-Za-z][A-Za-z'\-]*){0,6}\s+"
152
+ r"(Road|Rd|Street|St|Avenue|Ave|Lane|Ln|Drive|Dr|Crescent|Close|Place|Way|Gardens|Gdns|Court|Ct|Terrace|Terr))\b",
153
+ re.IGNORECASE,
154
+ )
155
+ _MONEY_RE = re.compile(
156
+ r"(£\s*\d[\d,]*(?:\.\d+)?|\b\d[\d,]*(?:\.\d+)?\s*(?:gbp|pounds)\b)",
157
+ re.IGNORECASE,
158
+ )
159
+ _DATE_RE = re.compile(
160
+ r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{1,2}\s+"
161
+ r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{2,4})\b",
162
+ re.IGNORECASE,
163
+ )
164
+ _LONG_NUMBER_RE = re.compile(r"\b\d{5,}\b")
165
+ _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
166
+ _PHONE_RE = re.compile(
167
+ r"\b(?:\+44\s?|0)(?:\d[\s-]?){9,12}\b|\b\d{3,4}[\s-]\d{3,4}[\s-]\d{3,4}\b"
168
+ )
169
+ _URL_RE = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
170
+
171
+
172
+ def should_sanitise_for_rag(tenant_id: str) -> bool:
173
+ """Whether uploads for this tenant should be sanitised before vector indexing."""
174
+ if not settings.enable_rag_upload_sanitisation:
175
+ return False
176
+ if (
177
+ settings.rag_sanitisation_skip_kb_tenant
178
+ and tenant_id == settings.knowledge_base_tenant_id
179
+ ):
180
+ return False
181
+ return True
182
+
183
+
184
+ def regex_sanitise_text(text: str) -> str:
185
+ """Deterministic redaction when the LLM path is unavailable or as a safety net."""
186
+ t = (text or "").strip()
187
+ if not t:
188
+ return ""
189
+ t = _EMAIL_RE.sub("", t)
190
+ t = _URL_RE.sub("", t)
191
+ t = _PHONE_RE.sub("", t)
192
+ t = _POSTCODE_RE.sub("", t)
193
+ t = _ADDRESS_LINE_RE.sub("", t)
194
+ t = _MONEY_RE.sub("", t)
195
+ t = _DATE_RE.sub("", t)
196
+ t = _LONG_NUMBER_RE.sub("", t)
197
+ t = re.sub(r"[ \t]{2,}", " ", t)
198
+ t = re.sub(r"\n{3,}", "\n\n", t)
199
+ return t.strip()
200
+
201
+
202
+ def _split_text_chunks(text: str, max_chars: int) -> list[str]:
203
+ body = (text or "").strip()
204
+ if not body:
205
+ return []
206
+ if len(body) <= max_chars:
207
+ return [body]
208
+ parts = re.split(r"(\n\s*\n)", body)
209
+ chunks: list[str] = []
210
+ current = ""
211
+ for part in parts:
212
+ if not part:
213
+ continue
214
+ candidate = current + part
215
+ if len(candidate) <= max_chars:
216
+ current = candidate
217
+ continue
218
+ if current.strip():
219
+ chunks.append(current.strip())
220
+ if len(part) <= max_chars:
221
+ current = part
222
+ else:
223
+ for i in range(0, len(part), max_chars):
224
+ segment = part[i : i + max_chars].strip()
225
+ if segment:
226
+ chunks.append(segment)
227
+ current = ""
228
+ if current.strip():
229
+ chunks.append(current.strip())
230
+ return chunks or [body[:max_chars]]
231
+
232
+
233
+ def _llm_sanitise_chunk_sync(chunk: str, *, part: int, total: int) -> str:
234
+ from app.llm.openai_chat import chat_completions_create
235
+
236
+ import asyncio
237
+
238
+ prefix = (
239
+ f"Sanitise this excerpt from a RICS property survey report "
240
+ f"(part {part} of {total}).\n\n"
241
+ )
242
+ user_content = prefix + chunk
243
+
244
+ async def _run() -> str:
245
+ return await chat_completions_create(
246
+ messages=[
247
+ {"role": "system", "content": RAG_UPLOAD_SANITISATION_SYSTEM_PROMPT},
248
+ {"role": "user", "content": user_content},
249
+ ],
250
+ model=settings.chat_model,
251
+ max_tokens=int(settings.rag_sanitisation_max_output_tokens),
252
+ temperature=0.0,
253
+ phase="rag_upload_sanitise",
254
+ )
255
+
256
+ try:
257
+ return asyncio.run(_run())
258
+ except RuntimeError:
259
+ # Already inside a running loop (should not happen from ingest thread).
260
+ import concurrent.futures
261
+
262
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
263
+ return pool.submit(asyncio.run, _run()).result()
264
+
265
+
266
+ def sanitise_text_for_rag_sync(text: str, *, tenant_id: str) -> str:
267
+ """Sanitise plain text before it is embedded into the tenant vector index."""
268
+ if not should_sanitise_for_rag(tenant_id):
269
+ return (text or "").strip()
270
+
271
+ raw = (text or "").strip()
272
+ if not raw:
273
+ return ""
274
+
275
+ max_chars = int(settings.rag_sanitisation_chunk_chars)
276
+ chunks = _split_text_chunks(raw, max_chars)
277
+ has_key = bool((settings.openai_api_key or "").strip())
278
+ out_parts: list[str] = []
279
+
280
+ for i, chunk in enumerate(chunks, start=1):
281
+ cleaned = ""
282
+ if has_key:
283
+ try:
284
+ cleaned = (_llm_sanitise_chunk_sync(chunk, part=i, total=len(chunks)) or "").strip()
285
+ except Exception as exc:
286
+ logger.warning(
287
+ "RAG sanitisation LLM failed tenant=%s part=%d/%d: %s",
288
+ tenant_id,
289
+ i,
290
+ len(chunks),
291
+ exc,
292
+ )
293
+ if not cleaned:
294
+ cleaned = regex_sanitise_text(chunk)
295
+ if has_key:
296
+ logger.info(
297
+ "RAG sanitisation using regex fallback tenant=%s part=%d/%d",
298
+ tenant_id,
299
+ i,
300
+ len(chunks),
301
+ )
302
+ if not cleaned and settings.rag_sanitisation_fail_closed:
303
+ raise DocumentSanitisationError(
304
+ f"Sanitisation produced empty text for tenant={tenant_id} part={i}/{len(chunks)}"
305
+ )
306
+ out_parts.append(cleaned)
307
+
308
+ combined = "\n\n".join(p for p in out_parts if p).strip()
309
+ if not combined and settings.rag_sanitisation_fail_closed:
310
+ raise DocumentSanitisationError(
311
+ f"Sanitisation produced empty document for tenant={tenant_id}"
312
+ )
313
+ return combined or regex_sanitise_text(raw)
314
+
315
+
316
+ def sanitise_langchain_documents_sync(
317
+ documents: list[Document],
318
+ *,
319
+ tenant_id: str,
320
+ ) -> list[Document]:
321
+ """Return documents whose ``page_content`` has been sanitised for RAG indexing."""
322
+ if not should_sanitise_for_rag(tenant_id):
323
+ return documents
324
+
325
+ from langchain_core.documents import Document as LCDocument
326
+
327
+ out: list[LCDocument] = []
328
+ for doc in documents:
329
+ meta = dict(doc.metadata or {})
330
+ cleaned = sanitise_text_for_rag_sync(doc.page_content or "", tenant_id=tenant_id)
331
+ meta["rag_sanitised"] = True
332
+ out.append(LCDocument(page_content=cleaned, metadata=meta))
333
+ return out
app/services/runtime_rag_index.py CHANGED
@@ -37,6 +37,14 @@ def upsert_runtime_section_index(
37
  if not text:
38
  logger.info("runtime RAG: cleared index for doc=%s", virtual_doc_id)
39
  return 0
 
 
 
 
 
 
 
 
40
  label = (section_title or "").strip() or virtual_doc_id
41
  raw = [Document(page_content=text, metadata={"source": label})]
42
  hier = build_hierarchical_documents(
 
37
  if not text:
38
  logger.info("runtime RAG: cleared index for doc=%s", virtual_doc_id)
39
  return 0
40
+
41
+ from app.services.document_sanitiser import sanitise_text_for_rag_sync
42
+
43
+ text = sanitise_text_for_rag_sync(text, tenant_id=tenant_id)
44
+ if not text:
45
+ logger.info("runtime RAG: empty after sanitisation doc=%s", virtual_doc_id)
46
+ return 0
47
+
48
  label = (section_title or "").strip() or virtual_doc_id
49
  raw = [Document(page_content=text, metadata={"source": label})]
50
  hier = build_hierarchical_documents(
app/tests/test_document_sanitiser.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for RAG upload sanitisation (regex path; no live OpenAI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from app.services.document_sanitiser import (
8
+ regex_sanitise_text,
9
+ sanitise_text_for_rag_sync,
10
+ should_sanitise_for_rag,
11
+ )
12
+
13
+
14
+ def test_should_skip_kb_tenant(monkeypatch: pytest.MonkeyPatch) -> None:
15
+ from app.config import settings
16
+
17
+ monkeypatch.setattr(settings, "enable_rag_upload_sanitisation", True)
18
+ monkeypatch.setattr(settings, "rag_sanitisation_skip_kb_tenant", True)
19
+ monkeypatch.setattr(settings, "knowledge_base_tenant_id", "__rics_kb__")
20
+ assert should_sanitise_for_rag("tenant-a") is True
21
+ assert should_sanitise_for_rag("__rics_kb__") is False
22
+
23
+
24
+ def test_regex_removes_postcode_and_email() -> None:
25
+ raw = (
26
+ "The roof covering is slate. Client: jane.doe@example.com. "
27
+ "Property at 12 High Street, London SW1A 1AA. Price £450,000."
28
+ )
29
+ out = regex_sanitise_text(raw)
30
+ assert "jane.doe@example.com" not in out
31
+ assert "SW1A" not in out
32
+ assert "450,000" not in out
33
+ assert "slate" in out.lower()
34
+
35
+
36
+ def test_sanitise_sync_without_openai_key(monkeypatch: pytest.MonkeyPatch) -> None:
37
+ from app.config import settings
38
+
39
+ monkeypatch.setattr(settings, "enable_rag_upload_sanitisation", True)
40
+ monkeypatch.setattr(settings, "openai_api_key", "")
41
+ monkeypatch.setattr(settings, "rag_sanitisation_fail_closed", False)
42
+ text = "Defect noted at 10 Church Road, Bristol BS1 4ST."
43
+ out = sanitise_text_for_rag_sync(text, tenant_id="firm-1")
44
+ assert "BS1" not in out
45
+ assert "defect" in out.lower()