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 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] = []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user