2026-10-20 14:53:46 +08:00
|
|
|
"""作业 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",
|
2026-10-21 15:11:16 +08:00
|
|
|
# 使用完整句而非短促词,避免触发分流守卫(“你好”<=2 字)
|
|
|
|
|
"input_text": "发生了交通事故,两辆车追尾",
|
2026-10-20 14:53:46 +08:00
|
|
|
"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"<state>1002</state>{u}")
|
|
|
|
|
out = await llm.complete("sys", "你好")
|
|
|
|
|
assert out == "<state>1002</state>你好"
|
|
|
|
|
assert len(llm.calls) == 1
|
|
|
|
|
assert llm.calls[0]["user_message"] == "你好"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_state_reply_valid():
|
|
|
|
|
code, body = parse_state_reply("<state>1002</state>请问有几辆车?")
|
|
|
|
|
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: "<state>1002</state>请问有几辆汽车卷入了这次事故呢?"
|
|
|
|
|
)
|
|
|
|
|
result = await run_collect(collect_state(), llm)
|
2026-10-21 17:10:33 +08:00
|
|
|
# 对话调用 + 独立的字段提取调用(计划作业 4-4)
|
|
|
|
|
assert len(llm.calls) == 2
|
2026-10-20 14:53:46 +08:00
|
|
|
assert result["response_text"].startswith("<state>1002</state>")
|
|
|
|
|
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: "<state>2000</state>好的,信息已记录。接下来请对准车辆前方拍摄。"
|
|
|
|
|
)
|
|
|
|
|
result = await run_collect(collect_state(), llm)
|
|
|
|
|
assert result["stage_code"] == "2000"
|
2026-10-21 17:10:33 +08:00
|
|
|
assert len(llm.calls) == 2 # 对话 + 提取
|
2026-10-20 14:53:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_collect_missing_prefix_repaired_once():
|
|
|
|
|
# 第一次回复缺前缀 → 受控修复重试一次(带修复指令),第二次合法
|
|
|
|
|
replies = iter(["没有前缀的回复", "<state>1002</state>请问您现在还在事故现场吗?"])
|
|
|
|
|
|
|
|
|
|
def responder(s, u):
|
|
|
|
|
# 修复调用应携带修复指令(对前缀的要求)
|
|
|
|
|
if len(llm.calls) == 1:
|
|
|
|
|
assert "<state>" in u or "state" in s
|
|
|
|
|
return next(replies)
|
|
|
|
|
|
|
|
|
|
llm = FakeLLMService(responder)
|
|
|
|
|
result = await run_collect(collect_state(), llm)
|
2026-10-21 17:10:33 +08:00
|
|
|
assert len(llm.calls) == 3 # 对话 + 修复 + 提取
|
2026-10-20 14:53:46 +08:00
|
|
|
assert result["response_text"].startswith("<state>1002</state>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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: "<state>9999</state>正文")
|
|
|
|
|
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():
|
2026-10-21 15:11:16 +08:00
|
|
|
# 图级:首轮走 welcome(确定性,不调模型);第二轮起 collect
|
|
|
|
|
# 节点经 Runtime 注入的 llm 走 run_collect
|
|
|
|
|
from src.agent.graph import GraphDeps
|
|
|
|
|
|
2026-10-20 14:53:46 +08:00
|
|
|
llm = FakeLLMService(lambda s, u: "<state>1002</state>收到。")
|
2026-10-21 15:11:16 +08:00
|
|
|
graph = build_graph()
|
2026-10-20 14:53:46 +08:00
|
|
|
first = await graph.ainvoke(
|
|
|
|
|
{"session_id": "s1", "input_text": "追尾了"},
|
|
|
|
|
config={"configurable": {"thread_id": "s1"}},
|
2026-10-21 15:11:16 +08:00
|
|
|
context=GraphDeps(llm=llm),
|
2026-10-20 14:53:46 +08:00
|
|
|
)
|
|
|
|
|
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"}},
|
2026-10-21 15:11:16 +08:00
|
|
|
context=GraphDeps(llm=llm),
|
2026-10-20 14:53:46 +08:00
|
|
|
)
|
2026-10-21 17:10:33 +08:00
|
|
|
assert len(llm.calls) == 2 # collect 对话 + 提取
|
2026-10-20 14:53:46 +08:00
|
|
|
assert result["response_text"].startswith("<state>1002</state>")
|
|
|
|
|
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
|