Add support for handling non-identity entity types in detection logic
All checks were successful
All checks were successful
- Introduced a new set of non-identity entity types (FOOD, DISH, MEAL, CUISINE, FOODSTUFF, INGREDIENT) that are omitted from detection results without causing contract violations. - Updated the `DetectionStats` class to track omitted non-identity types. - Modified validation logic to ensure that food-related mentions are not treated as identity types, preserving the context of mentions as either PERSON or omitted. - Enhanced tests to verify the correct handling of food homonyms and ensure that invalid entity types are recorded appropriately without causing errors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
79e4fd0d4b
commit
dd7858347c
|
|
@ -38,8 +38,8 @@
|
|||
"category": "privacy",
|
||||
"prompt_type": "base",
|
||||
"required_feature": "ai_calls",
|
||||
"seed_revision": "2026-08-27-detect-ground-v1",
|
||||
"template": "Untersuche den gesamten Text semantisch. Entscheide kontextabhängig, nicht nach Großschreibung oder Namensähnlichkeit allein.\n\nSchützenswert ist eine konkrete Bezeichnung oder Information, durch die eine natürliche Person, ein genauer persönlicher Ort, eine Organisation, ein privates Projekt oder ein anderer im Produktrahmen definierter Identifikator erkennbar werden kann. Allgemeine Gegenstände, Tätigkeiten, Lebensmittel, Räume und Beschreibungen sind keine Identitäten, sofern sie im konkreten Satz nicht als Eigenname oder identifizierende Information verwendet werden.\n\nErlaubte entity_type-Werte ausschließlich: PERSON, PLACE, ORG, PROJECT.\n\nKontrast, keine Wortliste:\n- «Sushi kam ins Wohnzimmer» kann eine Person bezeichnen.\n- «Ich aß Sushi» bezeichnet ein Gericht.\n- «Ich arbeitete am privaten Projekt Aurora» kann ein schützenswertes Projekt bezeichnen.\n- «Ich ging auf den Balkon» bezeichnet keinen Eigennamen.\n- «Ich traf Anna» bezeichnet eine Person.\n\nNur das identifizierende Wort oder die identifizierende Wortgruppe, nicht den ganzen Satz. Offsets beziehen sich ausschließlich auf den gelieferten Text nach «Text:», 0-basiert, end ausschließlich. text muss ein exakter Substring dieses Texts sein, nicht aus den Beispielen oben. Wenn Offsets unsicher sind, kopiere trotzdem das identifizierende Wort zeichengetreu.\n\nKeine Tokens, keine Platzhalter, keine kanonischen Namen, keine Aliase, keine zusätzlichen Felder.\n\nAntworte nur mit JSON. Form: {\"entities\":[{\"start\":<zahl>,\"end\":<zahl>,\"text\":\"<exakter Substring>\",\"entity_type\":\"PERSON\"}]}\nNichts schützenswert: {\"entities\":[]}\n\nText:\n{{source_text}}\n"
|
||||
"seed_revision": "2026-09-08-detect-homonym-v1",
|
||||
"template": "Untersuche den gesamten Text semantisch. Entscheide kontextabhängig, nicht nach Großschreibung oder Namensähnlichkeit allein.\n\nSchützenswert ist eine konkrete Bezeichnung oder Information, durch die eine natürliche Person, ein genauer persönlicher Ort, eine Organisation, ein privates Projekt oder ein anderer im Produktrahmen definierter Identifikator erkennbar werden kann. Allgemeine Gegenstände, Tätigkeiten, Lebensmittel, Räume und Beschreibungen sind keine Identitäten, sofern sie im konkreten Satz nicht als Eigenname oder identifizierende Information verwendet werden.\n\nErlaubte entity_type-Werte ausschließlich: PERSON, PLACE, ORG, PROJECT.\nEin Lebensmittel oder Gericht ist keine Entität. Nicht als FOOD oder einen anderen Typ melden, sondern weglassen. Derselbe Wortlaut kann im selben Text Person und Gericht sein; nur die identifizierende Nennung gehört in entities.\n\nKontrast, keine Wortliste:\n- «Sushi kam ins Wohnzimmer» kann eine Person bezeichnen.\n- «Ich aß Sushi» bezeichnet ein Gericht.\n- «Ich arbeitete am privaten Projekt Aurora» kann ein schützenswertes Projekt bezeichnen.\n- «Ich ging auf den Balkon» bezeichnet keinen Eigennamen.\n- «Ich traf Anna» bezeichnet eine Person.\n\nNur das identifizierende Wort oder die identifizierende Wortgruppe, nicht den ganzen Satz. Offsets beziehen sich ausschließlich auf den gelieferten Text nach «Text:», 0-basiert, end ausschließlich. text muss ein exakter Substring dieses Texts sein, nicht aus den Beispielen oben. Wenn Offsets unsicher sind, kopiere trotzdem das identifizierende Wort zeichengetreu.\n\nKeine Tokens, keine Platzhalter, keine kanonischen Namen, keine Aliase, keine zusätzlichen Felder.\n\nAntworte nur mit JSON. Form: {\"entities\":[{\"start\":<zahl>,\"end\":<zahl>,\"text\":\"<exakter Substring>\",\"entity_type\":\"PERSON\"}]}\nNichts schützenswert: {\"entities\":[]}\n\nText:\n{{source_text}}\n"
|
||||
},
|
||||
{
|
||||
"id": "mvp-profile-review",
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ def persist_engine_error(
|
|||
"detect_partial_discarded",
|
||||
"contract_violation",
|
||||
"invalid_entity_type",
|
||||
"omitted_non_identity_types",
|
||||
"generate_called",
|
||||
):
|
||||
if merged.get(key) is None:
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@ JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
|
|||
ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"})
|
||||
ALLOWED_ROOT_FIELDS = frozenset({"entities"})
|
||||
TYPE_PRIORITY = {"PERSON": 0, "PROJECT": 1, "ORG": 2, "PLACE": 3}
|
||||
NON_IDENTITY_ENTITY_TYPES = frozenset(
|
||||
{
|
||||
"FOOD",
|
||||
"DISH",
|
||||
"MEAL",
|
||||
"CUISINE",
|
||||
"FOODSTUFF",
|
||||
"INGREDIENT",
|
||||
}
|
||||
)
|
||||
|
||||
ERROR_DETECT_UNAVAILABLE = "detect_provider_unavailable"
|
||||
ERROR_DETECT_INCOMPLETE = "detect_incomplete"
|
||||
|
|
@ -55,6 +65,7 @@ SCHEMA_RETRY_HINT = (
|
|||
"Antworte ausschließlich mit dem geforderten JSON-Objekt. "
|
||||
"Nur die Felder start, end, text und entity_type sind zulässig. "
|
||||
"Nur entity_type-Werte PERSON, PLACE, ORG, PROJECT. "
|
||||
"Lebensmittel und Gerichte weglassen, nicht als FOOD oder anderen Typ melden. "
|
||||
"Offsets sind 0-basiert und end ausschließlich, bezogen auf den Text nach «Text:». "
|
||||
"text muss ein exakter Substring dieses Texts sein. "
|
||||
"Keine zusätzlichen Felder, keine Tokens, keine Platzhalter, keine Erklärungen."
|
||||
|
|
@ -143,6 +154,7 @@ class DetectionStats:
|
|||
detect_attempts: list[dict[str, Any]] = field(default_factory=list)
|
||||
contract_violation: str | None = None
|
||||
invalid_entity_type: str | None = None
|
||||
omitted_non_identity_types: list[str] = field(default_factory=list)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
payload = {
|
||||
|
|
@ -179,6 +191,8 @@ class DetectionStats:
|
|||
payload["contract_violation"] = self.contract_violation
|
||||
if self.invalid_entity_type:
|
||||
payload["invalid_entity_type"] = self.invalid_entity_type
|
||||
if self.omitted_non_identity_types:
|
||||
payload["omitted_non_identity_types"] = list(self.omitted_non_identity_types)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -433,9 +447,56 @@ def _ground_span(chunk_text: str, start: int, end: int, text: str) -> tuple[int,
|
|||
return best[0], best[1], source[best[0] : best[1]]
|
||||
|
||||
|
||||
def _delivered_entity_type(raw: Any) -> str:
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
return re.sub(r"[^A-Z0-9_:-]", "", raw.strip().upper())[:40]
|
||||
|
||||
|
||||
def _identity_mention(chunk_text: str, start: int, end: int) -> bool:
|
||||
from privacy_gateway import is_identity_mention
|
||||
|
||||
return is_identity_mention(chunk_text, start, end, "PERSON:00")
|
||||
|
||||
|
||||
def _span_from_non_identity(item: dict, chunk_text: str, chunk_index: int) -> DetectedSpan | None:
|
||||
"""Food/dish types are not identities. Keep only a person-shaped mention of the same word."""
|
||||
text = item.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return None
|
||||
try:
|
||||
start = int(item.get("start") or 0)
|
||||
except (TypeError, ValueError):
|
||||
start = 0
|
||||
try:
|
||||
end = int(item.get("end") or 0)
|
||||
except (TypeError, ValueError):
|
||||
end = 0
|
||||
grounded = _ground_span(chunk_text, start, end, text)
|
||||
if not grounded:
|
||||
return None
|
||||
left, right, local_text = grounded
|
||||
if not _identity_mention(chunk_text, left, right):
|
||||
return None
|
||||
return DetectedSpan(
|
||||
start=left,
|
||||
end=right,
|
||||
text=local_text,
|
||||
entity_type="PERSON",
|
||||
chunk_index=chunk_index,
|
||||
)
|
||||
|
||||
|
||||
def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> DetectedSpan | None:
|
||||
if not isinstance(item, dict):
|
||||
raise _contract_error("Detect-Entity muss ein Objekt sein.", violation="invalid_entity")
|
||||
if "token" in item or "placeholder" in item:
|
||||
raise _contract_error("Detect darf keine Tokens festlegen.", violation="extra_fields")
|
||||
raw_type = item.get("entity_type")
|
||||
delivered = _delivered_entity_type(raw_type)
|
||||
kind = normalize_entity_type(raw_type if isinstance(raw_type, str) else "", default="")
|
||||
if not kind and delivered in NON_IDENTITY_ENTITY_TYPES:
|
||||
return _span_from_non_identity(item, chunk_text, chunk_index)
|
||||
extra = set(item.keys()) - ALLOWED_ENTITY_FIELDS
|
||||
if extra:
|
||||
raise _contract_error("Detect-Entity enthält unerwartete Felder.", violation="extra_fields")
|
||||
|
|
@ -455,16 +516,12 @@ def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> De
|
|||
raise _contract_error("Detect-text fehlt.", violation="missing_fields")
|
||||
if not text.strip():
|
||||
raise _contract_error("Detect-text ist leer.", violation="invalid_text")
|
||||
if "token" in item or "placeholder" in item:
|
||||
raise _contract_error("Detect darf keine Tokens festlegen.", violation="extra_fields")
|
||||
raw_type = item.get("entity_type")
|
||||
kind = normalize_entity_type(raw_type if isinstance(raw_type, str) else "", default="")
|
||||
if not kind:
|
||||
delivered = raw_type if isinstance(raw_type, str) else ""
|
||||
delivered_raw = raw_type if isinstance(raw_type, str) else ""
|
||||
raise _contract_error(
|
||||
"Detect-entity_type ist nicht erlaubt.",
|
||||
violation="unknown_entity_type",
|
||||
invalid_entity_type=delivered,
|
||||
invalid_entity_type=delivered_raw,
|
||||
)
|
||||
grounded = _ground_span(chunk_text, start, end, text)
|
||||
if not grounded:
|
||||
|
|
@ -532,7 +589,19 @@ def _llm_chunk(config, excerpt: str, *, schema_retry: bool = False) -> ChatResul
|
|||
)
|
||||
|
||||
|
||||
def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int) -> list[DetectedSpan]:
|
||||
def _note_omitted_type(stats: DetectionStats | None, delivered: str) -> None:
|
||||
if not stats or not delivered or delivered not in NON_IDENTITY_ENTITY_TYPES:
|
||||
return
|
||||
if delivered not in stats.omitted_non_identity_types:
|
||||
stats.omitted_non_identity_types.append(delivered)
|
||||
|
||||
|
||||
def _entities_from_result(
|
||||
result: ChatResult,
|
||||
chunk_text: str,
|
||||
chunk_index: int,
|
||||
stats: DetectionStats | None = None,
|
||||
) -> list[DetectedSpan]:
|
||||
if (result.finish_reason or "").lower() in {"length", "max_tokens"}:
|
||||
raise DetectError(
|
||||
ERROR_DETECT_TRUNCATED,
|
||||
|
|
@ -543,8 +612,11 @@ def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int)
|
|||
spans: list[DetectedSpan] = []
|
||||
for item in data.get("entities") or []:
|
||||
span = validate_detected_entity(item, chunk_text, chunk_index)
|
||||
if span is not None:
|
||||
spans.append(span)
|
||||
if span is None:
|
||||
delivered = _delivered_entity_type(item.get("entity_type") if isinstance(item, dict) else "")
|
||||
_note_omitted_type(stats, delivered)
|
||||
continue
|
||||
spans.append(span)
|
||||
return spans
|
||||
|
||||
|
||||
|
|
@ -674,6 +746,9 @@ def _merge_pass_stats(overall: DetectionStats, pass_stats: DetectionStats) -> No
|
|||
if pass_stats.cost_known:
|
||||
overall.cost_known = True
|
||||
overall.cost += pass_stats.cost
|
||||
for kind in pass_stats.omitted_non_identity_types:
|
||||
if kind not in overall.omitted_non_identity_types:
|
||||
overall.omitted_non_identity_types.append(kind)
|
||||
|
||||
|
||||
def _pass_snapshot(
|
||||
|
|
@ -706,6 +781,8 @@ def _pass_snapshot(
|
|||
row["contract_violation"] = pass_stats.contract_violation
|
||||
if pass_stats.invalid_entity_type:
|
||||
row["invalid_entity_type"] = pass_stats.invalid_entity_type
|
||||
if pass_stats.omitted_non_identity_types:
|
||||
row["omitted_non_identity_types"] = list(pass_stats.omitted_non_identity_types)
|
||||
return row
|
||||
|
||||
|
||||
|
|
@ -822,7 +899,7 @@ def _run_detection_pass(
|
|||
)
|
||||
stats.detect_calls += 1
|
||||
_add_usage(stats, result.usage)
|
||||
spans = _entities_from_result(result, chunk_text, index)
|
||||
spans = _entities_from_result(result, chunk_text, index, stats)
|
||||
collected.extend(_to_global(span, offset) for span in spans)
|
||||
stats.chunks_ok += 1
|
||||
if stats.chunks_ok != stats.chunk_count:
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ COMPACT_DIAGNOSTIC_KEYS = (
|
|||
"detect_attempts",
|
||||
"contract_violation",
|
||||
"invalid_entity_type",
|
||||
"omitted_non_identity_types",
|
||||
"detect_cost_unknown",
|
||||
"generation_selection",
|
||||
"style_application",
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ def _chat(entities: list[dict], usage: dict) -> ChatResult:
|
|||
|
||||
|
||||
def _invalid_type():
|
||||
return [{"start": 0, "end": 1, "text": "x", "entity_type": "FOOD"}]
|
||||
return [{"start": 0, "end": 1, "text": "x", "entity_type": "HUMAN"}]
|
||||
|
||||
|
||||
def _extra_field():
|
||||
|
|
@ -135,14 +135,14 @@ def main() -> None:
|
|||
sample = "Ich traf Anna."
|
||||
try:
|
||||
validate_detected_entity(
|
||||
{"start": 0, "end": 4, "text": "Anna", "entity_type": "FOOD"},
|
||||
{"start": 0, "end": 4, "text": "Anna", "entity_type": "HUMAN"},
|
||||
sample,
|
||||
0,
|
||||
)
|
||||
typed = False
|
||||
except DetectError as exc:
|
||||
typed = exc.code == "detect_invalid_output"
|
||||
expect((exc.diagnostics or {}).get("invalid_entity_type") == "FOOD", "unknown type is recorded without the span text")
|
||||
expect((exc.diagnostics or {}).get("invalid_entity_type") == "HUMAN", "unknown type is recorded without the span text")
|
||||
expect((exc.diagnostics or {}).get("contract_violation") == "unknown_entity_type", "unknown type has a violation category")
|
||||
expect("Anna" not in json.dumps(exc.diagnostics or {}), "span plaintext is not stored on the type violation")
|
||||
expect(typed, "unknown entity_type is a contract error")
|
||||
|
|
@ -252,10 +252,10 @@ def main() -> None:
|
|||
expect(blocked.diagnostics.get("generate_called") is False, "final abort keeps generate_called false")
|
||||
expect(blocked.diagnostics.get("detect_passes") == 2, "both invalid passes are counted")
|
||||
expect(blocked.diagnostics.get("contract_violation") == "unknown_entity_type", "final abort keeps the violation category")
|
||||
expect(blocked.diagnostics.get("invalid_entity_type") == "FOOD", "delivered type is stored without span text")
|
||||
expect(blocked.diagnostics.get("invalid_entity_type") == "HUMAN", "delivered type is stored without span text")
|
||||
expect(blocked.diagnostics.get("detect_partial_discarded") is True, "failed retry discarded the first pass")
|
||||
expect(_ids(blocked.diagnostics), "failed diagnostics are acyclic")
|
||||
expect("FOOD" in json.dumps(blocked.diagnostics), "invalid type remains visible")
|
||||
expect("HUMAN" in json.dumps(blocked.diagnostics), "invalid type remains visible")
|
||||
expect("secret" not in json.dumps(blocked.diagnostics).lower(), "no extra identity payload")
|
||||
expect(blocked.status_code != 500, "detect abort is not a generic 500")
|
||||
|
||||
|
|
@ -334,7 +334,7 @@ def main() -> None:
|
|||
def script(attempt, chunk_index, chunk_text, offset, schema_retry=False):
|
||||
seen.append((kind, attempt))
|
||||
if attempt == 1:
|
||||
entity_type = "FOOD" if kind == "alpha" else "ANIMAL"
|
||||
entity_type = "HUMAN" if kind == "alpha" else "ANIMAL"
|
||||
return _chat([{"start": 0, "end": 1, "text": "x", "entity_type": entity_type}], USAGE_A)
|
||||
return _chat([], USAGE_B)
|
||||
|
||||
|
|
@ -357,7 +357,7 @@ def main() -> None:
|
|||
all(kind == row[0] for row in by_kind.values() for kind, _attempt in row[1]),
|
||||
"parallel scripts stay on their request",
|
||||
)
|
||||
expect(by_kind["alpha"][2] in (None, "FOOD"), "alpha does not keep beta's type")
|
||||
expect(by_kind["alpha"][2] in (None, "HUMAN"), "alpha does not keep beta's type")
|
||||
expect(by_kind["beta"][2] in (None, "ANIMAL"), "beta does not keep alpha's type")
|
||||
expect(by_kind["alpha"][4] != by_kind["beta"][4], "parallel stats objects are distinct")
|
||||
|
||||
|
|
@ -442,7 +442,7 @@ def main() -> None:
|
|||
expect(payload.get("detect_passes") == 2, "stored run records both detect passes")
|
||||
expect(payload.get("detect_partial_discarded") is True, "stored run records discarded partials")
|
||||
expect(payload.get("contract_violation") == "unknown_entity_type", "stored run records the violation")
|
||||
expect(payload.get("invalid_entity_type") == "FOOD", "stored run records the delivered type")
|
||||
expect(payload.get("invalid_entity_type") == "HUMAN", "stored run records the delivered type")
|
||||
expect(payload.get("generation_selection") or (payload.get("trace") or {}).get("generation_selection"), "stored run keeps generation selection")
|
||||
expect("local_label" not in json.dumps(payload), "stored run has no mapping labels")
|
||||
exported = client.get(
|
||||
|
|
|
|||
|
|
@ -113,6 +113,73 @@ def main() -> None:
|
|||
expect("[[PERSON:" in (person_sushi.trace.get("egress") or ""), "same word can be a person in another sentence")
|
||||
expect("Sushi" not in (person_sushi.trace.get("egress") or "").replace("[[PERSON:", ""), "person Sushi is masked")
|
||||
|
||||
mixed_homonym = "Ich aß Sushi. Meine Frau Sushi kam später."
|
||||
food_at = mixed_homonym.find("Sushi")
|
||||
person_at = mixed_homonym.find("Sushi", food_at + 1)
|
||||
dish = validate_detected_entity(
|
||||
{"start": food_at, "end": food_at + 5, "text": "Sushi", "entity_type": "FOOD"},
|
||||
mixed_homonym,
|
||||
0,
|
||||
)
|
||||
expect(dish is None, "FOOD on a dish mention is omitted, not a contract error")
|
||||
person_from_food = validate_detected_entity(
|
||||
{"start": person_at, "end": person_at + 5, "text": "Sushi", "entity_type": "FOOD"},
|
||||
mixed_homonym,
|
||||
0,
|
||||
)
|
||||
expect(
|
||||
person_from_food is not None and person_from_food.entity_type == "PERSON",
|
||||
"FOOD on a kinship mention stays PERSON",
|
||||
)
|
||||
arrived = "Sushi kam später."
|
||||
arrived_span = validate_detected_entity(
|
||||
{"start": 0, "end": 5, "text": "Sushi", "entity_type": "DISH"},
|
||||
arrived,
|
||||
0,
|
||||
)
|
||||
expect(
|
||||
arrived_span is not None and arrived_span.entity_type == "PERSON",
|
||||
"DISH on a person subject stays PERSON",
|
||||
)
|
||||
noted = validate_detected_entity(
|
||||
{
|
||||
"start": food_at,
|
||||
"end": food_at + 5,
|
||||
"text": "Sushi",
|
||||
"entity_type": "FOOD",
|
||||
"note": "Gericht",
|
||||
},
|
||||
mixed_homonym,
|
||||
0,
|
||||
)
|
||||
expect(noted is None, "extra fields on a FOOD dish do not fail the pass")
|
||||
install_test_spans(
|
||||
[
|
||||
{"start": food_at, "end": food_at + 5, "text": "Sushi", "entity_type": "FOOD"},
|
||||
{"start": person_at, "end": person_at + 5, "text": "Sushi", "entity_type": "FOOD"},
|
||||
]
|
||||
)
|
||||
try:
|
||||
mixed_run = _run(profile_id, mixed_homonym)
|
||||
mixed_egress = mixed_run.trace.get("egress") or ""
|
||||
expect(mixed_run.allowed, "FOOD homonym does not block generate")
|
||||
expect("aß Sushi" in mixed_egress, "reconstructed dish stays unmasked")
|
||||
expect("[[PERSON:" in mixed_egress, "person-shaped FOOD span is masked")
|
||||
expect(
|
||||
not mixed_run.diagnostics.get("contract_violation"),
|
||||
"food type is not a contract violation",
|
||||
)
|
||||
expect(
|
||||
not mixed_run.diagnostics.get("invalid_entity_type"),
|
||||
"food type is not recorded as invalid",
|
||||
)
|
||||
expect(
|
||||
"FOOD" in (mixed_run.diagnostics.get("omitted_non_identity_types") or []),
|
||||
"omitted food type is recorded without span text",
|
||||
)
|
||||
finally:
|
||||
reset_detect_test_hooks()
|
||||
|
||||
mixed = _run(
|
||||
profile_id,
|
||||
"Ich traf Anna. Ich arbeitete am privaten Projekt Aurora. "
|
||||
|
|
@ -470,7 +537,7 @@ def main() -> None:
|
|||
"SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
|
||||
("mvp.entity_detect",),
|
||||
).fetchone()
|
||||
expect(row["seed_revision"] == "2026-08-27-detect-ground-v1", "detect prompt revision is stored")
|
||||
expect(row["seed_revision"] == "2026-09-08-detect-homonym-v1", "detect prompt revision is stored")
|
||||
expect("{{known_labels}}" not in (row["template"] or ""), "new detect prompt has no known_labels skip list")
|
||||
expect("Zwiebeln" not in (row["template"] or ""), "detect prompt has no food word list")
|
||||
|
||||
|
|
@ -488,7 +555,7 @@ def main() -> None:
|
|||
("mvp.entity_detect",),
|
||||
).fetchone()
|
||||
expect(custom["template"] == "CUSTOM DETECT {{source_text}}", "user-edited detect prompt is not overwritten")
|
||||
expect(custom["seed_revision"] == "2026-08-27-detect-ground-v1", "revision still updates the default")
|
||||
expect(custom["seed_revision"] == "2026-09-08-detect-homonym-v1", "revision still updates the default")
|
||||
|
||||
remote = ProviderConfig(
|
||||
role="detect",
|
||||
|
|
|
|||
|
|
@ -207,6 +207,8 @@ Erlaubte Typen: `PERSON`, `PLACE`, `ORG`, `PROJECT`. `PROJECT` ist additiv entsc
|
|||
|
||||
**Additiv 2026-08-29 (Detect-Vertrag, ein vollständiger Neuversuch):** Eine formal ungültige Detector-Antwort (unbekannter `entity_type`, Extra-/Fehlfelder, ungültiges JSON, nicht verankerbarer Text, Abbruch/`finish_reason=length`, unvollständige Chunk-Abdeckung) verwirft den gesamten bisherigen request-lokalen Pass. Es folgt genau ein neuer vollständiger Pass über alle Chunks mit einer allgemeinen Schema-Korrekturanweisung, ohne textspezifische Namen oder Fehlertypen. Misslingt auch der zweite Pass: fail-closed, kein Generate, kein Journalentwurf. Netzwerk- und Timeout-Fehler (`detect_chunk_failed`) werden nicht durch denselben Vertrag-Retry verdoppelt. Ungültige Entitäten werden nicht still ignoriert, nicht auf erlaubte Typen umgedeutet und nicht durch Pattern-Fallback ersetzt. Compact-Fehlerdiagnose: `detect_passes`, Versuche mit Chunk-/Aufruf-/Coverage-Zahlen, aggregierte Detect-Tokens/Kosten/Dauer, `detect_partial_discarded`, `contract_violation`, bei unbekanntem Typ den gelieferten `entity_type` ohne Klartextspan, `generate_called: false`. Unbekannte Providerkosten bleiben `detect_cost_unknown`. Diagnoseobjekte sind azyklisch (`trace.budget` ist ein Snapshot, kein Live-`diagnostics`). Persistenzfehler ersetzen den ursprünglichen EngineError nicht.
|
||||
|
||||
**Additiv 2026-09-08 (Lebensmittel-Homonym, kein Vertragsbruch):** Explizit nicht-identifizierende Detect-Typen (`FOOD`, `DISH`, `MEAL`, `CUISINE`, `FOODSTUFF`, `INGREDIENT`) sind keine Schemaverletzung. Sie werden nicht auf einen erlaubten Typ umgedeutet und nicht maskiert, sofern die lokale Homonymregel denselben Wortlaut als Sache behandelt (`Sushi essen`, `aß Sushi`). Steht dieselbe Meldung in einem Personenkontext (`Frau Sushi`, `Sushi kam`), gilt lokal die bestehende Identitätsregel und der Span wird als `PERSON` behalten. Andere unbekannte Typen (`HUMAN`, `NAME`, `WIFE`, …) bleiben fail-closed. Extrafelder, Tokens, unbrauchbare Offsets und unvollständige Chunks bleiben unverändert fail-closed. Compact-Diagnose darf `omitted_non_identity_types` ohne Klartextspan enthalten.
|
||||
|
||||
Schemaverletzungen, Abbruch und unvollständige Chunks bleiben fail-closed. Im Dialogzug erzeugt ein solcher Fail-closed lokal einen Halte-Impuls, ohne den Generate-Provider zu rufen. Der Nutzer sieht keine interne Substring-Diagnose.
|
||||
|
||||
### RequestDetectionManifest vs. ConfirmedIdentityRegistry
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user