Offer kinship names in learning review when Detect skips them.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / smoke-dev (push) Waiting to run
Test Suite / frontend-build (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled

Frau/Sohn mentions in the user line must still reach the popup if OpenRouter only tags the food reading or misses the person.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-09 08:47:44 +02:00
parent fa1c11c33e
commit cc43fc6409
5 changed files with 137 additions and 4 deletions

View File

@ -11,7 +11,7 @@ import uuid
from typing import Any from typing import Any
from db import get_db, row_to_dict 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_SEMANTIC = "semantic"
MODE_LEARNING = "learning" MODE_LEARNING = "learning"
@ -163,6 +163,107 @@ def needs_review(profile_id: str, mapping: dict, user_body: str) -> bool:
return True 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]: def build_candidates(profile_id: str, mappings: list[dict], user_body: str) -> list[dict]:
seen: set[tuple[int | None, int | None, str]] = set() seen: set[tuple[int | None, int | None, str]] = set()
items: list[dict] = [] items: list[dict] = []

View File

@ -17,6 +17,7 @@ from detect_learning import (
get_detect_operating_mode, get_detect_operating_mode,
load_pending, load_pending,
save_pending, save_pending,
supplement_kinship_candidates,
suppress_known_non_identity, suppress_known_non_identity,
) )
from dialogue_store import append_message, get_conversation, list_messages, update_conversation_signals 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 "" user_body = user.get("body") or ""
mappings = list(outcome.mappings or []) mappings = list(outcome.mappings or [])
candidates = build_candidates(profile_id, mappings, user_body) 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) candidates, mappings = auto_resolve_ambiguous(profile_id, candidates, mappings)
if not candidates: if not candidates:
confirm_known_identity_spans(profile_id, mappings, user_body) confirm_known_identity_spans(profile_id, mappings, user_body)

View File

@ -176,6 +176,34 @@ def main() -> None:
expect(not amb.json().get("pending_mask_review"), "local passage can finish without a popup") 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") 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"}) 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") expect(back.json()["detect_operating_mode"] == "semantic", "mode can return to semantic")

View File

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

View File

@ -34,11 +34,11 @@ export default function MaskReviewPanel({ review, busy, onSubmit }) {
</div> </div>
<fieldset className="row-actions"> <fieldset className="row-actions">
<label className="check"> <label className="check">
<input type="radio" name={`decision-${item.id}`} value="identity" defaultChecked /> <input type="radio" name={`decision-${item.id}`} value="identity" defaultChecked={item.suggested !== 'not_identity'} />
Identität Identität
</label> </label>
<label className="check"> <label className="check">
<input type="radio" name={`decision-${item.id}`} value="not_identity" /> <input type="radio" name={`decision-${item.id}`} value="not_identity" defaultChecked={item.suggested === 'not_identity'} />
Nicht schützenswert Nicht schützenswert
</label> </label>
</fieldset> </fieldset>