"""Local identity mappings. Class A; never part of an external prompt.""" from __future__ import annotations import json import re import uuid from functools import lru_cache from pathlib import Path from db import get_db, row_to_dict PROMPTS_SEED_PATH = Path(__file__).resolve().parent / "config" / "prompts.seed.json" TOKEN_RE = re.compile(r"^(SELF|PERSON:[A-Z][A-Z0-9_]{0,24}|PLACE:[A-Z][A-Z0-9_]{0,24}|ORG:[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"} 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", "himmel", "luft", "regen", "sonne", "wind", "meer", "tee", "kaffee", "balkon", "laden", "brot", "brote", "bad", "gasse", "gassen", "weg", "ort", "ortes", "stadt", "zimmer", "haus", "frau", "herr", "mann", "kind", "lektion", "bootstour", "frühstück", "tagebuch", "omlett", "haferflocken", "speiseplan", "impuls", "tatsache", "motivation", "erzählung", "operation", "uhr", "zeit", "stunde", "minute", "tag", "tages", "abend", "nacht", "warm", "klar", "draußen", "menge", "teil", "stück", "relativ", "allerdings", "zunächst", "danach", "deshalb", "trotzdem", "außerdem", "schließlich", "während", "bevor", "seitdem", "deswegen", "überlegen", "feststellen", "frage", "antwort", "markt", "kirschen", "hafen", "wetter", "vormittag", "nachmittag", "anreise", "details", "absatz", "essen", "licht", "zwiebeln", "zwiebel", "tomaten", "tomate", "paprika", "omelett", "trockenobst", "trockenfrüchte", "glutenfrei", "schiff", "boot", "zweimaster", "fähre", } FUNCTION_STEMS = ("letzt", "nächst") NOUN_SUFFIXES = ("heit", "keit", "schaft", "tum") COLLECTIVE_NOUN = re.compile(r"^ge[a-zäöüß]+öse$", re.IGNORECASE) RESERVED_TOKEN_SUFFIXES = {"KURZ", "NAME", "TEXT", "LABEL", "EXAMPLE", "FOO", "BAR"} @lru_cache(maxsize=1) def instruction_vocab() -> frozenset[str]: """Words from Kanshō prompts must never become identity labels.""" items = json.loads(PROMPTS_SEED_PATH.read_text(encoding="utf-8")) words: set[str] = set() for item in items: if (item.get("slug") or "") == "mvp.entity_detect": continue template = item.get("template") or "" template = re.sub(r"\{\{[^}]+\}\}|\[\[[^\]]+\]\]", " ", template) for word in re.findall(r"[A-Za-zÄÖÜäöüß]{4,}", template): words.add(word.casefold()) return frozenset(words) def is_function_label(label: str) -> bool: word = (label or "").strip().casefold() if not word: return False for stem in FUNCTION_STEMS: if word == stem or word.startswith(stem): return True return False def is_common_noun_shape(label: str) -> bool: word = (label or "").strip() if COLLECTIVE_NOUN.match(word): return True lower = word.casefold() if lower.endswith("ung") and len(lower) > 6: return True return any(lower.endswith(suffix) for suffix in NOUN_SUFFIXES) def is_given_name_candidate(label: str) -> bool: core = normalize_label(label) if not core or " " in core: return False if is_function_label(core) or is_common_noun_shape(core): return False return is_maskable_label(core) def normalize_label(label: str) -> str: words = re.findall(r"[A-Za-zÄÖÜäöüß0-9.:]+", label or "") keep = [ word for word in words if word.lower() not in DETERMINERS and word.lower() not in KINSHIP and word.lower() not in UNMASKABLE ] return " ".join(keep).strip() def is_maskable_label(label: str) -> bool: core = normalize_label(label) if len(core) < MIN_LABEL_LEN: return False if core.lower() in UNMASKABLE: return False if is_function_label(core) or is_common_noun_shape(core): return False if core.casefold() in instruction_vocab(): 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 purge_unmaskable(profile_id: str) -> int: removed = 0 with get_db() as conn: rows = conn.execute( "SELECT id, local_label FROM identity_mappings WHERE profile_id = ?", (profile_id,), ).fetchall() for row in rows: if is_maskable_label(row["local_label"]): continue conn.execute("DELETE FROM identity_mappings WHERE id = ?", (row["id"],)) removed += 1 return removed def list_mappings(profile_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( "SELECT token, local_label FROM identity_mappings WHERE profile_id = ? ORDER BY created", (profile_id,), ).fetchall() return [row_to_dict(row) for row in rows] def _token_suffix(token: str) -> str: raw = (token or "").upper() return raw.split(":")[-1] if ":" in raw 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 _same_core(left: str, right: str) -> bool: a = normalize_label(left).casefold() b = normalize_label(right).casefold() return bool(a and b and a == b) 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: stored = (item.get("local_label") or "").strip() if stored.casefold() == label.casefold() or _same_core(stored, label): return item return None def _pick_keeper(rows: list) -> dict: def sort_key(row: dict) -> tuple: suffix = _token_suffix(row["token"] or "") reserved = 1 if suffix in RESERVED_TOKEN_SUFFIXES else 0 numeric = 0 if suffix.isdigit() else 1 return (reserved, numeric, row["created"] or "", row["id"] or "") return min(rows, key=sort_key) def coalesce_mappings(profile_id: str) -> int: """One token per normalized name. Template tokens like PERSON:KURZ lose against PERSON:08.""" merged = 0 with get_db() as conn: rows = [ row_to_dict(row) for row in conn.execute( """ SELECT id, token, local_label, 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("local_label") or "") if not core or not is_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 = _pick_keeper(items) core = items[0]["core"] if (keeper.get("local_label") or "") != core: conn.execute( "UPDATE identity_mappings SET local_label = ? WHERE id = ?", (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 _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 remember_mapping(profile_id: str, local_label: str, suggested_token: str | None = None) -> dict: label = normalize_label(local_label) if not label or not is_maskable_label(label): raise ValueError("empty_label") existing = list_mappings(profile_id) found = find_existing_mapping(existing, label) if found: stored = (found.get("local_label") or "").strip() if stored != label: with get_db() as conn: conn.execute( """ UPDATE identity_mappings SET local_label = ? WHERE profile_id = ? AND token = ? """, (label, profile_id, found["token"]), ) found = {**found, "local_label": label} return found token = normalize_token(suggested_token) if token and any((item.get("token") or "").upper() == token for item in existing): token = None if not token: token = _next_generic(existing, "PERSON:") with get_db() as conn: conn.execute( """ INSERT INTO identity_mappings (id, profile_id, token, local_label) VALUES (?, ?, ?, ?) """, (str(uuid.uuid4()), profile_id, token, label), ) return {"token": token, "local_label": label}