Skip to content

Commit 9228bb9

Browse files
committed
fix(podcasts): make the failure hints reachable and scope the not-found one (#1238)
Review follow-ups on this PR. - The hint mapper never ran for the failures it was written for. `except ValueError: raise` sits ahead of the handler that attaches hints, and both LangChain's OutputParserException and json.JSONDecodeError are ValueError subclasses - so a placeholder speaker name, `Invalid json output` and a truncated response all left untouched. That includes the pre-existing GPT-5 extended-thinking note, which has been dead for exactly the case it describes. The ValueError branch now attaches the hint and re-raises a ValueError, keeping the command layer's permanent-failure contract intact. - `Requested entity was not found` no longer claims the voice is at fault. Google returns it for any missing resource, including a model id on the outline or transcript call, so the hint now names both candidates and says which is likelier when audio had already started. The voice-specific hint stays keyed on `Voice name ... is not supported`, which does name the voice. - A solo speaker profile renders a one-entry example; the prose called it a two-entry excerpt and told the model to return more than "the two shown". Both now follow speakers|length. - The template drift check counted Jinja's `not` as a variable, so `{% if not language %}` would fail the check the moment one template negated a test the other didn't.
1 parent 4c847f9 commit 9228bb9

4 files changed

Lines changed: 123 additions & 17 deletions

File tree

commands/podcast_commands.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,24 @@ def explain_generation_failure(error_msg: str) -> Optional[str]:
5858
"since each attempt is a fresh sample."
5959
)
6060

61-
if "Requested entity was not found" in error_msg or (
62-
"Voice name" in error_msg and "not supported" in error_msg
63-
):
61+
if "Voice name" in error_msg and "not supported" in error_msg:
6462
return (
65-
"The speaker profile's voice_id is not valid for its TTS model - "
66-
"Google returns 'Requested entity was not found' for an unknown "
67-
"voice, which reads like a missing model. Check the voices in "
68-
"Settings -> Speaker Profiles against the ones your voice model "
69-
"provides (the profiles seeded on install use OpenAI voice names)."
63+
"The speaker profile's voice_id is not valid for its TTS model. "
64+
"Check the voices in Settings -> Speaker Profiles against the ones "
65+
"your voice model provides (the profiles seeded on install use "
66+
"OpenAI voice names)."
67+
)
68+
69+
if "Requested entity was not found" in error_msg:
70+
return (
71+
"Google returns this for any resource it cannot find, without "
72+
"naming which one. Two candidates, likeliest first: a speaker "
73+
"profile voice_id that its TTS model doesn't provide (the profiles "
74+
"seeded on install use OpenAI voice names, which Gemini voice "
75+
"models reject with exactly this message), or a model id in the "
76+
"episode profile that doesn't exist for its provider. If the "
77+
"transcript finished and the failure came during audio, it is the "
78+
"voice."
7079
)
7180

7281
if "Invalid json output" in error_msg or "Expecting value" in error_msg:
@@ -400,8 +409,19 @@ async def generate_podcast_command(
400409
processing_time=processing_time,
401410
)
402411

403-
except ValueError:
404-
raise
412+
except ValueError as e:
413+
# ValueError is the command layer's "permanent failure, do not retry"
414+
# signal (retry config uses stop_on=[ValueError]), so the type has to
415+
# survive - but LangChain's OutputParserException and
416+
# json.JSONDecodeError are ValueError subclasses too. Every parser
417+
# failure therefore left through here, past the hint mapper below:
418+
# the placeholder speaker name and the truncated-JSON cases reached
419+
# the user with no guidance at all (#1238).
420+
hint = explain_generation_failure(str(e))
421+
if not hint:
422+
raise
423+
logger.error(f"Podcast generation failed: {e}")
424+
raise ValueError(f"{e}\n\nNOTE: {hint}") from e
405425

406426
except Exception as e:
407427
logger.error(f"Podcast generation failed: {e}")

prompts/podcast/transcript.jinja

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Follow these format requirements strictly:
8080

8181
Return exactly one JSON object with a single root key "transcript" whose value is a list of at least {{ turns }} entries. Each entry has exactly two keys, "speaker" and "dialogue", both strings.
8282
{% if not language %}
83-
The line below is a two-entry EXCERPT showing that structure with sample dialogue - keep the structure, write your own words for this segment, and return at least {{ turns }} entries rather than the two shown:
83+
The line below is a {% if speakers|length == 1 %}one-entry{% else %}two-entry{% endif %} EXCERPT showing that structure with sample dialogue - keep the structure, write your own words for this segment, and return at least {{ turns }} entries rather than the {% if speakers|length == 1 %}one{% else %}two{% endif %} shown:
8484

8585
{% if speakers|length == 1 %}
8686
{"transcript": [{"speaker": {{ speaker_names[0]|tojson }}, "dialogue": "Let's pick up where we left off, because this is where the material really starts to come together."}]}

tests/test_podcast_error_hints.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@
66
provider - a truncated Gemini response was told to switch to gpt-4o.
77
"""
88

9+
from unittest.mock import AsyncMock, patch
10+
911
import pytest
12+
from langchain_core.exceptions import OutputParserException
1013

11-
from commands.podcast_commands import explain_generation_failure
14+
from commands.podcast_commands import (
15+
PodcastGenerationInput,
16+
explain_generation_failure,
17+
generate_podcast_command,
18+
)
1219

1320

1421
class TestExplainGenerationFailure:
@@ -24,21 +31,26 @@ def test_placeholder_speaker_name_is_explained(self):
2431
assert "speaker" in hint
2532
assert "gpt-4o" not in hint
2633

27-
def test_google_bad_voice_points_at_the_speaker_profile(self):
34+
def test_generic_not_found_names_both_candidates(self):
35+
"""Google returns this for any missing resource, so the hint must not
36+
send someone to the voice settings when the real fault is a model id
37+
on the outline or transcript call."""
2838
hint = explain_generation_failure(
2939
"Google API error: Requested entity was not found."
3040
)
3141
assert hint is not None
3242
assert "voice_id" in hint
33-
assert "Speaker Profiles" in hint
43+
assert "model id" in hint
3444

3545
def test_unsupported_voice_name_points_at_the_speaker_profile(self):
46+
"""This message names the voice, so the hint can be specific."""
3647
hint = explain_generation_failure(
3748
"Google API error: Voice name echo is not supported. Allowed voice "
3849
"names are: achernar, achird, algenib"
3950
)
4051
assert hint is not None
4152
assert "voice_id" in hint
53+
assert "Speaker Profiles" in hint
4254

4355
@pytest.mark.parametrize(
4456
"message",
@@ -55,3 +67,57 @@ def test_unparseable_output_mentions_truncation_and_thinking(self, message):
5567

5668
def test_unrecognised_failure_gets_no_hint(self):
5769
assert explain_generation_failure("Connection reset by peer") is None
70+
71+
72+
class TestHintsReachTheUser:
73+
"""The mapper is only useful if the failure it describes passes through it.
74+
75+
`except ValueError: raise` guards the command layer's permanent-failure
76+
contract, and LangChain's OutputParserException plus json.JSONDecodeError
77+
are both ValueError subclasses - so every parser failure, which is exactly
78+
what these hints are for, used to skip the mapper entirely.
79+
"""
80+
81+
@staticmethod
82+
def make_input():
83+
return PodcastGenerationInput(
84+
episode_profile="Test Episode Profile",
85+
episode_name="Test Episode",
86+
content="Some content",
87+
)
88+
89+
@pytest.mark.asyncio
90+
async def test_parser_failure_keeps_its_type_and_gains_the_hint(self):
91+
parse_error = OutputParserException(
92+
"Failed to parse ValidatedTranscript from completion "
93+
'{"transcript": [{"speaker": "...", "dialogue": "..."}]}. Got: '
94+
"Value error, Invalid speaker name '...'."
95+
)
96+
assert isinstance(parse_error, ValueError)
97+
98+
with patch(
99+
"commands.podcast_commands.EpisodeProfile.get_by_name",
100+
AsyncMock(side_effect=parse_error),
101+
):
102+
with pytest.raises(ValueError) as exc_info:
103+
await generate_podcast_command(self.make_input())
104+
105+
message = str(exc_info.value)
106+
assert "Invalid speaker name" in message
107+
assert "NOTE:" in message
108+
assert "Speaker names must match the profile exactly" in message
109+
# Still a ValueError, so the command layer keeps treating it as
110+
# permanent rather than retrying a failure that will repeat.
111+
assert not isinstance(exc_info.value, RuntimeError)
112+
113+
@pytest.mark.asyncio
114+
async def test_unrecognised_value_error_is_re_raised_untouched(self):
115+
with patch(
116+
"commands.podcast_commands.EpisodeProfile.get_by_name",
117+
AsyncMock(side_effect=ValueError("Episode profile 'x' not found")),
118+
):
119+
with pytest.raises(ValueError) as exc_info:
120+
await generate_podcast_command(self.make_input())
121+
122+
assert str(exc_info.value) == "Episode profile 'x' not found"
123+
assert "NOTE:" not in str(exc_info.value)

tests/test_podcast_prompt_templates.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ def test_transcript_example_points_at_the_turn_minimum(self):
166166
assert "two-entry EXCERPT" in rendered
167167
assert "return at least 6 entries rather than the two shown" in rendered
168168

169+
def test_solo_excerpt_is_not_called_a_two_entry_one(self):
170+
"""A solo profile renders one entry; calling it a two-entry excerpt
171+
contradicts the example directly above it."""
172+
rendered = render_transcript(speakers=[SPEAKERS[0]], turns=3)
173+
assert "one-entry EXCERPT" in rendered
174+
assert "two-entry" not in rendered
175+
assert "rather than the one shown" in rendered
176+
169177
def test_outline_example_points_at_the_segment_count(self):
170178
rendered = render_outline(num_segments=6)
171179
assert "two-entry EXCERPT" in rendered
@@ -286,15 +294,27 @@ class TestNoDriftFromBundledTemplates:
286294
invisible here. The language block was lost exactly this way. Fail when a
287295
variable the bundled template uses is missing from the app's copy."""
288296

289-
@staticmethod
290-
def _variables(path: Path) -> set:
297+
# Operators and literals a condition can open with: `{% if not language %}`
298+
# names no variable called "not".
299+
JINJA_KEYWORDS = frozenset(
300+
{"not", "and", "or", "is", "in", "if", "else", "true", "false", "none"}
301+
)
302+
303+
@classmethod
304+
def _variables(cls, path: Path) -> set:
291305
text = path.read_text()
292306
used = set(re.findall(r"\{\{-?\s*([a-zA-Z_][a-zA-Z0-9_]*)", text))
293307
used |= set(
294308
re.findall(r"\{%-?\s*(?:if|elif)\s+([a-zA-Z_][a-zA-Z0-9_]*)", text)
295309
)
296310
loop_locals = set(re.findall(r"\{%-?\s*for\s+([a-zA-Z_][a-zA-Z0-9_]*)", text))
297-
return used - loop_locals
311+
return used - loop_locals - cls.JINJA_KEYWORDS
312+
313+
def test_jinja_keywords_are_not_treated_as_variables(self):
314+
"""`{% if not language %}` names no variable called "not" - counting it
315+
would fail the drift check the moment one template negates a test the
316+
other doesn't."""
317+
assert "not" not in self._variables(PROMPTS_DIR / "transcript.jinja")
298318

299319
@pytest.mark.parametrize("template", ["transcript", "outline"])
300320
def test_app_template_uses_every_bundled_variable(self, template):

0 commit comments

Comments
 (0)