Apply learned mask reviews by local cue and highlight the reviewed mention.
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Test Suite / pytest-backend (push) Successful in 2m59s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-10 20:08:59 +02:00
parent 3f7c0d71a5
commit f7ffd28332
10 changed files with 413 additions and 36 deletions

View File

@ -22,6 +22,8 @@ WINDOW = 80
_DECISION_IDENTITY = "identity"
_DECISION_NOT = "not_identity"
_DECISION_ASK = "ask"
_CUE_BARE = "_bare"
def get_detect_operating_mode() -> str:
@ -136,31 +138,82 @@ def _in_user_text(label: str, user_body: str) -> bool:
)
def _excerpt(user_body: str, start: int | None, end: int | None, label: str) -> str:
def _resolve_user_span(
user_body: str,
start: int | None,
end: int | None,
label: str,
rendered: str = "",
) -> tuple[int, int] | None:
text = user_body or ""
if start is None or end is None or start < 0 or end > len(text):
match = re.search(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", text, re.IGNORECASE)
if not match:
return text[:WINDOW]
start, end = match.start(), match.end()
left = max(0, int(start) - WINDOW)
right = min(len(text), int(end) + WINDOW)
return text[left:right]
needle = (label or "").casefold()
if start is not None and end is not None:
left, right = int(start), int(end)
if 0 <= left < right <= len(text) and text[left:right].casefold() == needle:
return left, right
if rendered and text:
origin = rendered.rfind(text)
if origin >= 0:
user_left, user_right = left - origin, right - origin
if 0 <= user_left < user_right <= len(text) and text[user_left:user_right].casefold() == needle:
return user_left, user_right
if not needle:
return None
match = re.search(rf"(?<![{_LETTER}]){re.escape(label)}(?![{_LETTER}])", text, re.IGNORECASE)
if not match:
return None
return match.start(), match.end()
def needs_review(profile_id: str, mapping: dict, user_body: str) -> bool:
def excerpt_view(
user_body: str,
start: int | None,
end: int | None,
label: str,
rendered: str = "",
) -> dict:
text = user_body or ""
span = _resolve_user_span(text, start, end, label, rendered)
if not span:
snippet = text[:WINDOW]
return {"excerpt": snippet, "highlight_start": None, "highlight_end": None}
left_i, right_i = span
clip_left = max(0, left_i - WINDOW)
clip_right = min(len(text), right_i + WINDOW)
prefix = "" if clip_left > 0 else ""
suffix = "" if clip_right < len(text) else ""
snippet = text[clip_left:clip_right]
return {
"excerpt": f"{prefix}{snippet}{suffix}",
"highlight_start": left_i - clip_left + len(prefix),
"highlight_end": right_i - clip_left + len(prefix),
}
def _excerpt(user_body: str, start: int | None, end: int | None, label: str) -> str:
return excerpt_view(user_body, start, end, label)["excerpt"]
def needs_review(profile_id: str, mapping: dict, user_body: str, rendered: str = "") -> bool:
label = mapping.get("local_label") or ""
if mapping.get("source") == "confirmed_registry":
sense = get_sense(profile_id, label)
return bool(sense["ambiguous"]) and _in_user_text(label, user_body)
return (
_in_user_text(label, user_body)
and decision_for_mention(
profile_id, label, user_body, mapping.get("start"), mapping.get("end"), rendered
)
== _DECISION_ASK
)
if mapping.get("source") != "request_local":
return False
if not is_maskable_label(label) or not _in_user_text(label, user_body):
return False
sense = get_sense(profile_id, label)
if sense["identity_hits"] > 0 and not sense["ambiguous"]:
return False
return True
return (
decision_for_mention(
profile_id, label, user_body, mapping.get("start"), mapping.get("end"), rendered
)
== _DECISION_ASK
)
def _word_matches(text: str):
@ -172,6 +225,143 @@ def _prev_word(text: str, index: int) -> str:
return (words[-1].lower() if words else "")
def cue_for_mention(
user_body: str,
start: int | None,
end: int | None,
label: str,
rendered: str = "",
) -> str:
span = _resolve_user_span(user_body, start, end, label, rendered)
if not span:
return _CUE_BARE
return _prev_word(user_body, span[0]) or _CUE_BARE
def _mention_fields(
user_body: str,
start: int | None,
end: int | None,
label: str,
rendered: str = "",
) -> dict:
span = _resolve_user_span(user_body, start, end, label, rendered)
return {
**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,
}
def get_cue_decision(profile_id: str, label: str, cue: str) -> str | None:
key = normalize_label(label) or (label or "").strip()
token = (cue or _CUE_BARE).strip().casefold() or _CUE_BARE
if not key:
return None
with get_db() as conn:
row = row_to_dict(
conn.execute(
"""
SELECT decision FROM label_sense_cues
WHERE profile_id = ? AND lower(normalized_label) = lower(?) AND cue = ?
""",
(profile_id, key, token),
).fetchone()
)
value = ((row or {}).get("decision") or "").strip()
return value if value in {_DECISION_IDENTITY, _DECISION_NOT} else None
def record_cue(profile_id: str, label: str, cue: str, decision: str) -> None:
key = normalize_label(label) or (label or "").strip()
token = (cue or _CUE_BARE).strip().casefold() or _CUE_BARE
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
)
VALUES (?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(profile_id, normalized_label, cue) DO UPDATE SET
decision = excluded.decision,
hits = label_sense_cues.hits + 1,
updated = datetime('now')
""",
(profile_id, key, token, decision),
)
def decision_for_mention(
profile_id: str,
label: str,
user_body: str,
start: int | None,
end: int | None,
rendered: str = "",
) -> str:
"""identity / not_identity from prior review, or ask once for a new cue."""
if not label:
return _DECISION_ASK
cue = cue_for_mention(user_body, start, end, label, rendered)
learned = get_cue_decision(profile_id, label, cue)
if learned:
return learned
sense = get_sense(profile_id, label)
identity_shaped = cue in KINSHIP
if sense["ambiguous"]:
return _DECISION_ASK
if sense["identity_hits"] > 0 and not sense["non_identity_hits"]:
return _DECISION_IDENTITY
if sense["non_identity_hits"] > 0 and not sense["identity_hits"]:
return _DECISION_ASK if identity_shaped else _DECISION_NOT
return _DECISION_ASK
def apply_learned_decisions(
profile_id: str,
candidates: list[dict],
mappings: list[dict],
user_body: str,
rendered: str = "",
) -> tuple[list[dict], list[dict]]:
remaining = []
for item in candidates:
verdict = decision_for_mention(
profile_id,
item.get("text") or "",
user_body,
item.get("user_start", item.get("start")),
item.get("user_end", item.get("end")),
rendered,
)
if verdict == _DECISION_ASK:
remaining.append(item)
kept = []
for mapping in mappings:
label = mapping.get("local_label") or ""
verdict = decision_for_mention(
profile_id,
label,
user_body,
mapping.get("start"),
mapping.get("end"),
rendered,
)
if verdict == _DECISION_NOT:
continue
kept.append(mapping)
if verdict == _DECISION_IDENTITY:
kind = mapping.get("entity_type") if mapping.get("entity_type") in ENTITY_TYPES else "PERSON"
try:
confirm_identity(profile_id, label, entity_type=kind)
except ValueError:
continue
return remaining, kept
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()
@ -214,15 +404,15 @@ def supplement_kinship_candidates(
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
span = _resolve_user_span(
user_body,
item.get("start"),
item.get("end"),
item.get("text") or "",
rendered,
)
if span:
occupied.add((span[0], span[1], (item.get("text") or "").casefold()))
extra_mappings = list(mappings)
extra_candidates = list(candidates)
for match in _word_matches(user_body):
@ -232,6 +422,11 @@ def supplement_kinship_candidates(
user_key = (match.start(), match.end(), label.casefold())
if user_key in occupied:
continue
if (
decision_for_mention(profile_id, label, user_body, match.start(), match.end(), rendered)
!= _DECISION_ASK
):
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
@ -242,7 +437,7 @@ def supplement_kinship_candidates(
"entity_type": "PERSON",
"start": start,
"end": end,
"excerpt": _excerpt(user_body, match.start(), match.end(), label),
**_mention_fields(user_body, match.start(), match.end(), label, rendered),
"ambiguous": get_sense(profile_id, label)["ambiguous"],
"suggested": _DECISION_IDENTITY if identity_shaped else _DECISION_NOT,
}
@ -264,11 +459,11 @@ def supplement_kinship_candidates(
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, rendered: str = "") -> list[dict]:
seen: set[tuple[int | None, int | None, str]] = set()
items: list[dict] = []
for mapping in mappings:
if not needs_review(profile_id, mapping, user_body):
if not needs_review(profile_id, mapping, user_body, rendered):
continue
label = mapping.get("local_label") or ""
start = mapping.get("start")
@ -284,7 +479,7 @@ def build_candidates(profile_id: str, mappings: list[dict], user_body: str) -> l
"entity_type": mapping.get("entity_type") or "PERSON",
"start": start,
"end": end,
"excerpt": _excerpt(user_body, start, end, label),
**_mention_fields(user_body, start, end, label, rendered),
"ambiguous": get_sense(profile_id, label)["ambiguous"],
}
)
@ -369,11 +564,19 @@ def apply_review_decisions(profile_id: str, pending: dict, decisions: list[dict]
decision = (raw.get("decision") or "").strip()
label = candidate.get("text") or ""
kind = candidate.get("entity_type") if candidate.get("entity_type") in ENTITY_TYPES else "PERSON"
cue = candidate.get("cue") or cue_for_mention(
pending.get("user_body") or "",
candidate.get("user_start", candidate.get("start")),
candidate.get("user_end", candidate.get("end")),
label,
)
if decision == _DECISION_IDENTITY:
record_sense(profile_id, label, identity=True)
record_cue(profile_id, label, cue, _DECISION_IDENTITY)
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)
drop_keys.add((candidate.get("start"), candidate.get("end"), label.casefold()))
mappings = []
for mapping in pending.get("mappings") or []:
@ -459,9 +662,11 @@ def auto_resolve_ambiguous(profile_id: str, candidates: list[dict], mappings: li
decision = try_local_passage_decision(item.get("excerpt") or "", label)
if decision == _DECISION_NOT:
record_sense(profile_id, label, identity=False)
record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_NOT)
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)
else:
remaining.append(item)
if not drop_keys:

View File

@ -9,6 +9,7 @@ from conversation_signals import infer_signals
from debug_store import persist_engine_error, persist_step
from detect_learning import (
MODE_LEARNING,
apply_learned_decisions,
apply_review_decisions,
auto_resolve_ambiguous,
build_candidates,
@ -439,10 +440,13 @@ def _learning_pause(profile_id: str, conversation_id: str, user: dict, assembled
return None
user_body = user.get("body") or ""
mappings = list(outcome.mappings or [])
candidates = build_candidates(profile_id, mappings, user_body)
candidates = build_candidates(profile_id, mappings, user_body, rendered)
candidates, mappings = supplement_kinship_candidates(
profile_id, candidates, mappings, user_body, rendered
)
candidates, mappings = apply_learned_decisions(
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)

View File

@ -0,0 +1,12 @@
-- Remember mask-review decisions per local context cue (previous word).
-- Not a word list: cues come from the user line, e.g. Frau vs. a food mention.
CREATE TABLE IF NOT EXISTS label_sense_cues (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
cue TEXT NOT NULL,
decision TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 1,
updated TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP::text,
PRIMARY KEY (profile_id, normalized_label, cue)
);

View File

@ -239,6 +239,16 @@ CREATE TABLE IF NOT EXISTS label_senses (
PRIMARY KEY (profile_id, normalized_label)
);
CREATE TABLE IF NOT EXISTS label_sense_cues (
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
normalized_label TEXT NOT NULL,
cue TEXT NOT NULL,
decision TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 1,
updated TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (profile_id, normalized_label, cue)
);
CREATE TABLE IF NOT EXISTS pending_mask_reviews (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,

View File

@ -44,6 +44,7 @@ TABLES = [
"identity_mappings",
"identity_review_proposals",
"label_senses",
"label_sense_cues",
"pending_mask_reviews",
"journal_days",
"journal_drafts",

View File

@ -16,7 +16,13 @@ os.environ["KANSHO_FAKE_PROVIDER"] = "1"
os.environ["KANSHO_FAKE_DETECT"] = "1"
from fastapi.testclient import TestClient
from detect_learning import get_detect_operating_mode, get_sense, record_sense
from detect_learning import (
excerpt_view,
get_cue_decision,
get_detect_operating_mode,
get_sense,
record_sense,
)
from dialogue_turn import visible_for_role
from identity_store import list_confirmed_identities
from main import app
@ -161,6 +167,13 @@ def main() -> None:
status_ok(declined, "not_identity review")
expect(not any((item.get("canonical_label") or "").casefold() == "hanna" for item in list_confirmed_identities(profile_id)), "not_identity does not confirm Hanna")
expect(get_sense(profile_id, "Hanna")["non_identity_hits"] >= 1, "not_identity records a sense")
hanna_again = client.post(
f"/api/journal/conversations/{food_conv}/turn",
headers=headers,
json={"body": "Hanna aß mit uns."},
)
status_ok(hanna_again, "second Hanna turn")
expect(not hanna_again.json().get("pending_mask_review"), "known non-identity skips the popup")
record_sense(profile_id, "Clarissa", identity=True)
record_sense(profile_id, "Clarissa", identity=False)
@ -180,12 +193,30 @@ def main() -> None:
kin = client.post(
f"/api/journal/conversations/{kin_conv}/turn",
headers=headers,
json={"body": "Ich war mit meinem Sohn Rohan im Park."},
json={"body": "Ich war mit meinem Sohn Leon im Park."},
)
status_ok(kin, "kinship Rohan pause")
status_ok(kin, "kinship Leon 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")
expect("Leon" in kin_names, "Sohn Leon is offered even when Detect does not report it")
leon = next((item for item in kin_review.get("candidates") or [] if item.get("text") == "Leon"), None)
expect(leon is not None, "Leon candidate id is present")
kin_done = client.post(
f"/api/journal/conversations/{kin_conv}/turn/review",
headers=headers,
json={
"review_id": kin_review["id"],
"decisions": [{"id": leon["id"], "decision": "identity"}],
},
)
status_ok(kin_done, "Leon identity review")
kin_again = client.post(
f"/api/journal/conversations/{kin_conv}/turn",
headers=headers,
json={"body": "Ich war mit meinem Sohn Leon im Park."},
)
status_ok(kin_again, "second Leon turn")
expect(not kin_again.json().get("pending_mask_review"), "confirmed Sohn Leon skips the popup")
sushi_conv = open_conv(client, headers)
sushi = client.post(
@ -203,6 +234,73 @@ def main() -> None:
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")
sushi_hits = [
item
for item in (sushi.json().get("pending_mask_review") or {}).get("candidates") or []
if item.get("text") == "Sushi"
]
expect(len(sushi_hits) >= 2, "both Sushi mentions are offered")
expect(
sushi_hits[0].get("highlight_start") != sushi_hits[1].get("highlight_start"),
"the two Sushi mentions highlight different offsets",
)
for item in sushi_hits:
excerpt = item.get("excerpt") or ""
start = item.get("highlight_start")
end = item.get("highlight_end")
expect(excerpt[start:end] == "Sushi", "highlight covers the Sushi token")
homonym = (
"Gestern bin ich mit meiner Frau Sushi und meinem Sohn Rohan "
"Sushi essen gegangen."
)
first_at = homonym.index("Sushi")
second_at = homonym.rindex("Sushi")
first = excerpt_view(homonym, first_at, first_at + 5, "Sushi")
second = excerpt_view(homonym, second_at, second_at + 5, "Sushi")
expect(first["highlight_start"] < second["highlight_start"], "person Sushi sits left of dish Sushi")
expect(first["excerpt"][first["highlight_start"]:first["highlight_end"]] == "Sushi", "first highlight is Sushi")
expect(second["excerpt"][second["highlight_start"]:second["highlight_end"]] == "Sushi", "second highlight is Sushi")
prefix = "SYSTEM\n"
mapped = excerpt_view(
homonym,
len(prefix) + second_at,
len(prefix) + second_at + 5,
"Sushi",
prefix + homonym,
)
expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi")
sushi_review = sushi.json().get("pending_mask_review") or {}
sushi_decisions = []
for item in sushi_review.get("candidates") or []:
label = item.get("text")
cue = item.get("cue")
if label == "Sushi" and cue != "frau":
sushi_decisions.append({"id": item["id"], "decision": "not_identity"})
else:
sushi_decisions.append({"id": item["id"], "decision": "identity"})
sushi_done = client.post(
f"/api/journal/conversations/{sushi_conv}/turn/review",
headers=headers,
json={"review_id": sushi_review["id"], "decisions": sushi_decisions},
)
status_ok(sushi_done, "homonym review")
expect(get_cue_decision(profile_id, "Sushi", "frau") == "identity", "Frau Sushi is stored as identity")
expect(get_cue_decision(profile_id, "Sushi", "rohan") == "not_identity", "dish Sushi after Rohan is stored")
expect(get_cue_decision(profile_id, "Rohan", "sohn") == "identity", "Sohn Rohan cue is stored")
sushi_again = 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_again, "repeat homonym sentence")
expect(not sushi_again.json().get("pending_mask_review"), "same reviewed sentence does not re-ask")
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")

View File

@ -719,6 +719,8 @@ 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.
**Nicht:** Gateway abschalten, Detect-Treffer auto-speichern, `Sushi_`/`Sushi+` im Nutzertext, Pattern-Wortliste als Wahrheit.
---

View File

@ -265,6 +265,8 @@ 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.
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`.
## 10. Offene Fragen

View File

@ -695,3 +695,16 @@ pre.code {
flex-wrap: wrap;
gap: 0.8rem;
}
.mask-excerpt {
margin: 0.35rem 0 0;
color: var(--ink);
font-size: 0.95rem;
line-height: 1.45;
}
.mask-excerpt mark {
background: #f3e2b8;
color: inherit;
font-weight: 600;
padding: 0 0.12em;
border-radius: 3px;
}

View File

@ -1,3 +1,28 @@
function HighlightedExcerpt({ excerpt, highlightStart, highlightEnd, label }) {
if (!excerpt) return null
const start = Number(highlightStart)
const end = Number(highlightEnd)
if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end <= excerpt.length && end > start) {
return (
<p className="mask-excerpt">
{excerpt.slice(0, start)}
<mark>{excerpt.slice(start, end)}</mark>
{excerpt.slice(end)}
</p>
)
}
const needle = label || ''
const at = needle ? excerpt.toLowerCase().indexOf(needle.toLowerCase()) : -1
if (at < 0) return <p className="mask-excerpt">{excerpt}</p>
return (
<p className="mask-excerpt">
{excerpt.slice(0, at)}
<mark>{excerpt.slice(at, at + needle.length)}</mark>
{excerpt.slice(at + needle.length)}
</p>
)
}
export default function MaskReviewPanel({ review, busy, onSubmit }) {
if (!review?.candidates?.length) return null
return (
@ -7,7 +32,7 @@ export default function MaskReviewPanel({ review, busy, onSubmit }) {
<div>
<h2 id="mask-review-title">Maskierung prüfen</h2>
<p className="runlog-status">
Lernmodus: nur diese Nennung. Bestätigen legt die Bezeichnung lokal ab.
Lernmodus: nur die gelb markierte Nennung. Bestätigen legt die Bezeichnung lokal ab.
Nicht schützenswert verhindert die Maskierung. Beides bei demselben Wort markiert es als mehrdeutig.
</p>
</div>
@ -30,7 +55,12 @@ export default function MaskReviewPanel({ review, busy, onSubmit }) {
<div>
<strong>{item.text}</strong>
<span className="muted"> · {item.entity_type}{item.ambiguous ? ' · mehrdeutig' : ''}</span>
{item.excerpt && <p className="muted">{item.excerpt}</p>}
<HighlightedExcerpt
excerpt={item.excerpt}
highlightStart={item.highlight_start}
highlightEnd={item.highlight_end}
label={item.text}
/>
</div>
<fieldset className="row-actions">
<label className="check">