"""Confirmed identity registry. Class A; never part of an external prompt. Detect output is not stored here. Request-local detections live only on the request manifest. Review proposals are unconfirmed and never used for masking. """ from __future__ import annotations import json import re import uuid from db import get_db, row_to_dict TOKEN_RE = re.compile( r"^(SELF|PERSON:[A-Z][A-Z0-9_]{0,24}|PLACE:[A-Z][A-Z0-9_]{0,24}|" r"ORG:[A-Z][A-Z0-9_]{0,24}|PROJECT:[A-Z][A-Z0-9_]{0,24})$" ) MIN_LABEL_LEN = 4 TIME_RE = re.compile(r"\d|:\d|\buhr\b|\bminuten\b|\bstunde\b", re.IGNORECASE) DETERMINERS = { "der", "die", "das", "den", "dem", "des", "ein", "eine", "einen", "einem", "eines", "mein", "meine", "meinen", "meinem", "meiner", "meines", "dein", "deine", "sein", "seine", "ihr", "ihre", "unser", "unsere", "euer", "eure", } KINSHIP = {"frau", "herr", "mann", "kind", "tochter", "sohn", "partner", "partnerin"} STRUCTURAL_UNMASKABLE = { "ich", "du", "er", "sie", "es", "wir", "ihr", "mich", "mir", "mein", "meine", "dir", "dich", "uns", "euch", "sein", "seine", "ihre", "heute", "gestern", "morgen", "user", "assistant", "self", "kontext", } RESERVED_TOKEN_SUFFIXES = {"KURZ", "NAME", "TEXT", "LABEL", "EXAMPLE", "FOO", "BAR"} ENTITY_TYPES = ("PERSON", "PLACE", "ORG", "PROJECT") STATUS_CONFIRMED = "confirmed" STATUS_INACTIVE = "inactive" STATUS_LEGACY = "legacy_review_required" ORIGIN_USER_CONFIRMED = "user_confirmed" ORIGIN_LOCAL = "local_authoritative" ORIGIN_LEGACY = "legacy_auto" PROPOSAL_UNCONFIRMED = "unconfirmed" PROPOSAL_DISMISSED = "dismissed" PROPOSAL_ORIGIN = "detect_proposal" # Backward-compatible alias. Not a detection stopword list. UNMASKABLE = STRUCTURAL_UNMASKABLE def normalize_label(label: str) -> str: words = re.findall(r"[0-9A-Za-zÀ-žÄÖÜäöüß.:]+", label or "", re.UNICODE) keep = [ word for word in words if word.lower() not in DETERMINERS and word.lower() not in KINSHIP ] return " ".join(keep).strip() def is_maskable_label(label: str) -> bool: """Structural gate only: pronouns, roles, clocks. Not a food/weather word list.""" core = normalize_label(label) if len(core) < MIN_LABEL_LEN: return False if core.lower() in STRUCTURAL_UNMASKABLE: return False if TIME_RE.search(core): return False if re.fullmatch(r"user|assistant|self", core, re.IGNORECASE): return False if not re.search(r"[A-Za-zÀ-žÄÖÜäöüß]", core): return False return True def is_registry_maskable_label(label: str) -> bool: """Confirmed names may be shorter than the detect gate. Not a semantic word list.""" core = (label or "").strip() if len(core) < 2: return False if core.lower() in STRUCTURAL_UNMASKABLE: return False if TIME_RE.search(core): return False if re.fullmatch(r"user|assistant|self", core, re.IGNORECASE): return False if not re.search(r"[A-Za-zÀ-žÄÖÜäöüß]", core): return False return True def _token_suffix(token: str) -> str: raw = (token or "").upper() return raw.split(":")[-1] if ":" in token else raw def normalize_token(token: str | None) -> str | None: raw = (token or "").strip().upper().replace(" ", "_") raw = raw[2:-2] if raw.startswith("[[") and raw.endswith("]]") else raw if not TOKEN_RE.match(raw): return None if raw.count("_") > 1: return None if _token_suffix(raw) in RESERVED_TOKEN_SUFFIXES: return None return raw def normalize_entity_type(value: str | None, *, default: str = "PERSON") -> str | None: raw = (value or "").strip().upper() if not raw: raw = default if raw not in ENTITY_TYPES: return None return raw def parse_aliases(raw) -> list[str]: if isinstance(raw, list): items = raw else: try: items = json.loads(raw or "[]") except (TypeError, json.JSONDecodeError): items = [] seen: set[str] = set() result: list[str] = [] for item in items: label = (item or "").strip() if not label or label.casefold() in seen or not is_registry_maskable_label(label): continue seen.add(label.casefold()) result.append(label) return result def _row_public(row: dict | None) -> dict | None: if not row: return None canonical = (row.get("canonical_label") or row.get("local_label") or "").strip() aliases = [ item for item in parse_aliases(row.get("aliases_json")) if item.casefold() != canonical.casefold() ] return { "id": row.get("id"), "token": row.get("token"), "local_label": canonical, "canonical_label": canonical, "entity_type": (row.get("entity_type") or "PERSON").upper(), "status": row.get("status") or STATUS_LEGACY, "origin": row.get("origin") or ORIGIN_LEGACY, "aliases": aliases, "created": row.get("created"), "updated": row.get("updated"), "confirmed_at": row.get("confirmed_at"), } def _select_all(profile_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT * FROM identity_mappings WHERE profile_id = ? ORDER BY created """, (profile_id,), ).fetchall() return [_row_public(row_to_dict(row)) for row in rows] def list_registry(profile_id: str, *, include_inactive: bool = True) -> list[dict]: rows = [item for item in _select_all(profile_id) if item] if include_inactive: return rows return [item for item in rows if item.get("status") == STATUS_CONFIRMED] def list_confirmed_identities(profile_id: str) -> list[dict]: return [item for item in _select_all(profile_id) if item and item.get("status") == STATUS_CONFIRMED] def list_mappings(profile_id: str) -> list[dict]: """Confirmed identities only. Legacy and inactive rows are not a masking dictionary.""" return list_confirmed_identities(profile_id) def confirmed_match_labels(item: dict) -> list[str]: labels = [] canonical = (item.get("canonical_label") or item.get("local_label") or "").strip() if canonical: labels.append(canonical) for alias in item.get("aliases") or []: if alias and alias.casefold() != canonical.casefold(): labels.append(alias) return labels def mapping_spellings(item: dict) -> list[str]: """All known writings of one mapping: canonical, aliases, observed label, demask form.""" seen: set[str] = set() result: list[str] = [] def add(raw) -> None: label = (raw or "").strip() if not label or label.casefold() in seen or not is_registry_maskable_label(label): return seen.add(label.casefold()) result.append(label) add(item.get("local_label")) add(item.get("canonical_label")) add(item.get("demask_label")) for alias in item.get("aliases") or []: add(alias) for label in item.get("labels") or []: add(label) return result def masking_rows_from_confirmed(profile_id: str) -> list[dict]: """One masking row per confirmed canonical label or alias. Demask uses canonical.""" rows: list[dict] = [] for item in list_confirmed_identities(profile_id): token = (item.get("token") or "").strip() canonical = (item.get("canonical_label") or "").strip() entity_type = (item.get("entity_type") or "PERSON").upper() if not token or not canonical: continue for label in confirmed_match_labels(item): if not is_registry_maskable_label(label): continue rows.append( { "token": token, "local_label": label, "canonical_label": canonical, "demask_label": canonical, "entity_type": entity_type, "source": "confirmed_registry", "aliases": [item for item in confirmed_match_labels(item) if item.casefold() != label.casefold()], "labels": confirmed_match_labels(item), } ) return rows def find_existing_mapping(existing: list[dict], label: str) -> dict | None: core = normalize_label(label).casefold() if not core: return None for item in existing: candidates = [item.get("canonical_label") or "", item.get("local_label") or ""] candidates.extend(item.get("aliases") or []) for stored in candidates: stored_core = normalize_label(stored).casefold() if stored.casefold() == label.casefold() or (stored_core and stored_core == core): return item return None def _next_generic(existing: list[dict], prefix: str) -> str: used = set() for item in existing: token = (item.get("token") or "").upper() if token.startswith(prefix): suffix = token[len(prefix) :] if suffix.isdigit(): used.add(int(suffix)) n = 1 while n in used: n += 1 return f"{prefix}{n:02d}" def _prefix_for_type(entity_type: str) -> str: return f"{entity_type}:" def confirm_identity( profile_id: str, canonical_label: str, *, entity_type: str = "PERSON", token: str | None = None, aliases: list[str] | None = None, origin: str = ORIGIN_USER_CONFIRMED, identity_id: str | None = None, ) -> dict: label = normalize_label(canonical_label) or (canonical_label or "").strip() if not label or not is_registry_maskable_label(label): raise ValueError("empty_label") kind = normalize_entity_type(entity_type) if not kind: raise ValueError("invalid_entity_type") alias_list = parse_aliases(aliases or []) alias_list = [item for item in alias_list if item.casefold() != label.casefold()] existing = _select_all(profile_id) found = None if identity_id: found = next((item for item in existing if item.get("id") == identity_id), None) if found is None: found = find_existing_mapping(existing, label) chosen_token = normalize_token(token) if found: chosen_token = found["token"] elif chosen_token and any((item.get("token") or "").upper() == chosen_token for item in existing): chosen_token = None if not chosen_token: chosen_token = _next_generic(existing, _prefix_for_type(kind)) row_id = (found or {}).get("id") or str(uuid.uuid4()) with get_db() as conn: conn.execute( """ INSERT INTO identity_mappings ( id, profile_id, token, local_label, canonical_label, entity_type, status, origin, aliases_json, confirmed_at, updated ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(id) DO UPDATE SET token = excluded.token, local_label = excluded.local_label, canonical_label = excluded.canonical_label, entity_type = excluded.entity_type, status = excluded.status, origin = excluded.origin, aliases_json = excluded.aliases_json, confirmed_at = datetime('now'), updated = datetime('now') """, ( row_id, profile_id, chosen_token, label, label, kind, STATUS_CONFIRMED, origin if origin in {ORIGIN_USER_CONFIRMED, ORIGIN_LOCAL} else ORIGIN_USER_CONFIRMED, json.dumps(alias_list, ensure_ascii=False), ), ) return next(item for item in _select_all(profile_id) if item.get("id") == row_id) def remember_mapping(profile_id: str, local_label: str, suggested_token: str | None = None) -> dict: """Explicit local confirmation helper for tests and admin. Not the detect path.""" token = normalize_token(suggested_token) entity_type = "PERSON" if token and ":" in token: entity_type = token.split(":", 1)[0] return confirm_identity( profile_id, local_label, entity_type=entity_type if entity_type in ENTITY_TYPES else "PERSON", token=suggested_token, origin=ORIGIN_LOCAL, ) def update_identity( profile_id: str, identity_id: str, *, canonical_label: str | None = None, entity_type: str | None = None, aliases: list[str] | None = None, status: str | None = None, ) -> dict: rows = _select_all(profile_id) found = next((item for item in rows if item.get("id") == identity_id), None) if not found: raise ValueError("identity_missing") label = found["canonical_label"] if canonical_label is not None: label = normalize_label(canonical_label) or canonical_label.strip() if not label or not is_registry_maskable_label(label): raise ValueError("empty_label") kind = found["entity_type"] if entity_type is not None: kind = normalize_entity_type(entity_type) if not kind: raise ValueError("invalid_entity_type") alias_list = found.get("aliases") or [] if aliases is not None: alias_list = [item for item in parse_aliases(aliases) if item.casefold() != label.casefold()] next_status = found["status"] if status is not None: if status not in {STATUS_CONFIRMED, STATUS_INACTIVE, STATUS_LEGACY}: raise ValueError("invalid_status") next_status = status with get_db() as conn: conn.execute( """ UPDATE identity_mappings SET local_label = ?, canonical_label = ?, entity_type = ?, aliases_json = ?, status = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN datetime('now') ELSE confirmed_at END, origin = CASE WHEN ? = 'confirmed' AND origin = 'legacy_auto' THEN 'user_confirmed' ELSE origin END, updated = datetime('now') WHERE id = ? AND profile_id = ? """, ( label, label, kind, json.dumps(alias_list, ensure_ascii=False), next_status, next_status, next_status, identity_id, profile_id, ), ) return next(item for item in _select_all(profile_id) if item.get("id") == identity_id) def deactivate_identity(profile_id: str, identity_id: str) -> dict: return update_identity(profile_id, identity_id, status=STATUS_INACTIVE) def delete_identity(profile_id: str, identity_id: str) -> None: with get_db() as conn: conn.execute( "DELETE FROM identity_mappings WHERE id = ? AND profile_id = ?", (identity_id, profile_id), ) def list_review_proposals(profile_id: str, *, include_dismissed: bool = False) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT * FROM identity_review_proposals WHERE profile_id = ? ORDER BY last_seen DESC """, (profile_id,), ).fetchall() result = [] for row in rows: item = row_to_dict(row) if not include_dismissed and item.get("status") != PROPOSAL_UNCONFIRMED: continue result.append(item) return result def record_review_proposal(profile_id: str, observed_label: str, entity_type: str) -> None: """Local unconfirmed note only. Never used for masking or later detection skip.""" label = (observed_label or "").strip() kind = normalize_entity_type(entity_type) if not profile_id or not label or not kind or not is_maskable_label(label): return if find_existing_mapping(list_confirmed_identities(profile_id), label): return with get_db() as conn: existing = row_to_dict( conn.execute( """ SELECT id FROM identity_review_proposals WHERE profile_id = ? AND observed_label = ? AND entity_type = ? """, (profile_id, label, kind), ).fetchone() ) if existing: conn.execute( """ UPDATE identity_review_proposals SET last_seen = datetime('now'), status = CASE WHEN status = 'dismissed' THEN status ELSE 'unconfirmed' END WHERE id = ? """, (existing["id"],), ) return conn.execute( """ INSERT INTO identity_review_proposals (id, profile_id, observed_label, entity_type, status, origin) VALUES (?, ?, ?, ?, ?, ?) """, (str(uuid.uuid4()), profile_id, label, kind, PROPOSAL_UNCONFIRMED, PROPOSAL_ORIGIN), ) def confirm_review_proposal(profile_id: str, proposal_id: str, **overrides) -> dict: with get_db() as conn: row = row_to_dict( conn.execute( "SELECT * FROM identity_review_proposals WHERE id = ? AND profile_id = ?", (proposal_id, profile_id), ).fetchone() ) if not row: raise ValueError("proposal_missing") confirmed = confirm_identity( profile_id, overrides.get("canonical_label") or row["observed_label"], entity_type=overrides.get("entity_type") or row["entity_type"], aliases=overrides.get("aliases"), origin=ORIGIN_USER_CONFIRMED, ) with get_db() as conn: conn.execute( "DELETE FROM identity_review_proposals WHERE id = ? AND profile_id = ?", (proposal_id, profile_id), ) return confirmed def dismiss_review_proposal(profile_id: str, proposal_id: str) -> None: with get_db() as conn: conn.execute( """ UPDATE identity_review_proposals SET status = ?, last_seen = datetime('now') WHERE id = ? AND profile_id = ? """, (PROPOSAL_DISMISSED, proposal_id, profile_id), ) def delete_review_proposal(profile_id: str, proposal_id: str) -> None: with get_db() as conn: conn.execute( "DELETE FROM identity_review_proposals WHERE id = ? AND profile_id = ?", (proposal_id, profile_id), ) def purge_unmaskable(profile_id: str) -> int: removed = 0 with get_db() as conn: rows = conn.execute( "SELECT id, local_label, canonical_label FROM identity_mappings WHERE profile_id = ?", (profile_id,), ).fetchall() for row in rows: label = row["canonical_label"] or row["local_label"] if is_registry_maskable_label(label): continue conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],)) removed += 1 return removed def coalesce_mappings(profile_id: str) -> int: """One confirmed/legacy row per normalized canonical name.""" merged = 0 with get_db() as conn: rows = [ row_to_dict(row) for row in conn.execute( """ SELECT id, token, local_label, canonical_label, entity_type, status, origin, created FROM identity_mappings WHERE profile_id = ? ORDER BY created """, (profile_id,), ).fetchall() ] groups: dict[str, list[dict]] = {} for row in rows: core = normalize_label(row.get("canonical_label") or row.get("local_label") or "") if not core or not is_registry_maskable_label(core): conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],)) merged += 1 continue groups.setdefault(core.casefold(), []).append({**row, "core": core}) for items in groups.values(): keeper = min( items, key=lambda row: ( 0 if (row.get("status") == STATUS_CONFIRMED) else 1, 0 if _token_suffix(row.get("token") or "").isdigit() else 1, row.get("created") or "", row.get("id") or "", ), ) core = items[0]["core"] if (keeper.get("canonical_label") or keeper.get("local_label") or "") != core: conn.execute( """ UPDATE identity_mappings SET local_label = ?, canonical_label = ?, updated = datetime('now') WHERE id = ? """, (core, core, keeper["id"]), ) for row in items: if row["id"] == keeper["id"]: continue conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],)) merged += 1 return merged def migrate_legacy_identity_rows(conn) -> int: """Mark pre-existing auto-mappings as review-required. Never auto-confirm them.""" names = {row["name"] for row in conn.execute("PRAGMA table_info(identity_mappings)").fetchall()} if "status" not in names: return 0 conn.execute( """ UPDATE identity_mappings SET updated = COALESCE(NULLIF(updated, ''), created) WHERE updated IS NULL OR updated = '' """ ) marked = 0 rows = conn.execute( "SELECT id, local_label, canonical_label, status, origin FROM identity_mappings" ).fetchall() for row in rows: canonical = (row["canonical_label"] or row["local_label"] or "").strip() status = row["status"] or "" origin = row["origin"] or "" updates: list[str] = [] values: list[str] = [] if not (row["canonical_label"] or "").strip() and canonical: updates.append("canonical_label = ?") values.append(canonical) if not status or (status == STATUS_CONFIRMED and origin == ORIGIN_LEGACY): updates.append("status = ?") values.append(STATUS_LEGACY) if not origin: updates.append("origin = ?") values.append(ORIGIN_LEGACY) if not updates: continue values.append(row["id"]) conn.execute( f"UPDATE identity_mappings SET {', '.join(updates)}, updated = datetime('now') WHERE id = ?", values, ) marked += 1 return marked