"""Semantic entity detection for the privacy gateway. Detect may see plaintext. Generate must not. Detector output is untrusted: spans and types only, never tokens, never a durable registry write. """ from __future__ import annotations import json import re import time from dataclasses import dataclass, field from typing import Any import placeholder_mvp # noqa: F401 from db import get_db, row_to_dict from env_loader import allows_remote_plaintext_detect from identity_store import ( ENTITY_TYPES, confirmed_match_labels, is_maskable_label, list_confirmed_identities, masking_rows_from_confirmed, normalize_entity_type, record_review_proposal, ) from placeholders import PlaceholderError, resolve_template from providers import ChatResult, ProviderError, complete_chat, detect_provider DETECT_CHUNK_CHARS = 4000 DETECT_CHUNK_OVERLAP = 250 DETECT_TIMEOUT = 90.0 DETECT_MAX_TOKENS = 1024 JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL) ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"}) ALLOWED_ROOT_FIELDS = frozenset({"entities"}) TYPE_PRIORITY = {"PERSON": 0, "PROJECT": 1, "ORG": 2, "PLACE": 3} ERROR_DETECT_UNAVAILABLE = "detect_provider_unavailable" ERROR_DETECT_INCOMPLETE = "detect_incomplete" ERROR_DETECT_INVALID = "detect_invalid_output" ERROR_DETECT_TRUNCATED = "detect_truncated" ERROR_DETECT_CHUNK = "detect_chunk_failed" DETECT_DIALOGUE_FALLBACK_CODES = frozenset( { ERROR_DETECT_INVALID, ERROR_DETECT_TRUNCATED, ERROR_DETECT_INCOMPLETE, ERROR_DETECT_CHUNK, } ) USER_DETECT_MESSAGES = { ERROR_DETECT_UNAVAILABLE: ( "Persönliche Angaben können gerade nicht geschützt werden. " "Es wurde kein Impuls erzeugt." ), ERROR_DETECT_INCOMPLETE: ( "Persönliche Angaben konnten nicht vollständig geprüft werden. " "Es wurde kein Impuls erzeugt." ), ERROR_DETECT_INVALID: ( "Persönliche Angaben konnten nicht sicher zugeordnet werden. " "Es wurde kein Impuls erzeugt." ), ERROR_DETECT_TRUNCATED: ( "Die Prüfung persönlicher Angaben wurde abgebrochen. " "Es wurde kein Impuls erzeugt." ), ERROR_DETECT_CHUNK: ( "Die Prüfung persönlicher Angaben ist fehlgeschlagen. " "Es wurde kein Impuls erzeugt." ), } def user_detect_message(code: str, fallback: str = "") -> str: return USER_DETECT_MESSAGES.get(code) or fallback or "Persönliche Angaben konnten nicht geprüft werden." _LETTER = r"A-Za-zÄÖÜäöüß" class DetectError(Exception): def __init__(self, code: str, message: str, status_code: int = 503, diagnostics: dict | None = None): super().__init__(message) self.code = code self.message = message self.status_code = status_code self.diagnostics = diagnostics or {} @dataclass(frozen=True) class DetectedSpan: start: int end: int text: str entity_type: str chunk_index: int @dataclass class DetectionStats: detect_provider: str | None = None detect_model: str | None = None detect_note: str | None = None source_chars: int = 0 chunk_count: int = 0 chunks_ok: int = 0 full_detection_coverage: bool = False entity_counts: dict[str, int] = field(default_factory=dict) confirmed_registry_hits: int = 0 request_local_hits: int = 0 detect_calls: int = 0 prompt_tokens: int = 0 completion_tokens: int = 0 total_tokens: int = 0 cost: float = 0.0 detect_ms: int = 0 abort_reason: str | None = None def public(self) -> dict[str, Any]: payload = { "detect_provider": self.detect_provider, "detect_model": self.detect_model, "detect_note": self.detect_note, "source_chars": self.source_chars, "chunk_count": self.chunk_count, "chunks_ok": self.chunks_ok, "full_detection_coverage": self.full_detection_coverage, "entity_counts": dict(self.entity_counts), "confirmed_registry_hits": self.confirmed_registry_hits, "request_local_hits": self.request_local_hits, "detect_calls": self.detect_calls, "detect_prompt_tokens": self.prompt_tokens, "detect_completion_tokens": self.completion_tokens, "detect_total_tokens": self.total_tokens, "detect_cost": self.cost, "detect_ms": self.detect_ms, } if self.abort_reason: payload["abort_reason"] = self.abort_reason return payload @dataclass class DetectionOutcome: mappings: list[dict] stats: DetectionStats local_identities: list[dict] _injected_spans: list[dict] | None = None _injected_fail: DetectError | None = None _injected_truncated: bool = False def install_test_spans(spans: list[dict] | None) -> None: """Test-only span injection. Production never calls this.""" global _injected_spans _injected_spans = None if spans is None else [dict(item) for item in spans] def install_test_detect_failure(error: DetectError | None) -> None: global _injected_fail _injected_fail = error def install_test_truncated(flag: bool = True) -> None: global _injected_truncated _injected_truncated = bool(flag) def reset_detect_test_hooks() -> None: global _injected_spans, _injected_fail, _injected_truncated _injected_spans = None _injected_fail = None _injected_truncated = False def uses_llm_detect(config) -> bool: """Local detect stays a provider role. Remote plaintext detect is development/test only.""" if not config or config.mode != "http": return False if config.local: return True return allows_remote_plaintext_detect() def _detect_prompt() -> dict: with get_db() as conn: row = row_to_dict( conn.execute( "SELECT * FROM ai_prompts WHERE slug = ? AND active = 1", ("mvp.entity_detect",), ).fetchone() ) if not row or not (row.get("template") or "").strip(): raise DetectError( "detect_prompt_missing", "Prompt mvp.entity_detect fehlt in der Konfiguration.", ) return row def split_detect_chunks(text: str, *, chunk_chars: int | None = None, overlap: int | None = None) -> list[tuple[int, str]]: source = text or "" if not source: return [(0, "")] size = max(32, int(DETECT_CHUNK_CHARS if chunk_chars is None else chunk_chars)) overlap_n = DETECT_CHUNK_OVERLAP if overlap is None else overlap overlap_n = max(0, min(int(overlap_n), size // 2)) if len(source) <= size: return [(0, source)] chunks: list[tuple[int, str]] = [] start = 0 n = len(source) while start < n: end = min(n, start + size) if end < n: window = source[start:end] cut = max(window.rfind("\n"), window.rfind(" ")) if cut >= size // 3: end = start + cut + 1 chunks.append((start, source[start:end])) if end >= n: break nxt = end - overlap_n if nxt <= start: nxt = end start = nxt return chunks def _span_dict(text: str, label: str, entity_type: str, *, from_index: int = 0) -> dict | None: start = text.find(label, from_index) if start < 0: return None return { "start": start, "end": start + len(label), "text": label, "entity_type": entity_type, } def _contract_fake_spans(text: str) -> list[dict]: """Deterministic contract fixture. Not a semantic quality claim.""" source = text or "" found: list[dict] = [] seen: set[tuple[int, int, str]] = set() def add(label: str, entity_type: str, *, require: str | None = None) -> None: if require and require not in source: return start = 0 while True: item = _span_dict(source, label, entity_type, from_index=start) if not item: return key = (item["start"], item["end"], entity_type) if key not in seen: seen.add(key) found.append(item) start = item["end"] if re.search(r"Sushi kam", source): add("Sushi", "PERSON") if re.search(r"(?i)Frau\s+Sushi", source): add("Sushi", "PERSON") if re.search(r"(?i)Projekt\s+Aurora", source): add("Aurora", "PROJECT") if re.search(r"(?i)(Organisation|Firma|bei)\s+Nordwerk", source): add("Nordwerk", "ORG") if re.search(r"(?i)(in|nach|aus)\s+Hamburg", source): add("Hamburg", "PLACE") for name in ("Anna", "Clarissa", "Hanna", "Maren"): if re.search(rf"(? dict: text = (raw or "").strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE | re.DOTALL).strip() match = JSON_BLOCK.search(text) if not match: raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort war kein gültiges JSON.") try: data = json.loads(match.group(0)) except json.JSONDecodeError as exc: raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort war kein gültiges JSON.") from exc if not isinstance(data, dict): raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort muss ein Objekt mit entities sein.") extra = set(data.keys()) - ALLOWED_ROOT_FIELDS if extra: raise DetectError(ERROR_DETECT_INVALID, "Detect-Antwort enthält unerwartete Felder.") items = data.get("entities") if not isinstance(items, list): raise DetectError(ERROR_DETECT_INVALID, "Detect-entities muss eine Liste sein.") return data def _label_occurrences(chunk_text: str, label: str) -> list[tuple[int, int]]: if not label: return [] exact = list( re.finditer(rf"(? tuple[int, int, str] | None: """Trust the reported word only if it exists in this chunk. Offsets are a hint.""" source = chunk_text or "" label = (text or "").strip() if not label: return None n = len(source) windows: list[tuple[int, int]] = [] if 0 <= start < end <= n: windows.append((start, end)) if 0 <= start < end + 1 <= n and (start, end + 1) not in windows: windows.append((start, end + 1)) for left, right in windows: sliced = source[left:right] if sliced == text: return left, right, sliced if sliced.strip() == label: inner = left + (len(sliced) - len(sliced.lstrip())) if source[inner : inner + len(label)] == label: return inner, inner + len(label), label hits = _label_occurrences(source, label) if not hits: return None best = min(hits, key=lambda item: (abs(item[0] - start), item[0])) return best[0], best[1], source[best[0] : best[1]] def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> DetectedSpan | None: if not isinstance(item, dict): raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity muss ein Objekt sein.") extra = set(item.keys()) - ALLOWED_ENTITY_FIELDS if extra: raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity enthält unerwartete Felder.") missing = ALLOWED_ENTITY_FIELDS - set(item.keys()) if missing: raise DetectError(ERROR_DETECT_INVALID, "Detect-Entity ist unvollständig.") try: start = int(item["start"]) except (TypeError, ValueError): start = 0 try: end = int(item["end"]) except (TypeError, ValueError): end = 0 text = item.get("text") if not isinstance(text, str): raise DetectError(ERROR_DETECT_INVALID, "Detect-text fehlt.") if not text.strip(): return None if "token" in item or "placeholder" in item: raise DetectError(ERROR_DETECT_INVALID, "Detect darf keine Tokens festlegen.") kind = normalize_entity_type(item.get("entity_type"), default="") if not kind: raise DetectError(ERROR_DETECT_INVALID, "Detect-entity_type ist nicht erlaubt.") grounded = _ground_span(chunk_text, start, end, text) if not grounded: return None left, right, local_text = grounded return DetectedSpan(start=left, end=right, text=local_text, entity_type=kind, chunk_index=chunk_index) def resolve_overlaps(spans: list[DetectedSpan]) -> list[DetectedSpan]: """Longest span wins, then leftmost, then type PERSON > PROJECT > ORG > PLACE.""" ordered = sorted( spans, key=lambda item: ( -(item.end - item.start), item.start, TYPE_PRIORITY.get(item.entity_type, 9), item.chunk_index, ), ) kept: list[DetectedSpan] = [] occupied: list[tuple[int, int]] = [] for span in ordered: if any(span.start < end and span.end > start for start, end in occupied): continue kept.append(span) occupied.append((span.start, span.end)) return sorted(kept, key=lambda item: (item.start, item.end)) def _add_usage(stats: DetectionStats, usage: dict | None) -> None: data = usage or {} stats.prompt_tokens += int(data.get("prompt_tokens") or 0) stats.completion_tokens += int(data.get("completion_tokens") or 0) stats.total_tokens += int(data.get("total_tokens") or 0) try: stats.cost += float(data.get("cost") or 0) except (TypeError, ValueError): pass def _llm_chunk(config, excerpt: str) -> ChatResult: prompt = resolve_template( _detect_prompt()["template"], {"source_text": excerpt, "known_labels": ""}, ) return complete_chat( config, [{"role": "user", "content": prompt}], timeout=DETECT_TIMEOUT, max_tokens=DETECT_MAX_TOKENS, disable_context_compression=True, ) def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int) -> list[DetectedSpan]: if (result.finish_reason or "").lower() in {"length", "max_tokens"}: raise DetectError( ERROR_DETECT_TRUNCATED, "Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.", ) data = _parse_detect_json(result.content or "") spans: list[DetectedSpan] = [] for item in data.get("entities") or []: span = validate_detected_entity(item, chunk_text, chunk_index) if span is not None: spans.append(span) return spans def _to_global(span: DetectedSpan, offset: int) -> DetectedSpan: return DetectedSpan( start=span.start + offset, end=span.end + offset, text=span.text, entity_type=span.entity_type, chunk_index=span.chunk_index, ) def _dedupe_spans(spans: list[DetectedSpan]) -> list[DetectedSpan]: unique: dict[tuple[int, int, str, str], DetectedSpan] = {} for span in spans: key = (span.start, span.end, span.text, span.entity_type) unique[key] = span return resolve_overlaps(list(unique.values())) def _next_token(used: set[str], entity_type: str) -> str: prefix = f"{entity_type}:" n = 1 while True: token = f"{prefix}{n:02d}" if token not in used: used.add(token) return token n += 1 def _assign_request_tokens( spans: list[DetectedSpan], confirmed: list[dict], ) -> tuple[list[dict], int, int]: used_tokens = {(item.get("token") or "").upper() for item in confirmed if item.get("token")} by_label: dict[str, dict] = {} request_local = 0 confirmed_hits = 0 mappings: list[dict] = [] def confirmed_for(label: str) -> dict | None: needle = (label or "").strip().casefold() for item in confirmed: for candidate in confirmed_match_labels(item): if candidate.casefold() == needle: return item return None for span in spans: label = span.text if not is_maskable_label(label): continue found = confirmed_for(label) if found: token = found["token"] canonical = found["canonical_label"] source = "confirmed_registry" confirmed_hits += 1 entity_type = found["entity_type"] demask = canonical else: key = f"{span.entity_type}:{label.casefold()}" if key not in by_label: by_label[key] = { "token": _next_token(used_tokens, span.entity_type), "entity_type": span.entity_type, } request_local += 1 token = by_label[key]["token"] entity_type = span.entity_type source = "request_local" demask = label mappings.append( { "token": token, "local_label": label, "canonical_label": demask, "demask_label": demask, "entity_type": entity_type, "source": source, "start": span.start, "end": span.end, } ) return mappings, request_local, confirmed_hits def _merge_confirmed_safety_net(text: str, mappings: list[dict], profile_id: str | None) -> tuple[list[dict], int]: if not profile_id: return mappings, 0 already = {(item.get("local_label") or "").casefold() for item in mappings} extra: list[dict] = [] hits = 0 for row in masking_rows_from_confirmed(profile_id): label = row.get("local_label") or "" if not label or label.casefold() in already: continue if not re.search(rf"(? DetectionOutcome: """Full semantic detection of the personal generate-egress. Fail closed if incomplete.""" started = time.perf_counter() stats = DetectionStats(source_chars=len(source_text or "")) if _injected_fail is not None: stats.abort_reason = _injected_fail.code stats.detect_ms = int((time.perf_counter() - started) * 1000) raise DetectError( _injected_fail.code, _injected_fail.message, _injected_fail.status_code, {**(_injected_fail.diagnostics or {}), **stats.public()}, ) config = detect_provider() if not config: stats.abort_reason = ERROR_DETECT_UNAVAILABLE stats.detect_ms = int((time.perf_counter() - started) * 1000) raise DetectError( ERROR_DETECT_UNAVAILABLE, "Semantische Detection ist nicht konfiguriert. Generate wird nicht freigegeben.", diagnostics=stats.public(), ) if config.mode == "http" and not config.local and not allows_remote_plaintext_detect(): stats.abort_reason = "remote_detect_blocked_production" stats.detect_ms = int((time.perf_counter() - started) * 1000) raise DetectError( ERROR_DETECT_UNAVAILABLE, "Externes Klartext-Detect ist im Produktivmodus nicht zulässig, und ein lokales Detect-Modell fehlt.", diagnostics=stats.public(), ) stats.detect_provider = config.name stats.detect_model = config.model chunks = split_detect_chunks(source_text or "") stats.chunk_count = len(chunks) collected: list[DetectedSpan] = [] try: for index, (offset, chunk_text) in enumerate(chunks): if _injected_truncated: raise DetectError( ERROR_DETECT_TRUNCATED, "Detect-Ausgabe wurde abgeschnitten. Generate wird nicht freigegeben.", ) if _injected_spans is not None: raw_items = [ item for item in _injected_spans if int(item.get("start") or 0) >= offset and int(item.get("end") or 0) <= offset + len(chunk_text) ] local_items = [] for item in raw_items: local = dict(item) local["start"] = int(item["start"]) - offset local["end"] = int(item["end"]) - offset local_items.append(local) result = ChatResult( content=json.dumps({"entities": local_items}), model=config.model, usage={}, finish_reason="stop", ) stats.detect_note = "injected" elif config.mode == "fake": fake_items = _contract_fake_spans(chunk_text) result = ChatResult( content=json.dumps({"entities": fake_items}), model=config.model, usage={}, finish_reason="stop", ) stats.detect_note = "fake" elif uses_llm_detect(config): try: result = _llm_chunk(config, chunk_text) except PlaceholderError as exc: raise DetectError(exc.code, exc.message) from exc except ProviderError as exc: raise DetectError( ERROR_DETECT_CHUNK, "Ein Detect-Chunk ist fehlgeschlagen. Generate wird nicht freigegeben.", exc.status_code, ) from exc stats.detect_note = "local_llm" if config.local else "remote_llm" stats.detect_model = result.model or stats.detect_model else: raise DetectError( ERROR_DETECT_UNAVAILABLE, "Semantische Detection ist nicht verfügbar. Generate wird nicht freigegeben.", ) stats.detect_calls += 1 _add_usage(stats, result.usage) spans = _entities_from_result(result, chunk_text, index) collected.extend(_to_global(span, offset) for span in spans) stats.chunks_ok += 1 except DetectError as exc: stats.abort_reason = exc.code stats.full_detection_coverage = False stats.detect_ms = int((time.perf_counter() - started) * 1000) raise DetectError( exc.code, exc.message, exc.status_code, {**(exc.diagnostics or {}), **stats.public()}, ) from exc if stats.chunks_ok != stats.chunk_count: stats.abort_reason = ERROR_DETECT_INCOMPLETE stats.detect_ms = int((time.perf_counter() - started) * 1000) raise DetectError( ERROR_DETECT_INCOMPLETE, "Detection hat nicht alle Chunks geprüft. Generate wird nicht freigegeben.", diagnostics=stats.public(), ) merged = _dedupe_spans(collected) confirmed = list_confirmed_identities(profile_id) if profile_id else [] mappings, request_local, confirmed_from_spans = _assign_request_tokens(merged, confirmed) mappings, extra_confirmed = _merge_confirmed_safety_net(source_text or "", mappings, profile_id) stats.request_local_hits = request_local stats.confirmed_registry_hits = confirmed_from_spans + extra_confirmed counts: dict[str, int] = {kind: 0 for kind in ENTITY_TYPES} for item in mappings: kind = (item.get("entity_type") or "PERSON").upper() counts[kind] = counts.get(kind, 0) + 1 stats.entity_counts = {key: value for key, value in counts.items() if value} stats.full_detection_coverage = True stats.detect_ms = int((time.perf_counter() - started) * 1000) if profile_id: seen_proposals: set[tuple[str, str]] = set() for item in mappings: if item.get("source") != "request_local": continue key = ((item.get("local_label") or ""), (item.get("entity_type") or "")) if key in seen_proposals: continue seen_proposals.add(key) record_review_proposal(profile_id, item.get("local_label") or "", item.get("entity_type") or "") local_identities = [ { "local_label": item.get("local_label"), "token": item.get("token"), "entity_type": item.get("entity_type"), "demask_label": item.get("demask_label") or item.get("local_label"), "source": item.get("source"), } for item in mappings ] return DetectionOutcome(mappings=mappings, stats=stats, local_identities=local_identities)