150 lines
7.1 KiB
Python
150 lines
7.1 KiB
Python
"""Regression tests for relative-time provenance across collection turns."""
|
|||
|
|
from datetime import datetime
|
||
|
|
import json
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from src.agent.collect import CollectReplyError, run_collect
|
||
|
|
from src.agent.graph import GraphDeps, build_graph
|
||
|
|
from src.agent.llm import FakeLLMService
|
||
|
|
from src.agent.time_context import time_evidence
|
||
|
|
|
||
|
|
TZ = ZoneInfo('Asia/Shanghai')
|
||
|
|
T0 = datetime(2026, 10, 22, 0, 5, tzinfo=TZ)
|
||
|
|
T1 = datetime(2026, 10, 22, 1, 20, tzinfo=TZ)
|
||
|
|
RELATIVE = '十分钟前两辆汽车追尾。'
|
||
|
|
BAD = '<state>1002</state>好的,我记录的时间是2026年10月22日1点10分,请问这个时间对吗?'
|
||
|
|
ASK = '<state>1002</state>请问事故大概是什么时候发生的?'
|
||
|
|
|
||
|
|
|
||
|
|
def context(system):
|
||
|
|
return json.loads(system.rsplit('【当前会话上下文】\n', 1)[1])['time_evidence']
|
||
|
|
|
||
|
|
|
||
|
|
def legacy_state(**changes):
|
||
|
|
state = {'stage_code':'1002', 'input_text':'没有人受伤。', 'dialogue':[
|
||
|
|
{'role':'user','content':RELATIVE},
|
||
|
|
{'role':'assistant','content':'<state>1002</state>有没有人员受伤?'},
|
||
|
|
]}
|
||
|
|
state.update(changes)
|
||
|
|
return state
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_first_observation_survives_extraction_failure_and_midnight():
|
||
|
|
systems = []
|
||
|
|
def respond(system, user):
|
||
|
|
systems.append(system)
|
||
|
|
if system.startswith('【字段提取】'):
|
||
|
|
return 'invalid json' # first-turn form extraction is unavailable
|
||
|
|
return '<state>1002</state>请问有没有人员受伤?'
|
||
|
|
first = await run_collect({'stage_code':'1002','input_text':RELATIVE}, FakeLLMService(respond), now=T0)
|
||
|
|
assert first['accident_fields'] == {}
|
||
|
|
assert first['dialogue_timestamps'] == {'0':T0.isoformat()}
|
||
|
|
for system in systems:
|
||
|
|
assert context(system)['current_input_observed_at'] == T0.isoformat()
|
||
|
|
state = {**first,'input_text':'没有人受伤。'}
|
||
|
|
llm = FakeLLMService(lambda s,u: '{}' if s.startswith('【字段提取】') else ASK)
|
||
|
|
second = await run_collect(state,llm,now=T1)
|
||
|
|
for call in llm.calls:
|
||
|
|
observation = context(call['system_prompt'])['relative_time_observations'][0]
|
||
|
|
assert observation['observed_at'] == T0.isoformat()
|
||
|
|
assert observation['resolved_time'] == '2026年10月21日23点55分'
|
||
|
|
assert second['dialogue_timestamps']['0'] == T0.isoformat()
|
||
|
|
assert second['dialogue_timestamps']['2'] == T1.isoformat()
|
||
|
|
assert json.loads(json.dumps(second))['dialogue_timestamps'] == second['dialogue_timestamps']
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_clock_sidecar_survives_graph_checkpoints(monkeypatch):
|
||
|
|
import src.agent.collect as collect
|
||
|
|
times = iter([T0,T1])
|
||
|
|
monkeypatch.setattr(collect,'observation_time',lambda now=None: next(times))
|
||
|
|
llm = FakeLLMService(lambda s,u: '{}' if s.startswith('【字段提取】') else ASK)
|
||
|
|
graph = build_graph()
|
||
|
|
config = {'configurable':{'thread_id':'clock-checkpoint'}}
|
||
|
|
deps = GraphDeps(llm=llm)
|
||
|
|
await graph.ainvoke({'input_text':'新对话'}, config=config, context=deps)
|
||
|
|
await graph.ainvoke({'input_text':RELATIVE}, config=config, context=deps)
|
||
|
|
final = await graph.ainvoke({'input_text':'没有人受伤。'}, config=config, context=deps)
|
||
|
|
assert final['dialogue_timestamps']['2'] == T0.isoformat()
|
||
|
|
assert final['dialogue_timestamps']['4'] == T1.isoformat()
|
||
|
|
assert context(llm.calls[-2]['system_prompt'])['relative_time_observations'][0]['resolved_time'] == '2026年10月21日23点55分'
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_clock_evidence_remains_available_beyond_history_window():
|
||
|
|
history = [{'role':'user','content':RELATIVE}] + [
|
||
|
|
{'role':'user' if i%2 else 'assistant','content':'继续确认伤情。'} for i in range(60)
|
||
|
|
]
|
||
|
|
llm = FakeLLMService(lambda s,u: '{}' if s.startswith('【字段提取】') else ASK)
|
||
|
|
await run_collect(legacy_state(dialogue=history,dialogue_timestamps={'0':T0.isoformat()}),llm,now=T1)
|
||
|
|
assert len(llm.calls[0]['history']) == 50
|
||
|
|
assert all(m['content'] != RELATIVE for m in llm.calls[0]['history'])
|
||
|
|
assert context(llm.calls[0]['system_prompt'])['relative_time_observations'][0]['resolved_time'] == '2026年10月21日23点55分'
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_missing_reference_repairs_reply_and_rejects_only_time_field():
|
||
|
|
replies = iter([BAD,ASK,'{"accident_time":"2026年10月22日1点10分","has_injury":false}'])
|
||
|
|
llm = FakeLLMService(lambda s,u: next(replies))
|
||
|
|
state = legacy_state()
|
||
|
|
result = await run_collect(state,llm,now=T1)
|
||
|
|
assert len(llm.calls) == 3
|
||
|
|
assert result['response_text'] == ASK
|
||
|
|
assert result['accident_fields'] == {'has_injury':False}
|
||
|
|
assert result['turn_patch'] == {'has_injury':False}
|
||
|
|
assert '0' not in result['dialogue_timestamps'] # do not invent a historical clock
|
||
|
|
assert 'dialogue_timestamps' not in state
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@pytest.mark.parametrize('reply',[
|
||
|
|
BAD, '<state>2010</state>信息已齐,请拍照。',
|
||
|
|
'<state>1002</state>事故日期是2026年10月22日,对吗?',
|
||
|
|
'<state>1002</state>我记录的事故日期是2026-10-22,对吗?',
|
||
|
|
])
|
||
|
|
async def test_unsupported_time_or_photo_is_not_committed_after_failed_repair(reply):
|
||
|
|
llm = FakeLLMService(lambda s,u:reply)
|
||
|
|
state = legacy_state()
|
||
|
|
before = json.dumps(state,sort_keys=True)
|
||
|
|
with pytest.raises(CollectReplyError):
|
||
|
|
await run_collect(state,llm,now=T1)
|
||
|
|
assert len(llm.calls) == 2
|
||
|
|
assert json.dumps(state,sort_keys=True) == before
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@pytest.mark.parametrize('stage,user',[('0003','有人受伤了。'),('0001','请转人工。')])
|
||
|
|
async def test_missing_clock_does_not_block_priority_interrupts(stage,user):
|
||
|
|
llm = FakeLLMService(lambda s,u:'{}' if s.startswith('【字段提取】') else f'<state>{stage}</state>立即为您转接人工。')
|
||
|
|
result = await run_collect(legacy_state(input_text=user),llm,now=T1)
|
||
|
|
assert result['stage_code'] == stage
|
||
|
|
assert len(llm.calls) == 2
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('stamp',[None,'not a timestamp','2026-10-22T00:05:00'])
|
||
|
|
def test_missing_or_invalid_historical_clock_is_never_replaced_by_current_clock(stamp):
|
||
|
|
evidence = time_evidence(legacy_state(dialogue_timestamps={'0':stamp}),T1)
|
||
|
|
assert evidence['historical_reference_missing']
|
||
|
|
assert evidence['relative_time_observations'][0]['resolved_time'] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_current_new_time_uses_current_clock_despite_missing_old_clock():
|
||
|
|
evidence = time_evidence(legacy_state(input_text='半小时前发生的。'),T1)
|
||
|
|
assert not evidence['historical_reference_missing']
|
||
|
|
assert evidence['relative_time_observations'][-1]['resolved_time'] == '2026年10月22日00点50分'
|
||
|
|
|
||
|
|
|
||
|
|
def test_clarification_example_is_not_an_existing_time_candidate():
|
||
|
|
state = legacy_state()
|
||
|
|
state['dialogue'].append({'role':'assistant','content':'例如十一点二十分,您大概是什么时候发生的?'})
|
||
|
|
assert time_evidence(state,T1)['historical_reference_missing']
|
||
|
|
|
||
|
|
|
||
|
|
def test_explicit_candidate_can_still_be_confirmed_in_legacy_session():
|
||
|
|
state = legacy_state(input_text='对')
|
||
|
|
state['dialogue'].append({'role':'assistant','content':'我记录的时间是2026年10月21日23点55分,请问这个时间对吗?'})
|
||
|
|
assert not time_evidence(state,T1)['historical_reference_missing']
|