"""作业 4 验收测试:LLM 接入采集节点。 分组: - 接口与工具(提示词加载、FakeLLM 记录、前缀解析、固定时钟): 现在就应该通过; - 关键代码(run_collect / validate_candidate / validate_accident_time): 实现前会失败,属预期状态。 """ from datetime import datetime import pytest from src.agent.clock import FixedClock, validate_accident_time from src.agent.collect import ( CollectReplyError, parse_state_reply, run_collect, ) from src.agent.fields import FieldCandidate, FieldStatus, validate_candidate from src.agent.graph import build_graph from src.agent.llm import FakeLLMService from src.agent.prompts import COLLECTION, load_prompt def collect_state(**overrides) -> dict: state = { "session_id": "s1", # 使用完整句而非短促词,避免触发分流守卫(“你好”<=2 字) "input_text": "发生了交通事故,两辆车追尾", "stage_code": "1002", "turn_count": 1, "no_reply_count": 0, "clarify_count": 0, "accident_fields": {}, "turn_patch": {}, "client_event": "", } state.update(overrides) return state # ---------- 接口与工具(现在即应通过) ---------- def test_prompt_file_has_source_header(): text = load_prompt(COLLECTION) assert "事故信息采集2025118.json" in text assert "nodeId" in text assert "原文开始" in text @pytest.mark.asyncio async def test_fake_llm_records_calls(): llm = FakeLLMService(lambda s, u: f"1002{u}") out = await llm.complete("sys", "你好") assert out == "1002你好" assert len(llm.calls) == 1 assert llm.calls[0]["user_message"] == "你好" def test_parse_state_reply_valid(): code, body = parse_state_reply("1002请问有几辆车?") assert code == "1002" assert body == "请问有几辆车?" def test_parse_state_reply_missing_prefix_raises(): with pytest.raises(ValueError): parse_state_reply("没有前缀的回复") def test_fixed_clock(): fixed = datetime(2026, 10, 19, 12, 0, 0) assert FixedClock(fixed).now() == fixed # ---------- 关键代码(实现前失败,属预期) ---------- @pytest.mark.asyncio async def test_collect_single_llm_call(): # 计划作业 4-3:一次轮次一次模型调用,同时产出前缀与正文 llm = FakeLLMService( lambda s, u: "1002请问有几辆汽车卷入了这次事故呢?" ) result = await run_collect(collect_state(), llm) # 对话调用 + 独立的字段提取调用(计划作业 4-4) assert len(llm.calls) == 2 assert result["response_text"].startswith("1002") assert result["turn_count"] == 2 @pytest.mark.asyncio async def test_collect_routes_to_photo_when_llm_says_so(): # LLM 提议 2000(白名单合法):写状态,下一轮路由到 single_photo llm = FakeLLMService( lambda s, u: "2000好的,信息已记录。接下来请对准车辆前方拍摄。" ) result = await run_collect(collect_state(), llm) assert result["stage_code"] == "2000" assert len(llm.calls) == 2 # 对话 + 提取 @pytest.mark.asyncio async def test_collect_missing_prefix_repaired_once(): # 第一次回复缺前缀 → 受控修复重试一次(带修复指令),第二次合法 replies = iter(["没有前缀的回复", "1002请问您现在还在事故现场吗?"]) def responder(s, u): # 修复调用应携带修复指令(对前缀的要求) if len(llm.calls) == 1: assert "" in u or "state" in s return next(replies) llm = FakeLLMService(responder) result = await run_collect(collect_state(), llm) assert len(llm.calls) == 3 # 对话 + 修复 + 提取 assert result["response_text"].startswith("1002") @pytest.mark.asyncio async def test_collect_missing_prefix_unrepaired_is_recognizable_error(): # 修复后仍缺前缀:抛 CollectReplyError,不提交业务状态, # 且调用次数 = 原调用 + 1 次修复(不得无限重试) llm = FakeLLMService(lambda s, u: "还是没有前缀") with pytest.raises(CollectReplyError): await run_collect(collect_state(), llm) assert len(llm.calls) == 2 @pytest.mark.asyncio async def test_collect_illegal_stage_proposal_not_committed(): # LLM 提议非法阶段码(9999 未登记 / 非法):不得写状态, # 修复一次后仍非法 → 保持当前阶段并给出可识别错误 llm = FakeLLMService(lambda s, u: "9999正文") with pytest.raises(CollectReplyError): await run_collect(collect_state(), llm) assert len(llm.calls) == 2 @pytest.mark.asyncio async def test_graph_collect_node_with_llm(): # 图级:首轮走 welcome(确定性,不调模型);第二轮起 collect # 节点经 Runtime 注入的 llm 走 run_collect from src.agent.graph import GraphDeps llm = FakeLLMService(lambda s, u: "1002收到。") graph = build_graph() first = await graph.ainvoke( {"session_id": "s1", "input_text": "追尾了"}, config={"configurable": {"thread_id": "s1"}}, context=GraphDeps(llm=llm), ) assert len(llm.calls) == 0 # welcome 是确定性节点 assert first["stage_code"] == "1001" result = await graph.ainvoke( {"session_id": "s1", "input_text": "继续办理"}, config={"configurable": {"thread_id": "s1"}}, context=GraphDeps(llm=llm), ) assert len(llm.calls) == 2 # collect 对话 + 提取 assert result["response_text"].startswith("1002") assert result["stage_code"] == "1002" # ---------- 字段候选与时间校验(实现前失败,属预期) ---------- def test_validate_candidate_unknown_key_rejected(): # 计划作业 4-4/6-7:未知 key 不得写入正式字段 with pytest.raises(ValueError): validate_candidate(FieldCandidate(key="not_a_field", value=1)) def test_validate_candidate_future_time_rejected(): clock = FixedClock(datetime(2026, 10, 19, 12, 0, 0)) result = validate_candidate( FieldCandidate(key="sgfssj", value="明天早上八点"), clock=clock ) assert result.status != FieldStatus.CONFIRMED def test_validate_candidate_count_not_inferred_from_description(): # 计划作业 4-8:数量未知不得从“追尾”等词推断 result = validate_candidate(FieldCandidate(key="jdcsl", value="追尾")) assert result.status != FieldStatus.CONFIRMED def test_validate_accident_time_parses_absolute_and_relative(): now = datetime(2026, 10, 19, 12, 0, 0) assert validate_accident_time("11点45分", now) is True assert validate_accident_time("刚才", now) is True assert validate_accident_time("明天早上八点", now) is False assert validate_accident_time("乱七八糟", now) is False