Store mask-review excerpts and bind kinship from local phrase context.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 2m53s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 15s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-10 21:34:27 +02:00
parent f7ffd28332
commit a65ea98b0c
7 changed files with 116 additions and 17 deletions

View File

@ -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)

View File

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

View File

@ -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 '';

View File

@ -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)
);

View File

@ -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 = []

View File

@ -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.

View File

@ -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`.