"""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 = '1002好的,我记录的时间是2026年10月22日1点10分,请问这个时间对吗?'
ASK = '1002请问事故大概是什么时候发生的?'
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':'1002有没有人员受伤?'},
]}
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 '1002请问有没有人员受伤?'
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, '2010信息已齐,请拍照。',
'1002事故日期是2026年10月22日,对吗?',
'1002我记录的事故日期是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'{stage}立即为您转接人工。')
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']