feat(agent): LLM-powered collection node with welcome stage and session-scoped delete (assignment 4)

- prompts/collection.md: versioned collection system prompt extracted
  from workflow node yQaBxfrWjbRr with source/version/porting notes
- llm.py: LLMService protocol; FakeLLMService (scripted, records
  calls); OpenAILLMService (async, wait_for timeout with cancellation,
  retry only on timeout/429/connection, errors normalized)
- collect.py: run_collect reference impl - one LLM call produces
  state prefix + body, controlled repair (max 1 retry) for missing
  prefix or illegal stage proposal, whitelist guard, CollectReplyError
  without raw model content; dialogue history + confirmed fields
  injected into prompt so the model skips known info (作业4-5)
- fields.py / clock.py: three-state field candidates (unknown/
  candidate/confirmed), per-type validation (count not inferred from
  description, future time rejected), injectable Clock
- graph.py: welcome node - first message returns fixed 1001 greeting
  without LLM (mirrors workflow is_first_message -> Assigned Reply#3),
  route empty stage to welcome
- endpoints/delete_session: backend switch - langgraph clears graph
  checkpoint, fastgpt path unchanged
- config: AGENT_LLM_PROVIDER fake|openai with validation
- untrack .env (secrets), move template to root .env.example

Verified:
- pytest 103 passed; smoke turns 1/2/1
- Real LLM e2e: POST /chat multi-turn context works
- Golden eval collection_skip_known_info: 12/12 cases pass
- Golden eval no_false: 8/10 cases pass; 4 confirm/photo cases fail
  at final step - model routes on short reply '对' (prompt-adherence
  gap, photo/confirm stages are assignment 5 scope); recorded in
  evals/results/20261020-144856

Known limits: field extraction from model reply not wired (stub);
photo/confirm nodes still scaffold; clarify counting is approximate.
This commit is contained in:
Eric Wang
2026-10-20 14:53:46 +08:00
parent 8323d53a70
commit 1c6b1a513d
17 changed files with 1596 additions and 34 deletions
+189
View File
@@ -0,0 +1,189 @@
"""作业 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",
"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"<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)
assert len(llm.calls) == 1
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"
@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)
assert len(llm.calls) == 2
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():
# 图级:build_graph(llm=...) 时首轮走 welcome(不调模型),
# 第二轮起 collect 节点走 run_collect
llm = FakeLLMService(lambda s, u: "<state>1002</state>收到。")
graph = build_graph(llm=llm)
first = await graph.ainvoke(
{"session_id": "s1", "input_text": "追尾了"},
config={"configurable": {"thread_id": "s1"}},
)
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"}},
)
assert len(llm.calls) == 1
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
+18 -5
View File
@@ -46,11 +46,24 @@ async def test_different_threads_are_isolated() -> None:
@pytest.mark.asyncio
async def test_reply_uses_fixed_template_with_stage_prefix() -> None:
async def test_first_turn_is_welcome_stage() -> None:
# 新会话首条消息:welcome 节点固定问候,返回 1001(对照工作流
# work_start is_first_message → Assigned Reply#3),不调用模型
graph = build_graph()
result = await ask(graph, "thread-a", "你好")
assert result["stage_code"] == "1002"
assert result["response_text"].startswith("<state>1002</state>")
assert "你好" in result["response_text"]
assert result["stage_code"] == "1001"
assert result["response_text"].startswith("<state>1001</state>")
assert result["turn_count"] == 1
@pytest.mark.asyncio
async def test_second_turn_keeps_stage_without_event() -> None:
# 问候后的普通输入:脚手架版无事件则保持 1001,
# 【继续办理】等事件经白名单进入 1002(见 test_transitions.py)
graph = build_graph()
await ask(graph, "thread-a", "你好")
result = await ask(graph, "thread-a", "我出车祸了")
assert result["stage_code"] == "1001"
assert "我出车祸了" in result["response_text"]