49be3cf4b9
The turn endpoint was 500ing in multi-bot scenes whenever the classifier provider hiccuped on parse_turn — particularly visible after a guest was added and bots started exchanging turns. The traceback was 'classify failed for schema ParsedTurn with no default' because parse_turn was the only classify caller without a default. Two changes: - chat/services/turn_parse.py: parse_turn now passes a default that wraps the whole prose as one 'dialogue' segment. The narrative still fires on the prose; we lose finer-grained segment kinds (action vs dialogue vs ooc) on this turn, but the request returns cleanly. Updated the existing test that pinned the old RuntimeError contract. - chat/llm/classify.py: when retries are exhausted, log a WARNING with the schema name, last error type, and a snippet of the last raw text the model returned. Surfaces flapping classifiers in the uvicorn log for diagnosis without taking down the request. Suite: 471 passed in 11.7s.
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from chat.llm.mock import MockLLMClient
|
|
from chat.services.turn_parse import (
|
|
ParsedTurn,
|
|
TurnSegment,
|
|
parse_turn,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_turn_three_segment_happy_path():
|
|
canned = json.dumps(
|
|
{
|
|
"segments": [
|
|
{"kind": "action", "text": "walks over"},
|
|
{"kind": "dialogue", "text": "Hey."},
|
|
{"kind": "ooc", "text": "player note"},
|
|
]
|
|
}
|
|
)
|
|
mock = MockLLMClient(canned=[canned])
|
|
result = await parse_turn(
|
|
mock,
|
|
model="m",
|
|
prose='*walks over* "Hey." ((player note))',
|
|
)
|
|
assert isinstance(result, ParsedTurn)
|
|
assert len(result.segments) == 3
|
|
kinds = [s.kind for s in result.segments]
|
|
assert kinds == ["action", "dialogue", "ooc"]
|
|
texts = [s.text for s in result.segments]
|
|
assert texts == ["walks over", "Hey.", "player note"]
|
|
assert all(isinstance(s, TurnSegment) for s in result.segments)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_turn_pure_dialogue_single_segment():
|
|
canned = json.dumps(
|
|
{
|
|
"segments": [
|
|
{"kind": "dialogue", "text": "Hello there"},
|
|
]
|
|
}
|
|
)
|
|
mock = MockLLMClient(canned=[canned])
|
|
result = await parse_turn(
|
|
mock,
|
|
model="m",
|
|
prose='"Hello there"',
|
|
)
|
|
assert isinstance(result, ParsedTurn)
|
|
assert len(result.segments) == 1
|
|
assert result.segments[0].kind == "dialogue"
|
|
assert result.segments[0].text == "Hello there"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_turn_empty_prose_short_circuits_without_classifier_call():
|
|
# No canned responses provided — if classify() is invoked it will raise
|
|
# IndexError on the empty list. The short-circuit must prevent that.
|
|
mock = MockLLMClient(canned=[])
|
|
result = await parse_turn(mock, model="m", prose="")
|
|
assert isinstance(result, ParsedTurn)
|
|
assert result.segments == []
|
|
|
|
# Whitespace-only prose must also short-circuit.
|
|
result_ws = await parse_turn(mock, model="m", prose=" \n\t ")
|
|
assert isinstance(result_ws, ParsedTurn)
|
|
assert result_ws.segments == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_turn_falls_back_to_whole_prose_when_classifier_fails():
|
|
"""A flapping classifier (3 invalid responses) no longer 500s the
|
|
request. ``parse_turn`` returns the original prose as a single
|
|
``dialogue`` segment so the turn flow can keep moving — the
|
|
narrative will still fire on the prose, just without finer-grained
|
|
segment classification.
|
|
|
|
The old contract was ``RuntimeError`` (no default), but in
|
|
production that took down the whole turn endpoint with a 500 the
|
|
moment any classifier provider hiccuped — particularly painful in
|
|
multi-bot scenes where every user turn pays the parse_turn cost.
|
|
"""
|
|
mock = MockLLMClient(canned=["nope", "still nope", "nope3"])
|
|
result = await parse_turn(
|
|
mock,
|
|
model="m",
|
|
prose='*shrugs* "whatever"',
|
|
)
|
|
assert len(result.segments) == 1
|
|
assert result.segments[0].kind == "dialogue"
|
|
assert result.segments[0].text == '*shrugs* "whatever"'
|
|
assert result.intent == "narrative"
|