"""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']