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
+20
View File
@@ -18,6 +18,15 @@ class Config:
# Chat backend switch(作业 2)
# "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。
AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt")
# LLM provider(作业 4):fake 为脚本化假模型,离线可运行;
# openai 为真实 provider(OpenAI 兼容接口)。
AGENT_LLM_PROVIDER = os.getenv("AGENT_LLM_PROVIDER", "fake")
LLM_MODEL = os.getenv("LLM_MODEL", "fake-reply-model")
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "")
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "30"))
LLM_MAX_RETRIES = int(os.getenv("LLM_MAX_RETRIES", "2"))
@classmethod
def validate(cls):
@@ -28,6 +37,17 @@ class Config:
raise ValueError(
f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}"
)
if cls.AGENT_LLM_PROVIDER not in ("fake", "openai"):
raise ValueError(
"AGENT_LLM_PROVIDER must be 'fake' or 'openai', "
f"got {cls.AGENT_LLM_PROVIDER!r}"
)
if cls.AGENT_LLM_PROVIDER == "openai" and (
not cls.LLM_API_KEY or not cls.LLM_BASE_URL
):
raise ValueError(
"AGENT_LLM_PROVIDER=openai requires LLM_API_KEY and LLM_BASE_URL"
)
if cls.AGENT_BACKEND != "fastgpt":
return
required = [
+18 -1
View File
@@ -11,6 +11,7 @@ from .config import Config
_fastgpt_client: AsyncChatClient | None = None
_chat_backend: ChatBackend | None = None
_langgraph_graph = None
@asynccontextmanager
@@ -22,7 +23,11 @@ async def lifespan(app: FastAPI):
if Config.AGENT_BACKEND == "langgraph":
# 作业 2:LangGraph fake 模式不要求 FastGPT 密钥。
# TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。
_chat_backend = LangGraphBackend(build_graph())
from ..agent.llm import build_llm_service
global _langgraph_graph
_langgraph_graph = build_graph(llm=build_llm_service())
_chat_backend = LangGraphBackend(_langgraph_graph)
else:
_fastgpt_client = AsyncChatClient(
api_key=Config.FASTGPT_API_KEY,
@@ -39,11 +44,23 @@ async def lifespan(app: FastAPI):
yield
finally:
_chat_backend = None
_langgraph_graph = None
if _fastgpt_client:
await _fastgpt_client.__aexit__(None, None, None)
_fastgpt_client = None
async def delete_chat_session(thread_id: str) -> None:
"""langgraph 模式下删除会话:清理图 checkpoint(对照计划作业 6 的
完整语义——业务字段、事件、路由持久化清理在接入 repository 后补齐)。
"""
if _langgraph_graph is None:
raise RuntimeError("LangGraph backend not initialized")
checkpointer = getattr(_langgraph_graph, "checkpointer", None)
if checkpointer is not None and hasattr(checkpointer, "delete_thread"):
checkpointer.delete_thread(thread_id)
def get_fastgpt_client() -> AsyncChatClient:
"""Get the initialized FastGPT client."""
if _fastgpt_client is None: