603 lines
21 KiB
Python
603 lines
21 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 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",
|
|
}
|
|
)
|
|
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
|