diff --git a/backend/db.py b/backend/db.py index e9db051..a96822f 100644 --- a/backend/db.py +++ b/backend/db.py @@ -594,6 +594,7 @@ def init_db() -> None: migrate_journal_source_refs(conn) _migrate_writing_profile_shell(conn) _ensure_columns(conn, "identity_mappings", _IDENTITY_MAPPING_COLUMNS) + _ensure_columns(conn, "label_sense_cues", {"excerpt": "TEXT NOT NULL DEFAULT ''"}) from identity_store import migrate_legacy_identity_rows migrate_legacy_identity_rows(conn) diff --git a/backend/detect_learning.py b/backend/detect_learning.py index dc36776..13e4635 100644 --- a/backend/detect_learning.py +++ b/backend/detect_learning.py @@ -2,6 +2,7 @@ Not a global word list. Detect still does not auto-activate identities. Ambiguous spellings get a local passage call when a local detect provider exists. +Stored passages are the context a later local GLiNER/detect can use for pre-decision. """ from __future__ import annotations @@ -11,7 +12,14 @@ import uuid from typing import Any from db import get_db, row_to_dict -from identity_store import KINSHIP, ENTITY_TYPES, confirm_identity, is_maskable_label, normalize_label +from identity_store import ( + DETERMINERS, + ENTITY_TYPES, + KINSHIP, + confirm_identity, + is_maskable_label, + normalize_label, +) MODE_SEMANTIC = "semantic" MODE_LEARNING = "learning" @@ -225,6 +233,32 @@ def _prev_word(text: str, index: int) -> str: return (words[-1].lower() if words else "") +def _token_index_at(tokens, start: int) -> int | None: + for index, match in enumerate(tokens): + if match.start() <= start < match.end() or match.start() == start: + return index + return None + + +def attachment_kinship(text: str, start: int, end: int) -> str: + """Kinship in the local NP: left of the mention or right apposition, not the whole sentence.""" + tokens = _word_matches(text) + index = _token_index_at(tokens, start) + if index is None: + return "" + left = index - 1 + while left >= 0 and tokens[left].group(0).lower() in DETERMINERS: + left -= 1 + if left >= 0 and tokens[left].group(0).lower() in KINSHIP: + return tokens[left].group(0).lower() + right = index + 1 + while right < len(tokens) and tokens[right].group(0).lower() in DETERMINERS: + right += 1 + if right < len(tokens) and tokens[right].group(0).lower() in KINSHIP: + return tokens[right].group(0).lower() + return "" + + def cue_for_mention( user_body: str, start: int | None, @@ -235,6 +269,9 @@ def cue_for_mention( span = _resolve_user_span(user_body, start, end, label, rendered) if not span: return _CUE_BARE + attached = attachment_kinship(user_body, span[0], span[1]) + if attached: + return attached return _prev_word(user_body, span[0]) or _CUE_BARE @@ -250,7 +287,7 @@ def _mention_fields( **excerpt_view(user_body, start, end, label, rendered), "user_start": span[0] if span else None, "user_end": span[1] if span else None, - "cue": (_prev_word(user_body, span[0]) or _CUE_BARE) if span else _CUE_BARE, + "cue": cue_for_mention(user_body, start, end, label, rendered), } @@ -273,27 +310,64 @@ def get_cue_decision(profile_id: str, label: str, cue: str) -> str | None: return value if value in {_DECISION_IDENTITY, _DECISION_NOT} else None -def record_cue(profile_id: str, label: str, cue: str, decision: str) -> None: +def record_cue(profile_id: str, label: str, cue: str, decision: str, excerpt: str = "") -> None: key = normalize_label(label) or (label or "").strip() token = (cue or _CUE_BARE).strip().casefold() or _CUE_BARE + snippet = (excerpt or "").strip() if not key or decision not in {_DECISION_IDENTITY, _DECISION_NOT}: return with get_db() as conn: conn.execute( """ INSERT INTO label_sense_cues ( - profile_id, normalized_label, cue, decision, hits, updated + profile_id, normalized_label, cue, decision, hits, excerpt, updated ) - VALUES (?, ?, ?, ?, 1, datetime('now')) + VALUES (?, ?, ?, ?, 1, ?, datetime('now')) ON CONFLICT(profile_id, normalized_label, cue) DO UPDATE SET decision = excluded.decision, hits = label_sense_cues.hits + 1, + excerpt = CASE + WHEN excluded.excerpt <> '' THEN excluded.excerpt + ELSE label_sense_cues.excerpt + END, updated = datetime('now') """, - (profile_id, key, token, decision), + (profile_id, key, token, decision, snippet), ) +def list_cue_examples(profile_id: str, label: str, limit: int = 6) -> list[dict]: + key = normalize_label(label) or (label or "").strip() + if not key: + return [] + with get_db() as conn: + rows = conn.execute( + """ + SELECT cue, decision, excerpt, hits + FROM label_sense_cues + WHERE profile_id = ? AND lower(normalized_label) = lower(?) + ORDER BY hits DESC, updated DESC + """, + (profile_id, key), + ).fetchall() + items = [] + for raw in rows: + row = row_to_dict(raw) or {} + decision = (row.get("decision") or "").strip() + if decision not in {_DECISION_IDENTITY, _DECISION_NOT}: + continue + items.append( + { + "cue": row.get("cue") or _CUE_BARE, + "decision": decision, + "excerpt": (row.get("excerpt") or "").strip(), + } + ) + if len(items) >= limit: + break + return items + + def decision_for_mention( profile_id: str, label: str, @@ -363,13 +437,13 @@ def apply_learned_decisions( def kinship_governed_labels(user_body: str) -> set[str]: - """Labels after Frau/Sohn/… in the current user line. Not a food word list.""" + """Labels attached to 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: + if attachment_kinship(user_body, match.start(), match.end()): found.add(label.casefold()) return found @@ -429,7 +503,7 @@ def supplement_kinship_candidates( 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 + identity_shaped = bool(attachment_kinship(user_body, match.start(), match.end())) extra_candidates.append( { "id": str(uuid.uuid4()), @@ -572,11 +646,11 @@ def apply_review_decisions(profile_id: str, pending: dict, decisions: list[dict] ) if decision == _DECISION_IDENTITY: record_sense(profile_id, label, identity=True) - record_cue(profile_id, label, cue, _DECISION_IDENTITY) + record_cue(profile_id, label, cue, _DECISION_IDENTITY, candidate.get("excerpt") or "") confirm_identity(profile_id, label, entity_type=kind) elif decision == _DECISION_NOT: record_sense(profile_id, label, identity=False) - record_cue(profile_id, label, cue, _DECISION_NOT) + record_cue(profile_id, label, cue, _DECISION_NOT, candidate.get("excerpt") or "") drop_keys.add((candidate.get("start"), candidate.get("end"), label.casefold())) mappings = [] for mapping in pending.get("mappings") or []: @@ -659,14 +733,15 @@ def auto_resolve_ambiguous(profile_id: str, candidates: list[dict], mappings: li remaining.append(item) continue label = item.get("text") or "" - decision = try_local_passage_decision(item.get("excerpt") or "", label) + examples = list_cue_examples(profile_id, label) + decision = try_local_passage_decision(item.get("excerpt") or "", label, examples) if decision == _DECISION_NOT: record_sense(profile_id, label, identity=False) - record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_NOT) + record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_NOT, item.get("excerpt") or "") drop_keys.add((item.get("start"), item.get("end"), label.casefold())) elif decision == _DECISION_IDENTITY: record_sense(profile_id, label, identity=True) - record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_IDENTITY) + record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_IDENTITY, item.get("excerpt") or "") else: remaining.append(item) if not drop_keys: @@ -680,15 +755,26 @@ def auto_resolve_ambiguous(profile_id: str, candidates: list[dict], mappings: li return remaining, kept -def try_local_passage_decision(excerpt: str, label: str) -> str | None: +def try_local_passage_decision(excerpt: str, label: str, examples: list[dict] | None = None) -> str | None: """Return identity, not_identity, or None if no local model or unusable answer.""" from providers import ProviderError, complete_chat, detect_provider config = detect_provider() if not config or not config.local or config.mode != "http": return None + few_shot = "" + lines = [] + for example in examples or []: + snippet = (example.get("excerpt") or "").strip() + decision = (example.get("decision") or "").strip() + if snippet and decision in {_DECISION_IDENTITY, _DECISION_NOT}: + lines.append(f'- "{snippet}" → {decision}') + if lines: + few_shot = "Bisherige lokale Bestätigungen als Beispiele, nicht als Wortliste:\n" + "\n".join(lines) + "\n" prompt = ( + f"{few_shot}" "Entscheide nur für die markierte Nennung in diesem kurzen Ausschnitt. " + "Nutze den Satzkontext, nicht nur das Wort davor. " "Ist sie eine schützenswerte Identität (Person, Ort, Organisation, privates Projekt) " f"oder eine Sache/Allgemeinbedeutung? Wort: {label}\n" f"Ausschnitt: {excerpt}\n" diff --git a/backend/migrations/026_label_sense_cue_excerpt.sql b/backend/migrations/026_label_sense_cue_excerpt.sql new file mode 100644 index 0000000..49eac73 --- /dev/null +++ b/backend/migrations/026_label_sense_cue_excerpt.sql @@ -0,0 +1,2 @@ +-- Mini-passages beside each cue so local detect/GLiNER can pre-decide from context. +ALTER TABLE label_sense_cues ADD COLUMN IF NOT EXISTS excerpt TEXT NOT NULL DEFAULT ''; diff --git a/backend/schema.sql b/backend/schema.sql index a873de5..6e63346 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS label_sense_cues ( cue TEXT NOT NULL, decision TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 1, + excerpt TEXT NOT NULL DEFAULT '', updated TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (profile_id, normalized_label, cue) ); diff --git a/backend/tests/test_detect_learning.py b/backend/tests/test_detect_learning.py index 9037c3e..0650d59 100644 --- a/backend/tests/test_detect_learning.py +++ b/backend/tests/test_detect_learning.py @@ -17,6 +17,7 @@ os.environ["KANSHO_FAKE_DETECT"] = "1" from fastapi.testclient import TestClient from detect_learning import ( + cue_for_mention, excerpt_view, get_cue_decision, get_detect_operating_mode, @@ -270,6 +271,14 @@ def main() -> None: prefix + homonym, ) expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi") + expect(cue_for_mention(homonym, first_at, first_at + 5, "Sushi") == "frau", "person Sushi attaches to Frau") + expect(cue_for_mention(homonym, second_at, second_at + 5, "Sushi") == "rohan", "dish Sushi stays distinct from Frau") + appos = "Sushi, meine Frau war erkältet." + appos_at = appos.index("Sushi") + expect(cue_for_mention(appos, appos_at, appos_at + 5, "Sushi") == "frau", "apposition Sushi, meine Frau binds Frau") + far = "Meine Frau und ich aßen Sushi." + far_at = far.rindex("Sushi") + expect(cue_for_mention(far, far_at, far_at + 5, "Sushi") != "frau", "Frau elsewhere in the sentence does not bind the dish") sushi_review = sushi.json().get("pending_mask_review") or {} sushi_decisions = [] diff --git a/docs/architecture/functional/guardrails.md b/docs/architecture/functional/guardrails.md index 9602abb..9a743c4 100644 --- a/docs/architecture/functional/guardrails.md +++ b/docs/architecture/functional/guardrails.md @@ -719,7 +719,7 @@ Additiv. Technische Abbildung: `../technical/privacy_gateway.md` §9.7. **Entschieden (Übergang):** Eine Mini-Passage an ein internes Modell geht nur bei bereits mehrdeutiger Schreibweise und nur an ein lokales Detect. OpenRouter sieht diese Passage nicht. Fehlt das lokale Modell, bleibt das Popup. -**Additiv 2026-09-10:** Eine bestätigte Nennung wird nicht erneut gefragt, wenn derselbe lokale Kontext wiederkehrt (vorheriges Wort, z. B. `Frau` gegenüber einer Speisenennung). Das ist kein Wortlisten-Editor. Unbekannte Kontexte derselben Schreibweise bleiben prüfpflichtig. Reines Zählen von Identität/nicht-schützenswert ohne diese Anwendung ist kein Lernen. +**Additiv 2026-09-10:** Eine bestätigte Nennung wird nicht erneut gefragt, wenn derselbe lokale Kontext wiederkehrt. Enge Verwandtschaftsbindung in der Nominalphrase (auch Apposition: `Sushi, meine Frau`) ist ein Hinweis, nicht die ganze Wahrheit. Der gespeicherte Ausschnitt ist der Kontext für eine lokale Vorentscheidung. Ein späteres lokales GLiNER/Detect sieht diese Mini-Passage plus bestätigte Beispiele; OpenRouter sieht sie nicht. Unbekannte Kontexte bleiben prüfpflichtig. Das ist kein Wortlisten-Editor. **Nicht:** Gateway abschalten, Detect-Treffer auto-speichern, `Sushi_`/`Sushi+` im Nutzertext, Pattern-Wortliste als Wahrheit. diff --git a/docs/architecture/technical/privacy_gateway.md b/docs/architecture/technical/privacy_gateway.md index 9227749..d905d0e 100644 --- a/docs/architecture/technical/privacy_gateway.md +++ b/docs/architecture/technical/privacy_gateway.md @@ -265,7 +265,7 @@ Im Lernmodus untersucht Detect weiterhin den vollen gerenderten Generate-Egress. 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. -**Additiv 2026-09-10:** Bestätigungen speichern zusätzlich den lokalen Cue (Wort vor der Nennung, sonst `_bare`) in `label_sense_cues`. Derselbe Cue wird angewendet statt erneut gefragt. Verwandtschafts-Nachträge (`Frau`/`Sohn`/…) respektieren dieselbe Regel. Bekannte Nur-Identität und bekannte Nur-Allgemeinbedeutung werden nicht erneut gefragt; Verwandtschaft nach einer Nur-Allgemeinbedeutung bleibt einmal prüfpflichtig, damit ein Personen-Sinn entdeckt werden kann. +**Additiv 2026-09-10:** Bestätigungen speichern den lokalen Cue und den Ausschnitt in `label_sense_cues`. Cue ist die enge Bindung in der Phrase (`Frau Sushi`, `Sushi, meine Frau`), sonst das Wort unmittelbar davor — nicht jedes Verwandtschaftswort irgendwo im Satz, damit Homonyme (`Frau Sushi` / Speise) getrennt bleiben. Der Ausschnitt geht nur an ein **lokales** Detect/GLiNER als Mini-Passage plus bestätigte Beispiele; das ist die Vorentscheidung. GLiNER ist dafür der bevorzugte lokale Weg, in diesem Slice noch nicht verdrahtet. Fehlt das lokale Modell, bleibt das Popup. OpenRouter erhält diese Passage nicht. Nach der Bestätigung läuft Generate mit den geprüften Mappings (`precomputed_learning_review`), ohne zweiten Detect-Pass. Compact-Diagnose enthält weiterhin keine Klartextlabels. Tests: `backend/tests/test_detect_learning.py`.