diff --git a/backend/detect_learning.py b/backend/detect_learning.py index 20f392d..dc36776 100644 --- a/backend/detect_learning.py +++ b/backend/detect_learning.py @@ -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"(?= 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"(? 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: diff --git a/backend/dialogue_turn.py b/backend/dialogue_turn.py index 4a7cfaf..9f3a2d4 100644 --- a/backend/dialogue_turn.py +++ b/backend/dialogue_turn.py @@ -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) diff --git a/backend/migrations/025_label_sense_cues.sql b/backend/migrations/025_label_sense_cues.sql new file mode 100644 index 0000000..d65d258 --- /dev/null +++ b/backend/migrations/025_label_sense_cues.sql @@ -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) +); diff --git a/backend/schema.sql b/backend/schema.sql index c330e15..a873de5 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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, diff --git a/backend/sqlite_to_postgres.py b/backend/sqlite_to_postgres.py index d8128a2..acf9349 100644 --- a/backend/sqlite_to_postgres.py +++ b/backend/sqlite_to_postgres.py @@ -44,6 +44,7 @@ TABLES = [ "identity_mappings", "identity_review_proposals", "label_senses", + "label_sense_cues", "pending_mask_reviews", "journal_days", "journal_drafts", diff --git a/backend/tests/test_detect_learning.py b/backend/tests/test_detect_learning.py index 2775427..9037c3e 100644 --- a/backend/tests/test_detect_learning.py +++ b/backend/tests/test_detect_learning.py @@ -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") diff --git a/docs/architecture/functional/guardrails.md b/docs/architecture/functional/guardrails.md index 5d3380a..9602abb 100644 --- a/docs/architecture/functional/guardrails.md +++ b/docs/architecture/functional/guardrails.md @@ -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. --- diff --git a/docs/architecture/technical/privacy_gateway.md b/docs/architecture/technical/privacy_gateway.md index eca0d50..9227749 100644 --- a/docs/architecture/technical/privacy_gateway.md +++ b/docs/architecture/technical/privacy_gateway.md @@ -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 diff --git a/frontend/src/app.css b/frontend/src/app.css index db704f7..de57745 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; +} diff --git a/frontend/src/components/MaskReviewPanel.jsx b/frontend/src/components/MaskReviewPanel.jsx index cbd5219..16ade1d 100644 --- a/frontend/src/components/MaskReviewPanel.jsx +++ b/frontend/src/components/MaskReviewPanel.jsx @@ -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 ( +
+ {excerpt.slice(0, start)} + {excerpt.slice(start, end)} + {excerpt.slice(end)} +
+ ) + } + const needle = label || '' + const at = needle ? excerpt.toLowerCase().indexOf(needle.toLowerCase()) : -1 + if (at < 0) return{excerpt}
+ return ( ++ {excerpt.slice(0, at)} + {excerpt.slice(at, at + needle.length)} + {excerpt.slice(at + needle.length)} +
+ ) +} + 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 }) {- 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.
{item.excerpt}
} +