Kansho/backend/identity_store.py
2026-08-28 08:40:28 +02:00

627 lines
22 KiB
Python

"""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 _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_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_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_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_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_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_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_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