Spaces:
Runtime error
Runtime error
| """Per-tenant, two-tier FAISS vector store. | |
| Tiers: | |
| * ``TIER_MASTER`` — operator master-template paragraphs. **Not** scrubbed at | |
| ingest (the master is property-agnostic boilerplate); it carries the firm's | |
| approved wording and is the structural authority. | |
| * ``TIER_REFERENCE`` — past completed reports. **Always** scrubbed at ingest; | |
| used only for style/terminology. A read-time scrub backstop guards against any | |
| chunk that slipped through unscrubbed. | |
| Each (tenant, tier) pair is an independent ``IndexFlatIP`` over L2-normalised | |
| embeddings (so inner product == cosine similarity), persisted to disk alongside a | |
| JSON metadata sidecar. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import threading | |
| from dataclasses import dataclass, field | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import numpy as np | |
| from backend.config import settings | |
| from backend.core import pii_scrubber | |
| from backend.core.embeddings import Embedder, get_embedder | |
| from backend.core.reference_filter import build_reference_allowlist, meta_matches_allowlist | |
| from backend.utils import tenant_store | |
| logger = logging.getLogger(__name__) | |
| TIER_MASTER = "master" | |
| TIER_REFERENCE = "reference" | |
| class Chunk: | |
| text: str | |
| section_id: str = "" | |
| doc_id: str = "" | |
| tier: str = TIER_MASTER | |
| is_scrubbed: bool = False | |
| chunk_id: str = "" | |
| source_filename: str = "" | |
| paragraph_index: int = 0 | |
| class SearchHit: | |
| text: str | |
| section_id: str | |
| doc_id: str | |
| tier: str | |
| score: float | |
| is_scrubbed: bool | |
| source_filename: str = "" | |
| paragraph_index: int = 0 | |
| chunk_id: str = "" | |
| def _search_hit_from_meta( | |
| meta: dict, | |
| *, | |
| tier: str, | |
| rank: float, | |
| text: str, | |
| ) -> SearchHit: | |
| return SearchHit( | |
| text=text, | |
| section_id=meta.get("section_id", ""), | |
| doc_id=meta.get("doc_id", ""), | |
| tier=tier, | |
| score=rank, | |
| is_scrubbed=bool(meta.get("is_scrubbed")), | |
| source_filename=meta.get("source_filename", ""), | |
| paragraph_index=int(meta.get("paragraph_index") or 0), | |
| chunk_id=meta.get("chunk_id", ""), | |
| ) | |
| class _TierIndex: | |
| index: object # faiss.IndexFlatIP | |
| meta: list[dict] = field(default_factory=list) | |
| class RagStore: | |
| """Lazy, thread-safe per-tenant/tier FAISS store.""" | |
| def __init__(self, embedder: Embedder | None = None) -> None: | |
| self._embedder = embedder or get_embedder() | |
| self._cache: dict[tuple[str, str], _TierIndex] = {} | |
| self._lock = threading.RLock() | |
| # ── persistence ────────────────────────────────────────────────────────── | |
| def _paths(self, tenant_id: str, tier: str) -> tuple[Path, Path]: | |
| d = tenant_store.faiss_dir(tenant_id, tier) | |
| return d / "index.faiss", d / "meta.json" | |
| def _new_index(self): | |
| import faiss | |
| return faiss.IndexFlatIP(self._embedder.embed_dim) | |
| def _load(self, tenant_id: str, tier: str) -> _TierIndex: | |
| import faiss | |
| idx_path, meta_path = self._paths(tenant_id, tier) | |
| if idx_path.is_file() and meta_path.is_file(): | |
| try: | |
| # io_flags=0: load into RAM (avoid mmap — Windows cannot replace mmap'd files). | |
| index = faiss.read_index(str(idx_path), 0) | |
| meta = json.loads(meta_path.read_text(encoding="utf-8")) | |
| return _TierIndex(index=index, meta=meta) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Failed to load FAISS %s/%s (%s); rebuilding empty.", | |
| tenant_id, tier, exc) | |
| return _TierIndex(index=self._new_index(), meta=[]) | |
| def _persist(self, tenant_id: str, tier: str, ti: _TierIndex) -> None: | |
| import faiss | |
| import os | |
| import tempfile | |
| from backend.utils.runtime_paths import ensure_data_drive_runtime_dirs | |
| ensure_data_drive_runtime_dirs() | |
| idx_path, meta_path = self._paths(tenant_id, tier) | |
| idx_path = idx_path.resolve() | |
| meta_path = meta_path.resolve() | |
| idx_path.parent.mkdir(parents=True, exist_ok=True) | |
| payload = json.dumps(ti.meta, ensure_ascii=False, separators=(",", ":")) | |
| scratch_root = settings.data_dir_path / "tmp" / "faiss_persist" | |
| scratch_root.mkdir(parents=True, exist_ok=True) | |
| def _atomic_write() -> None: | |
| with tempfile.TemporaryDirectory(prefix="faiss_", dir=str(scratch_root)) as td: | |
| tmp_dir = Path(td) | |
| tmp_idx = tmp_dir / "index.faiss" | |
| tmp_meta = tmp_dir / "meta.json" | |
| faiss.write_index(ti.index, os.fspath(tmp_idx)) | |
| tmp_meta.write_text(payload, encoding="utf-8") | |
| for final, tmp in ((idx_path, tmp_idx), (meta_path, tmp_meta)): | |
| final_s = os.fspath(final) | |
| tmp_s = os.fspath(tmp) | |
| if os.path.exists(final_s): | |
| os.remove(final_s) | |
| os.replace(tmp_s, final_s) | |
| try: | |
| _atomic_write() | |
| except OSError as exc: | |
| logger.error( | |
| "FAISS persist failed for %s/%s (%s); quarantining and retrying once.", | |
| tenant_id, | |
| tier, | |
| exc, | |
| ) | |
| for stale in idx_path.parent.glob("*.write.*"): | |
| stale.unlink(missing_ok=True) | |
| for final in (idx_path, meta_path): | |
| if final.is_file(): | |
| bad = final.with_suffix(final.suffix + ".bad") | |
| if bad.is_file(): | |
| bad.unlink(missing_ok=True) | |
| os.replace(os.fspath(final), os.fspath(bad)) | |
| _atomic_write() | |
| def _get(self, tenant_id: str, tier: str) -> _TierIndex: | |
| key = (tenant_id, tier) | |
| with self._lock: | |
| if key not in self._cache: | |
| self._cache[key] = self._load(tenant_id, tier) | |
| return self._cache[key] | |
| # ── ingest ─────────────────────────────────────────────────────────────-- | |
| def ingest_document( | |
| self, | |
| tenant_id: str, | |
| doc_id: str, | |
| chunks: list[Chunk], | |
| *, | |
| tier: str, | |
| source_filename: str = "", | |
| ) -> int: | |
| """Embed and add chunks. REFERENCE-tier chunks are PII-scrubbed before storage.""" | |
| prepared: list[Chunk] = [] | |
| audit_entries: list[dict] = [] | |
| # One session per document so the same name/address/reference is masked | |
| # to one stable token across every chunk of this file (referential | |
| # integrity), while distinct values remain distinguishable. | |
| scrub_session = ( | |
| pii_scrubber.ScrubSession() if tier == TIER_REFERENCE else None | |
| ) | |
| for c in chunks: | |
| text = c.text or "" | |
| scrubbed = c.is_scrubbed | |
| chunk_audit: dict[str, int] = {} | |
| if tier == TIER_REFERENCE and not scrubbed: | |
| result = pii_scrubber.scrub_reference_for_ingest( | |
| text, session=scrub_session | |
| ) | |
| if result is None: | |
| logger.warning( | |
| "Skipping REFERENCE chunk from %s (PII could not be fully redacted)", | |
| doc_id, | |
| ) | |
| continue | |
| text = result.text | |
| chunk_audit = dict(result.audit) | |
| scrubbed = True | |
| if not text.strip(): | |
| continue | |
| if chunk_audit: | |
| audit_entries.append({ | |
| "redactions": [ | |
| {"type": k, "count": v} for k, v in sorted(chunk_audit.items()) | |
| ], | |
| "is_scrubbed": True, | |
| }) | |
| prepared.append( | |
| Chunk( | |
| text=text, | |
| section_id=c.section_id, | |
| doc_id=c.doc_id or doc_id, | |
| tier=tier, | |
| is_scrubbed=scrubbed, | |
| chunk_id=c.chunk_id, | |
| source_filename=c.source_filename or source_filename, | |
| paragraph_index=c.paragraph_index, | |
| ) | |
| ) | |
| if not prepared: | |
| return 0 | |
| if tier == TIER_REFERENCE and audit_entries: | |
| self._append_scrub_audit( | |
| tenant_id, | |
| doc_id=doc_id, | |
| chunks_processed=len(audit_entries), | |
| chunk_audits=audit_entries, | |
| ) | |
| vecs = self._embedder.embed_documents([c.text for c in prepared]) | |
| arr = np.asarray(vecs, dtype="float32") | |
| with self._lock: | |
| ti = self._get(tenant_id, tier) | |
| ti.index.add(arr) | |
| base = len(ti.meta) | |
| for i, c in enumerate(prepared): | |
| cid = c.chunk_id or f"{doc_id}:{base + i}" | |
| src = c.source_filename or source_filename or doc_id.split(":", 1)[-1] | |
| ti.meta.append({ | |
| "text": c.text, | |
| "paragraph_text": c.text, | |
| "section_id": c.section_id, | |
| "doc_id": c.doc_id or doc_id, | |
| "tier": tier, | |
| "is_scrubbed": c.is_scrubbed, | |
| "chunk_id": cid, | |
| "source_filename": src, | |
| "paragraph_index": c.paragraph_index or 0, | |
| }) | |
| self._persist(tenant_id, tier, ti) | |
| logger.info("Ingested %d chunks into %s/%s (doc=%s)", | |
| len(prepared), tenant_id, tier, doc_id) | |
| return len(prepared) | |
| def _append_scrub_audit( | |
| tenant_id: str, | |
| *, | |
| doc_id: str, | |
| chunks_processed: int, | |
| chunk_audits: list[dict], | |
| ) -> None: | |
| """Persist scrub audit metadata (types and counts only, never originals).""" | |
| record = { | |
| "document": doc_id, | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "chunks_processed": chunks_processed, | |
| "chunks": chunk_audits, | |
| } | |
| path = tenant_store.scrub_audit_path(tenant_id) | |
| with path.open("a", encoding="utf-8") as f: | |
| f.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| def clear_tier(self, tenant_id: str, tier: str) -> None: | |
| """Drop all vectors for a (tenant, tier) — used by admin override.""" | |
| with self._lock: | |
| ti = _TierIndex(index=self._new_index(), meta=[]) | |
| self._cache[(tenant_id, tier)] = ti | |
| self._persist(tenant_id, tier, ti) | |
| def evict_tier_cache(self, tenant_id: str, tier: str) -> None: | |
| """Drop in-memory index without persisting (used before disk purge).""" | |
| with self._lock: | |
| self._cache.pop((tenant_id, tier), None) | |
| def remove_document( | |
| self, | |
| tenant_id: str, | |
| tier: str, | |
| *, | |
| source_filename: str | None = None, | |
| doc_id: str | None = None, | |
| ) -> int: | |
| """Drop all chunks for a reference upload and rebuild the FAISS index.""" | |
| if not source_filename and not doc_id: | |
| return 0 | |
| with self._lock: | |
| ti = self._get(tenant_id, tier) | |
| kept: list[dict] = [] | |
| removed = 0 | |
| for row in ti.meta: | |
| sf = str(row.get("source_filename") or "").strip() | |
| did = str(row.get("doc_id") or "").strip() | |
| match = False | |
| if source_filename and sf == source_filename: | |
| match = True | |
| if doc_id and did == doc_id: | |
| match = True | |
| if source_filename and did == f"reference:{source_filename}": | |
| match = True | |
| if match: | |
| removed += 1 | |
| else: | |
| kept.append(row) | |
| if removed == 0: | |
| return 0 | |
| if not kept: | |
| ti = _TierIndex(index=self._new_index(), meta=[]) | |
| else: | |
| texts = [r["text"] for r in kept] | |
| vecs = self._embedder.embed_documents(texts) | |
| arr = np.asarray(vecs, dtype="float32") | |
| index = self._new_index() | |
| index.add(arr) | |
| ti = _TierIndex(index=index, meta=kept) | |
| self._cache[(tenant_id, tier)] = ti | |
| self._persist(tenant_id, tier, ti) | |
| logger.info( | |
| "Removed %d chunk(s) from tenant=%s tier=%s (source=%s doc_id=%s)", | |
| removed, | |
| tenant_id, | |
| tier, | |
| source_filename, | |
| doc_id, | |
| ) | |
| return removed | |
| def count(self, tenant_id: str, tier: str) -> int: | |
| return len(self._get(tenant_id, tier).meta) | |
| def list_source_filenames(self, tenant_id: str, tier: str) -> list[str]: | |
| """Unique source filenames ingested for a tier.""" | |
| seen: set[str] = set() | |
| out: list[str] = [] | |
| for row in self._get(tenant_id, tier).meta: | |
| name = str(row.get("source_filename") or row.get("doc_id") or "").strip() | |
| if name.startswith("reference:"): | |
| name = name.split(":", 1)[-1] | |
| if name and name not in seen: | |
| seen.add(name) | |
| out.append(name) | |
| return out | |
| def sample_chunk_texts(self, tenant_id: str, tier: str, *, limit: int = 40) -> list[str]: | |
| """Return up to ``limit`` chunk texts from a tier (for style analysis).""" | |
| texts: list[str] = [] | |
| for row in self._get(tenant_id, tier).meta: | |
| text = str(row.get("text") or "").strip() | |
| if text: | |
| texts.append(text) | |
| if len(texts) >= limit: | |
| break | |
| return texts | |
| def fetch_section_chunks( | |
| self, | |
| tenant_id: str, | |
| *, | |
| tier: str, | |
| section_id: str, | |
| source_key: str | None = None, | |
| allowed_doc_keys: frozenset[str] | None = None, | |
| ) -> list[SearchHit]: | |
| """Return EVERY stored chunk for a ``(tenant, tier, section_id)`` in document | |
| order — a metadata scan, NOT a similarity search. | |
| This backs section-complete baseline assembly: the full past-report section is | |
| mapped, not only the top-K semantically nearest chunks. REFERENCE chunks pass | |
| the same read-time scrub backstop as :meth:`search` (unscrubbed dropped, text | |
| re-sanitised). When ``source_key`` is given, only chunks from that one document | |
| (matched on ``source_filename`` or ``doc_id``) are returned, pinning assembly to | |
| a single report. When ``allowed_doc_keys`` is set, the upload allowlist applies. | |
| """ | |
| want = (section_id or "").strip().upper() | |
| if not want: | |
| return [] | |
| ti = self._get(tenant_id, tier) | |
| out: list[SearchHit] = [] | |
| for m in ti.meta: | |
| if (m.get("section_id") or "").strip().upper() != want: | |
| continue | |
| if allowed_doc_keys is not None and not meta_matches_allowlist(m, allowed_doc_keys): | |
| continue | |
| if source_key is not None: | |
| key = (m.get("source_filename") or m.get("doc_id") or "").strip() | |
| if key != source_key: | |
| continue | |
| if tier == TIER_REFERENCE and not m.get("is_scrubbed"): | |
| continue | |
| text = m.get("text") or "" | |
| if tier == TIER_REFERENCE: | |
| text = pii_scrubber.sanitize_for_generation_context(text) | |
| if not text.strip(): | |
| continue | |
| # Uniform rank: ordering is by document position, not similarity. | |
| out.append(_search_hit_from_meta(m, tier=tier, rank=1.0, text=text)) | |
| out.sort(key=lambda h: (h.paragraph_index or 0, h.chunk_id or "")) | |
| return out | |
| # ── search ─────────────────────────────────────────────────────────────-- | |
| def search( | |
| self, | |
| tenant_id: str, | |
| query: str, | |
| *, | |
| tier: str | None = None, | |
| section_id: str | None = None, | |
| top_k: int = 5, | |
| section_strict: bool = False, | |
| allowed_doc_keys: frozenset[str] | None = None, | |
| reference_document_ids: list[str] | None = None, | |
| strict_uploaded_only: bool = False, | |
| ) -> list[SearchHit]: | |
| """Search one or both tiers, ranked by similarity. | |
| When ``tier`` is None, MASTER hits are ranked ahead of REFERENCE hits at | |
| equal relevance (master is authoritative). When ``section_id`` is given, | |
| chunks tagged with that section are boosted. | |
| When ``section_strict`` is True, only chunks whose ``section_id`` equals | |
| ``section_id`` are returned (used to pin mapping to the correct paragraph). | |
| When ``allowed_doc_keys`` is set, only chunks whose ``doc_id`` or | |
| ``source_filename`` appears in the allowlist are returned. | |
| When ``strict_uploaded_only`` is True and ``reference_document_ids`` is | |
| non-empty, builds an allowlist via :func:`build_reference_allowlist` and | |
| drops hits that fail :func:`meta_matches_allowlist` on ``doc_id`` or | |
| ``source_filename``. | |
| """ | |
| if allowed_doc_keys is None and strict_uploaded_only: | |
| allowed_doc_keys = build_reference_allowlist( | |
| tenant_id, | |
| reference_document_ids, | |
| strict_uploaded_only=True, | |
| ) | |
| tiers = [tier] if tier else [TIER_MASTER, TIER_REFERENCE] | |
| hits: list[SearchHit] = [] | |
| qvec = np.asarray([self._embedder.embed_query(query)], dtype="float32") | |
| pool_mult = 20 if allowed_doc_keys else 3 | |
| for t in tiers: | |
| ti = self._get(tenant_id, t) | |
| n = len(ti.meta) | |
| if n == 0: | |
| continue | |
| k = min(max(top_k * pool_mult, top_k), n) | |
| scores, idxs = ti.index.search(qvec, k) | |
| for score, i in zip(scores[0], idxs[0]): | |
| if i < 0: | |
| continue | |
| m = ti.meta[i] | |
| if allowed_doc_keys is not None and not meta_matches_allowlist(m, allowed_doc_keys): | |
| continue | |
| if section_strict and section_id and m.get("section_id") != section_id: | |
| continue | |
| # Reference backstop: never surface unscrubbed reference chunks. | |
| if t == TIER_REFERENCE and not m.get("is_scrubbed"): | |
| continue | |
| text = m["text"] | |
| if t == TIER_REFERENCE: | |
| text = pii_scrubber.sanitize_for_generation_context(text) | |
| if not text.strip(): | |
| continue | |
| rank = float(score) | |
| if t == TIER_MASTER: | |
| rank += 0.05 # authoritative tier nudge | |
| if section_id and m.get("section_id") == section_id: | |
| rank += settings.retrieval_section_boost | |
| hits.append(_search_hit_from_meta(m, tier=t, rank=rank, text=text)) | |
| hits.sort(key=lambda h: h.score, reverse=True) | |
| return hits[:top_k] | |
| def search_for_generation( | |
| self, | |
| tenant_id: str, | |
| query: str, | |
| *, | |
| section_id: str | None = None, | |
| top_k: int = 5, | |
| section_strict: bool = False, | |
| ) -> list[SearchHit]: | |
| """Retrieve paragraphs for report mapping — MASTER boilerplate only. | |
| Past completed reports (REFERENCE tier) are never passed to the mapping | |
| step, so another property's sensitive data cannot enter the current report. | |
| """ | |
| hits = self.search( | |
| tenant_id, | |
| query, | |
| tier=TIER_MASTER, | |
| section_id=section_id, | |
| top_k=top_k, | |
| section_strict=section_strict, | |
| ) | |
| safe: list[SearchHit] = [] | |
| for h in hits: | |
| text = pii_scrubber.sanitize_for_generation_context(h.text) | |
| if not text.strip(): | |
| continue | |
| safe.append( | |
| _search_hit_from_meta( | |
| { | |
| "section_id": h.section_id, | |
| "doc_id": h.doc_id, | |
| "is_scrubbed": h.is_scrubbed, | |
| "source_filename": h.source_filename, | |
| "paragraph_index": h.paragraph_index, | |
| "chunk_id": h.chunk_id, | |
| }, | |
| tier=h.tier, | |
| rank=h.score, | |
| text=text, | |
| ) | |
| ) | |
| return safe | |
| def search_for_reference_mapping( | |
| self, | |
| tenant_id: str, | |
| query: str, | |
| *, | |
| section_id: str | None = None, | |
| top_k: int = 5, | |
| section_strict: bool = False, | |
| allowed_doc_keys: frozenset[str] | None = None, | |
| ) -> list[SearchHit]: | |
| """Retrieve scrubbed past-report excerpts for minimum-AI mapping.""" | |
| hits = self.search( | |
| tenant_id, | |
| query, | |
| tier=TIER_REFERENCE, | |
| section_id=section_id, | |
| top_k=top_k, | |
| section_strict=section_strict, | |
| allowed_doc_keys=allowed_doc_keys, | |
| ) | |
| safe: list[SearchHit] = [] | |
| for h in hits: | |
| text = pii_scrubber.sanitize_for_generation_context(h.text) | |
| if not text.strip(): | |
| continue | |
| safe.append( | |
| _search_hit_from_meta( | |
| { | |
| "section_id": h.section_id, | |
| "doc_id": h.doc_id, | |
| "is_scrubbed": h.is_scrubbed, | |
| "source_filename": h.source_filename, | |
| "paragraph_index": h.paragraph_index, | |
| "chunk_id": h.chunk_id, | |
| }, | |
| tier=h.tier, | |
| rank=h.score, | |
| text=text, | |
| ) | |
| ) | |
| return safe | |
| _instance: RagStore | None = None | |
| def get_rag_store() -> RagStore: | |
| global _instance | |
| if _instance is None: | |
| _instance = RagStore() | |
| return _instance | |
| def reset_rag_store() -> None: | |
| global _instance | |
| _instance = None | |