Add support for handling non-identity entity types in detection logic
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Test Suite / pytest-backend (push) Successful in 2m37s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 15s
Deploy Production / deploy (push) Successful in 43s

- 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:
Lars 2026-09-08 07:35:44 +02:00
parent 79e4fd0d4b
commit dd7858347c
7 changed files with 170 additions and 22 deletions

View File

@ -38,8 +38,8 @@
"category": "privacy", "category": "privacy",
"prompt_type": "base", "prompt_type": "base",
"required_feature": "ai_calls", "required_feature": "ai_calls",
"seed_revision": "2026-08-27-detect-ground-v1", "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.\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" "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", "id": "mvp-profile-review",

View File

@ -289,6 +289,7 @@ def persist_engine_error(
"detect_partial_discarded", "detect_partial_discarded",
"contract_violation", "contract_violation",
"invalid_entity_type", "invalid_entity_type",
"omitted_non_identity_types",
"generate_called", "generate_called",
): ):
if merged.get(key) is None: if merged.get(key) is None:

View File

@ -36,6 +36,16 @@ JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"}) ALLOWED_ENTITY_FIELDS = frozenset({"start", "end", "text", "entity_type"})
ALLOWED_ROOT_FIELDS = frozenset({"entities"}) ALLOWED_ROOT_FIELDS = frozenset({"entities"})
TYPE_PRIORITY = {"PERSON": 0, "PROJECT": 1, "ORG": 2, "PLACE": 3} 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_UNAVAILABLE = "detect_provider_unavailable"
ERROR_DETECT_INCOMPLETE = "detect_incomplete" ERROR_DETECT_INCOMPLETE = "detect_incomplete"
@ -55,6 +65,7 @@ SCHEMA_RETRY_HINT = (
"Antworte ausschließlich mit dem geforderten JSON-Objekt. " "Antworte ausschließlich mit dem geforderten JSON-Objekt. "
"Nur die Felder start, end, text und entity_type sind zulässig. " "Nur die Felder start, end, text und entity_type sind zulässig. "
"Nur entity_type-Werte PERSON, PLACE, ORG, PROJECT. " "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:». " "Offsets sind 0-basiert und end ausschließlich, bezogen auf den Text nach «Text:». "
"text muss ein exakter Substring dieses Texts sein. " "text muss ein exakter Substring dieses Texts sein. "
"Keine zusätzlichen Felder, keine Tokens, keine Platzhalter, keine Erklärungen." "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) detect_attempts: list[dict[str, Any]] = field(default_factory=list)
contract_violation: str | None = None contract_violation: str | None = None
invalid_entity_type: 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]: def public(self) -> dict[str, Any]:
payload = { payload = {
@ -179,6 +191,8 @@ class DetectionStats:
payload["contract_violation"] = self.contract_violation payload["contract_violation"] = self.contract_violation
if self.invalid_entity_type: if self.invalid_entity_type:
payload["invalid_entity_type"] = 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 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]] 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: def validate_detected_entity(item: Any, chunk_text: str, chunk_index: int) -> DetectedSpan | None:
if not isinstance(item, dict): if not isinstance(item, dict):
raise _contract_error("Detect-Entity muss ein Objekt sein.", violation="invalid_entity") 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 extra = set(item.keys()) - ALLOWED_ENTITY_FIELDS
if extra: if extra:
raise _contract_error("Detect-Entity enthält unerwartete Felder.", violation="extra_fields") 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") raise _contract_error("Detect-text fehlt.", violation="missing_fields")
if not text.strip(): if not text.strip():
raise _contract_error("Detect-text ist leer.", violation="invalid_text") 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: 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( raise _contract_error(
"Detect-entity_type ist nicht erlaubt.", "Detect-entity_type ist nicht erlaubt.",
violation="unknown_entity_type", violation="unknown_entity_type",
invalid_entity_type=delivered, invalid_entity_type=delivered_raw,
) )
grounded = _ground_span(chunk_text, start, end, text) grounded = _ground_span(chunk_text, start, end, text)
if not grounded: 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"}: if (result.finish_reason or "").lower() in {"length", "max_tokens"}:
raise DetectError( raise DetectError(
ERROR_DETECT_TRUNCATED, ERROR_DETECT_TRUNCATED,
@ -543,7 +612,10 @@ def _entities_from_result(result: ChatResult, chunk_text: str, chunk_index: int)
spans: list[DetectedSpan] = [] spans: list[DetectedSpan] = []
for item in data.get("entities") or []: for item in data.get("entities") or []:
span = validate_detected_entity(item, chunk_text, chunk_index) span = validate_detected_entity(item, chunk_text, chunk_index)
if span is not None: 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) spans.append(span)
return spans return spans
@ -674,6 +746,9 @@ def _merge_pass_stats(overall: DetectionStats, pass_stats: DetectionStats) -> No
if pass_stats.cost_known: if pass_stats.cost_known:
overall.cost_known = True overall.cost_known = True
overall.cost += pass_stats.cost 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( def _pass_snapshot(
@ -706,6 +781,8 @@ def _pass_snapshot(
row["contract_violation"] = pass_stats.contract_violation row["contract_violation"] = pass_stats.contract_violation
if pass_stats.invalid_entity_type: if pass_stats.invalid_entity_type:
row["invalid_entity_type"] = 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 return row
@ -822,7 +899,7 @@ def _run_detection_pass(
) )
stats.detect_calls += 1 stats.detect_calls += 1
_add_usage(stats, result.usage) _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) collected.extend(_to_global(span, offset) for span in spans)
stats.chunks_ok += 1 stats.chunks_ok += 1
if stats.chunks_ok != stats.chunk_count: if stats.chunks_ok != stats.chunk_count:

View File

@ -96,6 +96,7 @@ COMPACT_DIAGNOSTIC_KEYS = (
"detect_attempts", "detect_attempts",
"contract_violation", "contract_violation",
"invalid_entity_type", "invalid_entity_type",
"omitted_non_identity_types",
"detect_cost_unknown", "detect_cost_unknown",
"generation_selection", "generation_selection",
"style_application", "style_application",

View File

@ -101,7 +101,7 @@ def _chat(entities: list[dict], usage: dict) -> ChatResult:
def _invalid_type(): 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(): def _extra_field():
@ -135,14 +135,14 @@ def main() -> None:
sample = "Ich traf Anna." sample = "Ich traf Anna."
try: try:
validate_detected_entity( validate_detected_entity(
{"start": 0, "end": 4, "text": "Anna", "entity_type": "FOOD"}, {"start": 0, "end": 4, "text": "Anna", "entity_type": "HUMAN"},
sample, sample,
0, 0,
) )
typed = False typed = False
except DetectError as exc: except DetectError as exc:
typed = exc.code == "detect_invalid_output" 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((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("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") 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("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("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("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(blocked.diagnostics.get("detect_partial_discarded") is True, "failed retry discarded the first pass")
expect(_ids(blocked.diagnostics), "failed diagnostics are acyclic") 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("secret" not in json.dumps(blocked.diagnostics).lower(), "no extra identity payload")
expect(blocked.status_code != 500, "detect abort is not a generic 500") 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): def script(attempt, chunk_index, chunk_text, offset, schema_retry=False):
seen.append((kind, attempt)) seen.append((kind, attempt))
if attempt == 1: 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([{"start": 0, "end": 1, "text": "x", "entity_type": entity_type}], USAGE_A)
return _chat([], USAGE_B) 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]), all(kind == row[0] for row in by_kind.values() for kind, _attempt in row[1]),
"parallel scripts stay on their request", "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["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") 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_passes") == 2, "stored run records both detect passes")
expect(payload.get("detect_partial_discarded") is True, "stored run records discarded partials") 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("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(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") expect("local_label" not in json.dumps(payload), "stored run has no mapping labels")
exported = client.get( exported = client.get(

View File

@ -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("[[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") 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( mixed = _run(
profile_id, profile_id,
"Ich traf Anna. Ich arbeitete am privaten Projekt Aurora. " "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 = ?", "SELECT seed_revision, template, default_template FROM ai_prompts WHERE slug = ?",
("mvp.entity_detect",), ("mvp.entity_detect",),
).fetchone() ).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("{{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") expect("Zwiebeln" not in (row["template"] or ""), "detect prompt has no food word list")
@ -488,7 +555,7 @@ def main() -> None:
("mvp.entity_detect",), ("mvp.entity_detect",),
).fetchone() ).fetchone()
expect(custom["template"] == "CUSTOM DETECT {{source_text}}", "user-edited detect prompt is not overwritten") 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( remote = ProviderConfig(
role="detect", role="detect",

View File

@ -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-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. 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 ### RequestDetectionManifest vs. ConfirmedIdentityRegistry