Skip to content

Commit 06ebf51

Browse files
committed
Merge branch 'feature/issue-309-openai-structured-schema-blocks'
fix(#309): OpenAI strict schema for email insight blocks (distinct anyOf first keys, test, richer API error logs).
2 parents 233cee5 + 32f55a2 commit 06ebf51

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

coaching/src/infrastructure/llm/openai_provider.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,14 @@ async def generate(
289289
)
290290

291291
except Exception as e:
292-
logger.error("OpenAI Responses API call failed", error=str(e), model=model)
292+
err_body = getattr(e, "body", None)
293+
logger.error(
294+
"OpenAI Responses API call failed",
295+
error=str(e),
296+
model=model,
297+
has_response_schema=response_schema is not None,
298+
error_body=err_body,
299+
)
293300
raise RuntimeError(f"OpenAI API call failed: {e}") from e
294301

295302
async def generate_stream(

coaching/src/models/responses.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,36 +263,40 @@ class EmailInsightParagraphBlock(BaseModel):
263263

264264
model_config = ConfigDict(extra="forbid")
265265

266-
type: Literal["paragraph"] = "paragraph"
266+
# Field order matters for OpenAI strict structured outputs: each branch of the
267+
# blocks union is sent as anyOf; the API rejects anyOf object variants whose
268+
# first property key is identical ("Objects provided via 'anyOf' must not
269+
# share identical first keys"). Discriminator stays ``type`` but is not first.
267270
text: str = Field(min_length=1, max_length=600, description="Paragraph content")
271+
type: Literal["paragraph"] = "paragraph"
268272

269273

270274
class EmailInsightListBlock(BaseModel):
271275
"""List block in email insight payload."""
272276

273277
model_config = ConfigDict(extra="forbid")
274278

275-
type: Literal["list"] = "list"
276279
items: list[str] = Field(
277280
min_length=1,
278281
max_length=6,
279282
description="List items for actionable guidance",
280283
)
284+
type: Literal["list"] = "list"
281285

282286

283287
class EmailInsightCtaBlock(BaseModel):
284288
"""CTA block in email insight payload."""
285289

286290
model_config = ConfigDict(extra="forbid")
287291

288-
type: Literal["cta"] = "cta"
289292
label: str = Field(min_length=1, max_length=80, description="CTA label text")
290293
action: str = Field(min_length=1, max_length=120, description="CTA action identifier")
291294
url: str | None = Field(
292295
default=None,
293296
max_length=500,
294297
description="Optional URI link; https-only enforcement is handled by backend runtime policy",
295298
)
299+
type: Literal["cta"] = "cta"
296300

297301

298302
EmailInsightBlock = Annotated[

coaching/tests/unit/application/ai_engine/test_llm_json_schema_adaptation.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,41 @@ def test_unified_engine_adapt_routes_by_provider() -> None:
5757

5858
def test_adapt_none_returns_none() -> None:
5959
assert UnifiedAIEngine._adapt_structured_schema_for_llm_provider(None, "openai") is None
60+
61+
62+
def _first_property_key_of_object_schema(obj: dict) -> str | None:
63+
props = obj.get("properties")
64+
if not isinstance(props, dict) or not props:
65+
return None
66+
return next(iter(props.keys()))
67+
68+
69+
def test_openai_blocks_anyof_branches_have_distinct_first_keys() -> None:
70+
"""OpenAI rejects anyOf when each object branch shares the same first key."""
71+
full = EmailInsightResponse.model_json_schema(by_alias=True)
72+
prepared = _prepare_like_engine(full, "EmailInsightResponse")
73+
adapted = adapt_json_schema_for_openai_structured_output(prepared)
74+
75+
blocks = adapted.get("properties", {}).get("blocks")
76+
assert isinstance(blocks, dict)
77+
items = blocks.get("items")
78+
assert isinstance(items, dict)
79+
variants = items.get("anyOf") or items.get("oneOf")
80+
assert isinstance(variants, list) and len(variants) == 3
81+
82+
first_keys: list[str] = []
83+
for branch in variants:
84+
assert isinstance(branch, dict)
85+
if "$ref" in branch:
86+
ref = branch["$ref"]
87+
assert ref.startswith("#/$defs/")
88+
def_name = ref.removeprefix("#/$defs/")
89+
defn = adapted.get("$defs", {}).get(def_name)
90+
assert isinstance(defn, dict)
91+
key = _first_property_key_of_object_schema(defn)
92+
else:
93+
key = _first_property_key_of_object_schema(branch)
94+
assert key is not None
95+
first_keys.append(key)
96+
97+
assert len(set(first_keys)) == len(first_keys), f"duplicate first keys: {first_keys}"

0 commit comments

Comments
 (0)