Store mask-review excerpts and bind kinship from local phrase context.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f7ffd28332
commit
a65ea98b0c
|
|
@ -594,6 +594,7 @@ def init_db() -> None:
|
||||||
migrate_journal_source_refs(conn)
|
migrate_journal_source_refs(conn)
|
||||||
_migrate_writing_profile_shell(conn)
|
_migrate_writing_profile_shell(conn)
|
||||||
_ensure_columns(conn, "identity_mappings", _IDENTITY_MAPPING_COLUMNS)
|
_ensure_columns(conn, "identity_mappings", _IDENTITY_MAPPING_COLUMNS)
|
||||||
|
_ensure_columns(conn, "label_sense_cues", {"excerpt": "TEXT NOT NULL DEFAULT ''"})
|
||||||
from identity_store import migrate_legacy_identity_rows
|
from identity_store import migrate_legacy_identity_rows
|
||||||
|
|
||||||
migrate_legacy_identity_rows(conn)
|
migrate_legacy_identity_rows(conn)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
Not a global word list. Detect still does not auto-activate identities.
|
Not a global word list. Detect still does not auto-activate identities.
|
||||||
Ambiguous spellings get a local passage call when a local detect provider exists.
|
Ambiguous spellings get a local passage call when a local detect provider exists.
|
||||||
|
Stored passages are the context a later local GLiNER/detect can use for pre-decision.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -11,7 +12,14 @@ 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 KINSHIP, ENTITY_TYPES, confirm_identity, is_maskable_label, normalize_label
|
from identity_store import (
|
||||||
|
DETERMINERS,
|
||||||
|
ENTITY_TYPES,
|
||||||
|
KINSHIP,
|
||||||
|
confirm_identity,
|
||||||
|
is_maskable_label,
|
||||||
|
normalize_label,
|
||||||
|
)
|
||||||
|
|
||||||
MODE_SEMANTIC = "semantic"
|
MODE_SEMANTIC = "semantic"
|
||||||
MODE_LEARNING = "learning"
|
MODE_LEARNING = "learning"
|
||||||
|
|
@ -225,6 +233,32 @@ def _prev_word(text: str, index: int) -> str:
|
||||||
return (words[-1].lower() if words else "")
|
return (words[-1].lower() if words else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _token_index_at(tokens, start: int) -> int | None:
|
||||||
|
for index, match in enumerate(tokens):
|
||||||
|
if match.start() <= start < match.end() or match.start() == start:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_kinship(text: str, start: int, end: int) -> str:
|
||||||
|
"""Kinship in the local NP: left of the mention or right apposition, not the whole sentence."""
|
||||||
|
tokens = _word_matches(text)
|
||||||
|
index = _token_index_at(tokens, start)
|
||||||
|
if index is None:
|
||||||
|
return ""
|
||||||
|
left = index - 1
|
||||||
|
while left >= 0 and tokens[left].group(0).lower() in DETERMINERS:
|
||||||
|
left -= 1
|
||||||
|
if left >= 0 and tokens[left].group(0).lower() in KINSHIP:
|
||||||
|
return tokens[left].group(0).lower()
|
||||||
|
right = index + 1
|
||||||
|
while right < len(tokens) and tokens[right].group(0).lower() in DETERMINERS:
|
||||||
|
right += 1
|
||||||
|
if right < len(tokens) and tokens[right].group(0).lower() in KINSHIP:
|
||||||
|
return tokens[right].group(0).lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def cue_for_mention(
|
def cue_for_mention(
|
||||||
user_body: str,
|
user_body: str,
|
||||||
start: int | None,
|
start: int | None,
|
||||||
|
|
@ -235,6 +269,9 @@ def cue_for_mention(
|
||||||
span = _resolve_user_span(user_body, start, end, label, rendered)
|
span = _resolve_user_span(user_body, start, end, label, rendered)
|
||||||
if not span:
|
if not span:
|
||||||
return _CUE_BARE
|
return _CUE_BARE
|
||||||
|
attached = attachment_kinship(user_body, span[0], span[1])
|
||||||
|
if attached:
|
||||||
|
return attached
|
||||||
return _prev_word(user_body, span[0]) or _CUE_BARE
|
return _prev_word(user_body, span[0]) or _CUE_BARE
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -250,7 +287,7 @@ def _mention_fields(
|
||||||
**excerpt_view(user_body, start, end, label, rendered),
|
**excerpt_view(user_body, start, end, label, rendered),
|
||||||
"user_start": span[0] if span else None,
|
"user_start": span[0] if span else None,
|
||||||
"user_end": span[1] 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,
|
"cue": cue_for_mention(user_body, start, end, label, rendered),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -273,27 +310,64 @@ def get_cue_decision(profile_id: str, label: str, cue: str) -> str | None:
|
||||||
return value if value in {_DECISION_IDENTITY, _DECISION_NOT} else None
|
return value if value in {_DECISION_IDENTITY, _DECISION_NOT} else None
|
||||||
|
|
||||||
|
|
||||||
def record_cue(profile_id: str, label: str, cue: str, decision: str) -> None:
|
def record_cue(profile_id: str, label: str, cue: str, decision: str, excerpt: str = "") -> None:
|
||||||
key = normalize_label(label) or (label or "").strip()
|
key = normalize_label(label) or (label or "").strip()
|
||||||
token = (cue or _CUE_BARE).strip().casefold() or _CUE_BARE
|
token = (cue or _CUE_BARE).strip().casefold() or _CUE_BARE
|
||||||
|
snippet = (excerpt or "").strip()
|
||||||
if not key or decision not in {_DECISION_IDENTITY, _DECISION_NOT}:
|
if not key or decision not in {_DECISION_IDENTITY, _DECISION_NOT}:
|
||||||
return
|
return
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO label_sense_cues (
|
INSERT INTO label_sense_cues (
|
||||||
profile_id, normalized_label, cue, decision, hits, updated
|
profile_id, normalized_label, cue, decision, hits, excerpt, updated
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, 1, datetime('now'))
|
VALUES (?, ?, ?, ?, 1, ?, datetime('now'))
|
||||||
ON CONFLICT(profile_id, normalized_label, cue) DO UPDATE SET
|
ON CONFLICT(profile_id, normalized_label, cue) DO UPDATE SET
|
||||||
decision = excluded.decision,
|
decision = excluded.decision,
|
||||||
hits = label_sense_cues.hits + 1,
|
hits = label_sense_cues.hits + 1,
|
||||||
|
excerpt = CASE
|
||||||
|
WHEN excluded.excerpt <> '' THEN excluded.excerpt
|
||||||
|
ELSE label_sense_cues.excerpt
|
||||||
|
END,
|
||||||
updated = datetime('now')
|
updated = datetime('now')
|
||||||
""",
|
""",
|
||||||
(profile_id, key, token, decision),
|
(profile_id, key, token, decision, snippet),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cue_examples(profile_id: str, label: str, limit: int = 6) -> list[dict]:
|
||||||
|
key = normalize_label(label) or (label or "").strip()
|
||||||
|
if not key:
|
||||||
|
return []
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT cue, decision, excerpt, hits
|
||||||
|
FROM label_sense_cues
|
||||||
|
WHERE profile_id = ? AND lower(normalized_label) = lower(?)
|
||||||
|
ORDER BY hits DESC, updated DESC
|
||||||
|
""",
|
||||||
|
(profile_id, key),
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for raw in rows:
|
||||||
|
row = row_to_dict(raw) or {}
|
||||||
|
decision = (row.get("decision") or "").strip()
|
||||||
|
if decision not in {_DECISION_IDENTITY, _DECISION_NOT}:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"cue": row.get("cue") or _CUE_BARE,
|
||||||
|
"decision": decision,
|
||||||
|
"excerpt": (row.get("excerpt") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def decision_for_mention(
|
def decision_for_mention(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
label: str,
|
label: str,
|
||||||
|
|
@ -363,13 +437,13 @@ def apply_learned_decisions(
|
||||||
|
|
||||||
|
|
||||||
def kinship_governed_labels(user_body: str) -> set[str]:
|
def kinship_governed_labels(user_body: str) -> set[str]:
|
||||||
"""Labels after Frau/Sohn/… in the current user line. Not a food word list."""
|
"""Labels attached to Frau/Sohn/… in the current user line. Not a food word list."""
|
||||||
found: set[str] = set()
|
found: set[str] = set()
|
||||||
for match in _word_matches(user_body):
|
for match in _word_matches(user_body):
|
||||||
label = match.group(0)
|
label = match.group(0)
|
||||||
if not is_maskable_label(label):
|
if not is_maskable_label(label):
|
||||||
continue
|
continue
|
||||||
if _prev_word(user_body, match.start()) in KINSHIP:
|
if attachment_kinship(user_body, match.start(), match.end()):
|
||||||
found.add(label.casefold())
|
found.add(label.casefold())
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
@ -429,7 +503,7 @@ def supplement_kinship_candidates(
|
||||||
continue
|
continue
|
||||||
occupied.add(user_key)
|
occupied.add(user_key)
|
||||||
start, end = _align_user_span(rendered, user_body, match.start(), match.end())
|
start, end = _align_user_span(rendered, user_body, match.start(), match.end())
|
||||||
identity_shaped = _prev_word(user_body, match.start()) in KINSHIP
|
identity_shaped = bool(attachment_kinship(user_body, match.start(), match.end()))
|
||||||
extra_candidates.append(
|
extra_candidates.append(
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
|
|
@ -572,11 +646,11 @@ def apply_review_decisions(profile_id: str, pending: dict, decisions: list[dict]
|
||||||
)
|
)
|
||||||
if decision == _DECISION_IDENTITY:
|
if decision == _DECISION_IDENTITY:
|
||||||
record_sense(profile_id, label, identity=True)
|
record_sense(profile_id, label, identity=True)
|
||||||
record_cue(profile_id, label, cue, _DECISION_IDENTITY)
|
record_cue(profile_id, label, cue, _DECISION_IDENTITY, candidate.get("excerpt") or "")
|
||||||
confirm_identity(profile_id, label, entity_type=kind)
|
confirm_identity(profile_id, label, entity_type=kind)
|
||||||
elif decision == _DECISION_NOT:
|
elif decision == _DECISION_NOT:
|
||||||
record_sense(profile_id, label, identity=False)
|
record_sense(profile_id, label, identity=False)
|
||||||
record_cue(profile_id, label, cue, _DECISION_NOT)
|
record_cue(profile_id, label, cue, _DECISION_NOT, candidate.get("excerpt") or "")
|
||||||
drop_keys.add((candidate.get("start"), candidate.get("end"), label.casefold()))
|
drop_keys.add((candidate.get("start"), candidate.get("end"), label.casefold()))
|
||||||
mappings = []
|
mappings = []
|
||||||
for mapping in pending.get("mappings") or []:
|
for mapping in pending.get("mappings") or []:
|
||||||
|
|
@ -659,14 +733,15 @@ def auto_resolve_ambiguous(profile_id: str, candidates: list[dict], mappings: li
|
||||||
remaining.append(item)
|
remaining.append(item)
|
||||||
continue
|
continue
|
||||||
label = item.get("text") or ""
|
label = item.get("text") or ""
|
||||||
decision = try_local_passage_decision(item.get("excerpt") or "", label)
|
examples = list_cue_examples(profile_id, label)
|
||||||
|
decision = try_local_passage_decision(item.get("excerpt") or "", label, examples)
|
||||||
if decision == _DECISION_NOT:
|
if decision == _DECISION_NOT:
|
||||||
record_sense(profile_id, label, identity=False)
|
record_sense(profile_id, label, identity=False)
|
||||||
record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_NOT)
|
record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_NOT, item.get("excerpt") or "")
|
||||||
drop_keys.add((item.get("start"), item.get("end"), label.casefold()))
|
drop_keys.add((item.get("start"), item.get("end"), label.casefold()))
|
||||||
elif decision == _DECISION_IDENTITY:
|
elif decision == _DECISION_IDENTITY:
|
||||||
record_sense(profile_id, label, identity=True)
|
record_sense(profile_id, label, identity=True)
|
||||||
record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_IDENTITY)
|
record_cue(profile_id, label, item.get("cue") or _CUE_BARE, _DECISION_IDENTITY, item.get("excerpt") or "")
|
||||||
else:
|
else:
|
||||||
remaining.append(item)
|
remaining.append(item)
|
||||||
if not drop_keys:
|
if not drop_keys:
|
||||||
|
|
@ -680,15 +755,26 @@ def auto_resolve_ambiguous(profile_id: str, candidates: list[dict], mappings: li
|
||||||
return remaining, kept
|
return remaining, kept
|
||||||
|
|
||||||
|
|
||||||
def try_local_passage_decision(excerpt: str, label: str) -> str | None:
|
def try_local_passage_decision(excerpt: str, label: str, examples: list[dict] | None = None) -> str | None:
|
||||||
"""Return identity, not_identity, or None if no local model or unusable answer."""
|
"""Return identity, not_identity, or None if no local model or unusable answer."""
|
||||||
from providers import ProviderError, complete_chat, detect_provider
|
from providers import ProviderError, complete_chat, detect_provider
|
||||||
|
|
||||||
config = detect_provider()
|
config = detect_provider()
|
||||||
if not config or not config.local or config.mode != "http":
|
if not config or not config.local or config.mode != "http":
|
||||||
return None
|
return None
|
||||||
|
few_shot = ""
|
||||||
|
lines = []
|
||||||
|
for example in examples or []:
|
||||||
|
snippet = (example.get("excerpt") or "").strip()
|
||||||
|
decision = (example.get("decision") or "").strip()
|
||||||
|
if snippet and decision in {_DECISION_IDENTITY, _DECISION_NOT}:
|
||||||
|
lines.append(f'- "{snippet}" → {decision}')
|
||||||
|
if lines:
|
||||||
|
few_shot = "Bisherige lokale Bestätigungen als Beispiele, nicht als Wortliste:\n" + "\n".join(lines) + "\n"
|
||||||
prompt = (
|
prompt = (
|
||||||
|
f"{few_shot}"
|
||||||
"Entscheide nur für die markierte Nennung in diesem kurzen Ausschnitt. "
|
"Entscheide nur für die markierte Nennung in diesem kurzen Ausschnitt. "
|
||||||
|
"Nutze den Satzkontext, nicht nur das Wort davor. "
|
||||||
"Ist sie eine schützenswerte Identität (Person, Ort, Organisation, privates Projekt) "
|
"Ist sie eine schützenswerte Identität (Person, Ort, Organisation, privates Projekt) "
|
||||||
f"oder eine Sache/Allgemeinbedeutung? Wort: {label}\n"
|
f"oder eine Sache/Allgemeinbedeutung? Wort: {label}\n"
|
||||||
f"Ausschnitt: {excerpt}\n"
|
f"Ausschnitt: {excerpt}\n"
|
||||||
|
|
|
||||||
2
backend/migrations/026_label_sense_cue_excerpt.sql
Normal file
2
backend/migrations/026_label_sense_cue_excerpt.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Mini-passages beside each cue so local detect/GLiNER can pre-decide from context.
|
||||||
|
ALTER TABLE label_sense_cues ADD COLUMN IF NOT EXISTS excerpt TEXT NOT NULL DEFAULT '';
|
||||||
|
|
@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS label_sense_cues (
|
||||||
cue TEXT NOT NULL,
|
cue TEXT NOT NULL,
|
||||||
decision TEXT NOT NULL,
|
decision TEXT NOT NULL,
|
||||||
hits INTEGER NOT NULL DEFAULT 1,
|
hits INTEGER NOT NULL DEFAULT 1,
|
||||||
|
excerpt TEXT NOT NULL DEFAULT '',
|
||||||
updated TEXT NOT NULL DEFAULT (datetime('now')),
|
updated TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
PRIMARY KEY (profile_id, normalized_label, cue)
|
PRIMARY KEY (profile_id, normalized_label, cue)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ os.environ["KANSHO_FAKE_DETECT"] = "1"
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from detect_learning import (
|
from detect_learning import (
|
||||||
|
cue_for_mention,
|
||||||
excerpt_view,
|
excerpt_view,
|
||||||
get_cue_decision,
|
get_cue_decision,
|
||||||
get_detect_operating_mode,
|
get_detect_operating_mode,
|
||||||
|
|
@ -270,6 +271,14 @@ def main() -> None:
|
||||||
prefix + homonym,
|
prefix + homonym,
|
||||||
)
|
)
|
||||||
expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi")
|
expect(mapped["highlight_start"] == second["highlight_start"], "rendered detect offsets map to the dish Sushi")
|
||||||
|
expect(cue_for_mention(homonym, first_at, first_at + 5, "Sushi") == "frau", "person Sushi attaches to Frau")
|
||||||
|
expect(cue_for_mention(homonym, second_at, second_at + 5, "Sushi") == "rohan", "dish Sushi stays distinct from Frau")
|
||||||
|
appos = "Sushi, meine Frau war erkältet."
|
||||||
|
appos_at = appos.index("Sushi")
|
||||||
|
expect(cue_for_mention(appos, appos_at, appos_at + 5, "Sushi") == "frau", "apposition Sushi, meine Frau binds Frau")
|
||||||
|
far = "Meine Frau und ich aßen Sushi."
|
||||||
|
far_at = far.rindex("Sushi")
|
||||||
|
expect(cue_for_mention(far, far_at, far_at + 5, "Sushi") != "frau", "Frau elsewhere in the sentence does not bind the dish")
|
||||||
|
|
||||||
sushi_review = sushi.json().get("pending_mask_review") or {}
|
sushi_review = sushi.json().get("pending_mask_review") or {}
|
||||||
sushi_decisions = []
|
sushi_decisions = []
|
||||||
|
|
|
||||||
|
|
@ -719,7 +719,7 @@ 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.
|
**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.
|
**Additiv 2026-09-10:** Eine bestätigte Nennung wird nicht erneut gefragt, wenn derselbe lokale Kontext wiederkehrt. Enge Verwandtschaftsbindung in der Nominalphrase (auch Apposition: `Sushi, meine Frau`) ist ein Hinweis, nicht die ganze Wahrheit. Der gespeicherte Ausschnitt ist der Kontext für eine lokale Vorentscheidung. Ein späteres lokales GLiNER/Detect sieht diese Mini-Passage plus bestätigte Beispiele; OpenRouter sieht sie nicht. Unbekannte Kontexte bleiben prüfpflichtig. Das ist kein Wortlisten-Editor.
|
||||||
|
|
||||||
**Nicht:** Gateway abschalten, Detect-Treffer auto-speichern, `Sushi_`/`Sushi+` im Nutzertext, Pattern-Wortliste als Wahrheit.
|
**Nicht:** Gateway abschalten, Detect-Treffer auto-speichern, `Sushi_`/`Sushi+` im Nutzertext, Pattern-Wortliste als Wahrheit.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ 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.
|
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.
|
**Additiv 2026-09-10:** Bestätigungen speichern den lokalen Cue und den Ausschnitt in `label_sense_cues`. Cue ist die enge Bindung in der Phrase (`Frau Sushi`, `Sushi, meine Frau`), sonst das Wort unmittelbar davor — nicht jedes Verwandtschaftswort irgendwo im Satz, damit Homonyme (`Frau Sushi` / Speise) getrennt bleiben. Der Ausschnitt geht nur an ein **lokales** Detect/GLiNER als Mini-Passage plus bestätigte Beispiele; das ist die Vorentscheidung. GLiNER ist dafür der bevorzugte lokale Weg, in diesem Slice noch nicht verdrahtet. Fehlt das lokale Modell, bleibt das Popup. OpenRouter erhält diese Passage nicht.
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user