Skip to content

Commit ce1f1e6

Browse files
authored
fix(backend): stop one out-of-vocabulary extractor enum from failing a whole conversation (#11871)
* fix(backend): stop one out-of-vocabulary extractor enum from failing a whole conversation The conversation structure model is asked for `owner_name` when the owner is known, but the notes-v2 prompt never restricts `capture_owner` to its vocabulary, so the model sometimes answers with the speaker's name there instead. `ExtractedActionItem.capture_owner` is a pydantic Literal, so each such item raised literal_error, PydanticOutputParser turned the batch into OutputParserException, and `_get_structured` mapped that to HTTP 500 — the user's conversation never got a summary at all. In prod on 2026-08-19 one conversation came back with `capture_owner: "Archit"` on three of six action items; every other field was well formed. Three bad tokens cost the whole note. `ExtractedActionItem` now normalizes its optional literal fields before validation: a recognized value in different casing is accepted, an unusable value falls back to the field's own default of None, and a name where an owner class belongs becomes `other` with the name carried into `owner_name`, which is where the schema already wanted it. The guard sits on the model, so both parser targets that embed it — StructuredExtraction and ActionItemsExtraction — are covered. Verification: backend/tests/unit/test_extracted_action_item_vocabulary.py, 10 tests, all pass and all 10 fail against origin/main. The first drives the real prod completion through the production PydanticOutputParser seam. Related suites re-run green: test_conversation_notes_v2, test_backend_candidate_capture, test_chat_first_proactive_engine, test_action_item_date_validation, test_action_items_date_coercion, test_conversation_model_split, test_task_transcript_provenance, test_llm_gateway_openai_compatible, test_llm_gateway_chat_extraction_pilot, test_byok_llm_logging, test_omi_qos_tiers, test_daily_summary_zero_coordinate_locations. Failure-Class: new * chore(failure-classes): register FC-out-of-vocabulary-model-token-discards-extraction The class this fix repairs has no definition yet. A strict enum on one field of one list element failed the whole provider completion, so a conversation lost its note over three tokens. Failure-Class: none * chore(failure-classes): cite the real PR number as evidence Failure-Class: none
1 parent 091a65b commit ce1f1e6

3 files changed

Lines changed: 170 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"schema_version": 1,
3+
"id": "FC-out-of-vocabulary-model-token-discards-extraction",
4+
"violated_contract": "A closed vocabulary a language model was asked to answer in is a preference, not a guarantee, so one out-of-vocabulary token must never discard the whole payload it appeared in. A provider completion is parsed as a batch: when a strict enum on one field of one list element raises, the parser fails the entire response, and every well-formed field the model produced -- the title, the overview, the other list elements -- is lost with it. The user then sees a server error for content that was almost entirely usable.",
5+
"canonical_prevention": "Normalize provider-supplied enum fields on the parsed model before validation, not at the call site, so every parser target that embeds the model inherits the guard. Accept a recognized value in any casing or padding; map an unusable value to the field's own declared default, which is what an absent field already means; and where a wrong-but-meaningful answer has an obvious home in the schema, route it there instead of dropping it. Keep the strictness where it is load-bearing -- required fields and fields whose absence changes behavior still fail. The regression test replays a real provider completion through the production parser seam.",
6+
"canonical_prevention_artifact": [
7+
"backend/models/structured_extraction.py",
8+
"backend/tests/unit/test_extracted_action_item_vocabulary.py"
9+
],
10+
"evidence_prs": [
11+
11871
12+
],
13+
"scope_hints": [
14+
"backend/models/**",
15+
"backend/utils/llm/**"
16+
],
17+
"status": "open"
18+
}

backend/models/structured_extraction.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
from datetime import datetime
2-
from typing import Any, List, Literal, Optional
2+
from typing import Any, Dict, List, Literal, Optional, Tuple
33

4-
from pydantic import BaseModel, Field, field_validator
4+
from pydantic import BaseModel, Field, field_validator, model_validator
55

66
from models.conversation_enums import CategoryEnum
77
from models.structured import ActionItem, Event, Section, Structured
88

9+
CAPTURE_OWNERS: Tuple[str, ...] = ('user', 'other', 'unknown')
10+
11+
# The extractor is asked for a fixed vocabulary on these fields, but it answers outside of it often
12+
# enough to matter: on 2026-08-19 it returned a speaker's name for capture_owner on three items of
13+
# one conversation, pydantic raised literal_error, and the whole StructuredExtraction failed to
14+
# parse — so conversation processing returned HTTP 500 and the user got no summary at all. One
15+
# out-of-vocabulary token must never cost the conversation; an unusable value is worth exactly as
16+
# much as the field being absent, which is what these Optionals already model.
17+
_OPTIONAL_LITERAL_VOCABULARIES: Dict[str, Tuple[str, ...]] = {
18+
'capture_kind': ('explicit_command', 'clear_commitment', 'direct_request', 'inferred_next_step'),
19+
'capture_owner': CAPTURE_OWNERS,
20+
'due_certainty': ('confirmed', 'tentative'),
21+
'candidate_action': ('create', 'update', 'complete'),
22+
}
23+
924

1025
class ExtractedActionItem(BaseModel):
1126
description: str = Field(description="The action item to be completed")
@@ -30,6 +45,31 @@ class ExtractedActionItem(BaseModel):
3045
description='Transcript segment IDs that directly support this action item',
3146
)
3247

48+
@model_validator(mode='before')
49+
@classmethod
50+
def drop_out_of_vocabulary_literals(cls, data: Any) -> Any:
51+
if not isinstance(data, dict):
52+
return data
53+
54+
coerced = dict(data)
55+
for field, vocabulary in _OPTIONAL_LITERAL_VOCABULARIES.items():
56+
value = coerced.get(field)
57+
if value is None or value in vocabulary:
58+
continue
59+
normalized = value.strip().lower() if isinstance(value, str) else None
60+
if normalized in vocabulary:
61+
coerced[field] = normalized
62+
continue
63+
# A name where an owner class belongs still says the owner is somebody other than the
64+
# user, and owner_name is where that name is meant to live.
65+
if field == 'capture_owner' and normalized:
66+
coerced[field] = 'other'
67+
if not coerced.get('owner_name'):
68+
coerced['owner_name'] = value.strip()
69+
continue
70+
coerced[field] = None
71+
return coerced
72+
3373
def to_action_item(self) -> ActionItem:
3474
return ActionItem(
3575
description=self.description,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""The extractor's out-of-vocabulary literals must not abort a whole conversation extraction.
2+
3+
Prod, 2026-08-19: the structure model returned a speaker's name ("Archit") for `capture_owner` on
4+
three action items of one conversation. Pydantic raised literal_error for each, PydanticOutputParser
5+
turned that into OutputParserException, and `_get_structured` mapped it to HTTP 500 — the user's
6+
conversation never got a summary because of an enum token. The completion below is that prod
7+
payload, trimmed to the three offending items plus one good one.
8+
"""
9+
10+
import json
11+
12+
import pytest
13+
from langchain_core.output_parsers import PydanticOutputParser
14+
15+
from models.structured_extraction import ActionItemsExtraction, ExtractedActionItem, StructuredExtraction
16+
17+
PROD_COMPLETION = json.dumps(
18+
{
19+
"title": "Archit Defines Omi's Agent Context Strategy",
20+
"overview": "Archit and the team discuss Omi's hardware, Mac app, and strategic direction.",
21+
"emoji": "🧠",
22+
"category": "technology",
23+
"action_items": [
24+
{
25+
"description": "Collect a couple of weeks of performance data for the launch.",
26+
"due_at": None,
27+
"capture_kind": "clear_commitment",
28+
"capture_confidence": 0.9,
29+
"ownership_confidence": 0.9,
30+
"capture_owner": "Archit",
31+
"concrete_deliverable": True,
32+
"candidate_action": "complete",
33+
"target_task_id": None,
34+
"source_segment_ids": [],
35+
},
36+
{
37+
"description": "Download and personally test the Omi macOS app.",
38+
"capture_kind": "direct_request",
39+
"capture_owner": "user",
40+
"candidate_action": "complete",
41+
"source_segment_ids": [],
42+
},
43+
{
44+
"description": "Submit the collaboration form discussed during the evaluation.",
45+
"capture_kind": "clear_commitment",
46+
"capture_owner": "Archit",
47+
"candidate_action": "complete",
48+
"source_segment_ids": [],
49+
},
50+
{
51+
"description": "Give feedback on the Omi product after trying the device.",
52+
"capture_kind": "clear_commitment",
53+
"capture_owner": "Archit",
54+
"candidate_action": "complete",
55+
"source_segment_ids": [],
56+
},
57+
],
58+
"events": [],
59+
}
60+
)
61+
62+
63+
def test_prod_completion_with_named_capture_owner_parses():
64+
"""The exact prod payload, through the exact production parser seam."""
65+
extraction = PydanticOutputParser(pydantic_object=StructuredExtraction).parse(PROD_COMPLETION)
66+
67+
assert extraction.title == "Archit Defines Omi's Agent Context Strategy"
68+
assert [item.capture_owner for item in extraction.action_items] == ['other', 'user', 'other', 'other']
69+
assert len(extraction.to_structured().action_items) == 4
70+
71+
72+
def test_named_capture_owner_is_kept_as_owner_name():
73+
item = ExtractedActionItem.model_validate({'description': 'Send the address', 'capture_owner': 'Archit'})
74+
75+
assert item.capture_owner == 'other'
76+
assert item.owner_name == 'Archit'
77+
assert item.to_action_item().owner_name == 'Archit'
78+
79+
80+
def test_named_capture_owner_does_not_overwrite_an_explicit_owner_name():
81+
item = ExtractedActionItem.model_validate(
82+
{'description': 'Send the address', 'capture_owner': 'Archit', 'owner_name': 'Archit Sharma'}
83+
)
84+
85+
assert item.capture_owner == 'other'
86+
assert item.owner_name == 'Archit Sharma'
87+
88+
89+
@pytest.mark.parametrize('value', ['User', ' other ', 'UNKNOWN'])
90+
def test_vocabulary_values_survive_casing_and_padding(value):
91+
item = ExtractedActionItem.model_validate({'description': 'Ship it', 'capture_owner': value})
92+
93+
assert item.capture_owner == value.strip().lower()
94+
95+
96+
@pytest.mark.parametrize('field', ['capture_kind', 'due_certainty', 'candidate_action'])
97+
def test_unusable_optional_literal_is_dropped_rather_than_failing_the_item(field):
98+
item = ExtractedActionItem.model_validate({'description': 'Ship it', field: 'something_the_model_invented'})
99+
100+
assert getattr(item, field) is None
101+
assert item.description == 'Ship it'
102+
103+
104+
def test_action_items_extraction_survives_the_same_payload():
105+
"""The action-item-only parser shares the model, so it shares the guard."""
106+
completion = json.dumps({'action_items': [{'description': 'Send the deck', 'capture_owner': 'Archit'}]})
107+
108+
extraction = PydanticOutputParser(pydantic_object=ActionItemsExtraction).parse(completion)
109+
110+
assert extraction.to_action_items()[0].capture_owner == 'other'

0 commit comments

Comments
 (0)