Offer kinship names in learning review when Detect skips them.
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:
parent
fa1c11c33e
commit
cc43fc6409
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ export default function MaskReviewPanel({ review, busy, onSubmit }) {
|
|||
</div>
|
||||
<fieldset className="row-actions">
|
||||
<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
|
||||
</label>
|
||||
<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
|
||||
</label>
|
||||
</fieldset>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user