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
+10 -9
View File
@@ -1,14 +1,14 @@
"""作业 2 验收测试:LangGraph 后端适配器。
通过标准(对应迁移计划作业 2):
- ``complete()`` 返回带 ``<state>1002</state>`` 前缀的 ``ChatResult``,
API 层据此解析阶段码;
- ``complete()`` 返回带 ``<state>`` 前缀的 ``ChatResult``,
API 层据此解析阶段码(首轮为 welcome 固定问候 1001);
- ``stream()`` 先产出含完整 state 前缀的 ``TextDelta``,再产出正文,
保证 ``stage_code`` 事件先于正文;
- 会话通过 ``thread_id = session_id`` 绑定(图 checkpoint 语义)。
注意:``src/backends/langgraph.py`` 中的 TODO 完成前,
本文件的测试会失败,属于预期状态。
注意:新会话首条消息返回 1001(对照工作流 is_first_message
分支);1002 是进入采集后的阶段。
"""
import pytest
@@ -31,8 +31,8 @@ async def test_complete_returns_state_prefixed_result() -> None:
result = await backend.complete(make_input())
assert result.content.startswith("<state>1002</state>")
assert "发生了交通事故" in result.content
# 首轮为 welcome 固定问候(1001,对照工作流 is_first_message 分支)
assert result.content.startswith("<state>1001</state>")
@pytest.mark.asyncio
@@ -44,8 +44,8 @@ async def test_stream_emits_prefix_before_body() -> None:
assert events, "stream 至少应产生一个 TextDelta"
assert all(isinstance(event, TextDelta) for event in events)
joined = "".join(event.text for event in events)
assert joined.startswith("<state>1002</state>")
assert "发生了交通事故" in joined
# 首轮为 welcome 固定问候(1001)
assert joined.startswith("<state>1001</state>")
@pytest.mark.asyncio
@@ -59,4 +59,5 @@ async def test_sessions_are_isolated_by_thread_id() -> None:
)
assert first.content != "" and second.content != ""
assert "你好" in other.content
# 三个会话的首轮都是 welcome 问候;checkpoint 相互隔离
assert other.content.startswith("<state>1001</state>")