diff --git a/backend/detect_learning.py b/backend/detect_learning.py index e68772b..20f392d 100644 --- a/backend/detect_learning.py +++ b/backend/detect_learning.py @@ -11,7 +11,7 @@ import uuid from typing import Any from db import get_db, row_to_dict -from identity_store import ENTITY_TYPES, confirm_identity, is_maskable_label, normalize_label +from identity_store import KINSHIP, ENTITY_TYPES, confirm_identity, is_maskable_label, normalize_label MODE_SEMANTIC = "semantic" MODE_LEARNING = "learning" @@ -163,6 +163,107 @@ def needs_review(profile_id: str, mapping: dict, user_body: str) -> bool: return True +def _word_matches(text: str): + return list(re.finditer(rf"[{_LETTER}]+", text or "")) + + +def _prev_word(text: str, index: int) -> str: + words = re.findall(rf"[{_LETTER}]+", text[:index]) + return (words[-1].lower() if words else "") + + +def kinship_governed_labels(user_body: str) -> set[str]: + """Labels after Frau/Sohn/… in the current user line. Not a food word list.""" + found: set[str] = set() + for match in _word_matches(user_body): + label = match.group(0) + if not is_maskable_label(label): + continue + if _prev_word(user_body, match.start()) in KINSHIP: + found.add(label.casefold()) + return found + + +def _align_user_span(rendered: str, user_body: str, start: int, end: int) -> tuple[int, int]: + if not user_body: + return start, end + pos = (rendered or "").rfind(user_body) + if pos < 0: + return start, end + return pos + start, pos + end + + +def _token_for_label(mappings: list[dict], label: str, entity_type: str = "PERSON") -> str: + needle = label.casefold() + for mapping in mappings: + if (mapping.get("local_label") or "").casefold() == needle and mapping.get("token"): + return mapping["token"] + return f"{entity_type}:L{uuid.uuid4().hex[:6].upper()}" + + +def supplement_kinship_candidates( + profile_id: str, + candidates: list[dict], + mappings: list[dict], + user_body: str, + rendered: str = "", +) -> tuple[list[dict], list[dict]]: + """If Detect misses Frau X / Sohn X, still offer those user-line mentions for review.""" + wanted = kinship_governed_labels(user_body) + if not wanted: + return candidates, mappings + occupied: set[tuple[int, int, str]] = set() + for item in candidates: + label = (item.get("text") or "").casefold() + excerpt = item.get("excerpt") or "" + for match in _word_matches(user_body): + if match.group(0).casefold() != label: + continue + snippet = _excerpt(user_body, match.start(), match.end(), match.group(0)) + if snippet == excerpt or match.group(0) in excerpt: + occupied.add((match.start(), match.end(), label)) + break + extra_mappings = list(mappings) + extra_candidates = list(candidates) + for match in _word_matches(user_body): + label = match.group(0) + if label.casefold() not in wanted: + continue + user_key = (match.start(), match.end(), label.casefold()) + if user_key in occupied: + continue + occupied.add(user_key) + start, end = _align_user_span(rendered, user_body, match.start(), match.end()) + identity_shaped = _prev_word(user_body, match.start()) in KINSHIP + extra_candidates.append( + { + "id": str(uuid.uuid4()), + "text": label, + "entity_type": "PERSON", + "start": start, + "end": end, + "excerpt": _excerpt(user_body, match.start(), match.end(), label), + "ambiguous": get_sense(profile_id, label)["ambiguous"], + "suggested": _DECISION_IDENTITY if identity_shaped else _DECISION_NOT, + } + ) + extra_mappings.append( + { + "token": _token_for_label(extra_mappings, label), + "local_label": label, + "canonical_label": label, + "demask_label": label, + "entity_type": "PERSON", + "source": "request_local", + "start": start, + "end": end, + "aliases": [], + "labels": [label], + } + ) + return extra_candidates, extra_mappings + + def build_candidates(profile_id: str, mappings: list[dict], user_body: str) -> list[dict]: seen: set[tuple[int | None, int | None, str]] = set() items: list[dict] = [] diff --git a/backend/dialogue_turn.py b/backend/dialogue_turn.py index 10decd5..4a7cfaf 100644 --- a/backend/dialogue_turn.py +++ b/backend/dialogue_turn.py @@ -17,6 +17,7 @@ from detect_learning import ( get_detect_operating_mode, load_pending, save_pending, + supplement_kinship_candidates, suppress_known_non_identity, ) from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals @@ -439,6 +440,9 @@ def _learning_pause(profile_id: str, conversation_id: str, user: dict, assembled user_body = user.get("body") or "" mappings = list(outcome.mappings or []) candidates = build_candidates(profile_id, mappings, user_body) + candidates, mappings = supplement_kinship_candidates( + profile_id, candidates, mappings, user_body, rendered + ) candidates, mappings = auto_resolve_ambiguous(profile_id, candidates, mappings) if not candidates: confirm_known_identity_spans(profile_id, mappings, user_body) diff --git a/backend/tests/test_detect_learning.py b/backend/tests/test_detect_learning.py index 9f53925..2775427 100644 --- a/backend/tests/test_detect_learning.py +++ b/backend/tests/test_detect_learning.py @@ -176,6 +176,34 @@ def main() -> None: expect(not amb.json().get("pending_mask_review"), "local passage can finish without a popup") expect(any(item.get("role") == "assistant" for item in amb.json().get("messages") or []), "local passage still generates") + kin_conv = open_conv(client, headers) + kin = client.post( + f"/api/journal/conversations/{kin_conv}/turn", + headers=headers, + json={"body": "Ich war mit meinem Sohn Rohan im Park."}, + ) + status_ok(kin, "kinship Rohan pause") + kin_review = kin.json().get("pending_mask_review") or {} + kin_names = [item.get("text") for item in kin_review.get("candidates") or []] + expect("Rohan" in kin_names, "Sohn Rohan is offered even when Detect does not report it") + + sushi_conv = open_conv(client, headers) + sushi = client.post( + f"/api/journal/conversations/{sushi_conv}/turn", + headers=headers, + json={ + "body": ( + "Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan " + "Sushi essen gegangen. Das Restaurant hat mich dabei total beeindruckt." + ) + }, + ) + status_ok(sushi, "homonym sentence pause") + sushi_names = [item.get("text") for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or []] + expect("Rohan" in sushi_names, "Rohan from Sohn is a candidate") + expect("Sushi" in sushi_names, "Sushi from Frau is a candidate") + expect("Restaurant" not in sushi_names, "plain Restaurant is not kinship-offered") + back = client.put("/api/admin/providers/detect-mode", headers=headers, json={"mode": "semantic"}) expect(back.json()["detect_operating_mode"] == "semantic", "mode can return to semantic") diff --git a/docs/architecture/technical/privacy_gateway.md b/docs/architecture/technical/privacy_gateway.md index b388969..eca0d50 100644 --- a/docs/architecture/technical/privacy_gateway.md +++ b/docs/architecture/technical/privacy_gateway.md @@ -261,7 +261,7 @@ Additiv. Fachliches Home: `../functional/guardrails.md` §22.4. Ersetzt weder se Default bleibt `semantic`: Detect läuft wie bisher, Generate folgt ohne Pause. `learning` ist ein Admin-Schalter (`PUT /api/admin/providers/detect-mode`), kein Gateway-Bypass und keine Wortlisten-UI. -Im Lernmodus untersucht Detect weiterhin den vollen gerenderten Generate-Egress. Bevor Generate startet, werden `request_local`-Spans im aktuellen Nutzersatz zur Bestätigung angeboten (Dialog und Journal-Gespräch, nicht Journal-Generate). „Identität“ bestätigt die lokale Registry und zählt einen Identitätssinn. „Nicht schützenswert“ maskiert diese Nennung nicht und zählt den anderen Sinn. Detect-Ausgabe allein speichert weiterhin keine aktive Identität. +Im Lernmodus untersucht Detect weiterhin den vollen gerenderten Generate-Egress. Bevor Generate startet, werden `request_local`-Spans im aktuellen Nutzersatz zur Bestätigung angeboten (Dialog und Journal-Gespräch, nicht Journal-Generate). Zusätzlich: Nennungen nach Verwandtschaftswörtern der bestehenden Identitätsregel (`Frau`, `Sohn`, …) im Nutzersatz, auch wenn Detect sie als Gericht weglässt oder nicht meldet. Andere Großschreibung (`Restaurant`) wird dadurch nicht angeboten. „Identität“ bestätigt die lokale Registry und zählt einen Identitätssinn. „Nicht schützenswert“ maskiert diese Nennung nicht und zählt den anderen Sinn. Detect-Ausgabe allein speichert weiterhin keine aktive Identität. Es gibt keine separat zu pflegende Doppeldeutigkeitsliste. Mehrdeutigkeit entsteht, wenn dieselbe Schreibweise beide Sinne hat. Nur dann darf ein **lokales** Detect-Modell eine Mini-Passage (Ausschnitt um die Nennung) entscheiden. Fehlt ein lokales Modell oder ist die Antwort unbrauchbar, bleibt das Popup. OpenRouter erhält diese Passage nicht. diff --git a/frontend/src/components/MaskReviewPanel.jsx b/frontend/src/components/MaskReviewPanel.jsx index 07934b1..cbd5219 100644 --- a/frontend/src/components/MaskReviewPanel.jsx +++ b/frontend/src/components/MaskReviewPanel.jsx @@ -34,11 +34,11 @@ export default function MaskReviewPanel({ review, busy, onSubmit }) {