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:
@@ -0,0 +1,245 @@
|
||||
"""采集节点 LLM 接入(作业 4)。
|
||||
|
||||
职责边界:
|
||||
- LLM 一次调用同时产出 ``<state>XXXX</state>正文``——
|
||||
不得为复判状态再调用第二次(计划作业 4-3);
|
||||
- 代码校验前缀合法性、阶段枚举与白名单守卫,
|
||||
非法提议不写状态(受控修复重试:前缀/非法提议最多各修复一次);
|
||||
- 字段候选经 fields.validate_candidate 校验后进入
|
||||
accident_fields / confirm_status / turn_patch(三态语义);
|
||||
- 澄清/无回复计数是代码侧簿记,只作为 LLM 判断的输入,
|
||||
转不转人工由模型按提示词决定(代码不代替模型决定)。
|
||||
|
||||
失败语义(计划作业 4-10):前缀缺失/非法且修复失败时,抛出
|
||||
``CollectReplyError``——不提交业务状态、不泄漏原始模型内容;
|
||||
调用方(API 层)将其转换为可识别错误响应。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from .fields import (
|
||||
FieldCandidate,
|
||||
FieldStatus,
|
||||
validate_candidate,
|
||||
)
|
||||
from .llm import LLMService
|
||||
from .prompts import COLLECTION, load_prompt
|
||||
from .state import AgentState
|
||||
from .transitions import COLLECT_STAGE, is_legal_transition
|
||||
|
||||
STATE_PREFIX_RE = re.compile(r"^\s*<state>\s*(\d{4})\s*</state>")
|
||||
_STATE_TAG_RE = re.compile(r"</?state>")
|
||||
|
||||
# 采集阶段澄清阈值(来源:prompts/collection.md)
|
||||
COLLECT_FAILURE_LIMIT = 5
|
||||
|
||||
# 受控修复指令(计划作业 4-10)。追加在系统提示词之后,
|
||||
# 明确要求输出格式,供模型自我修正。
|
||||
_REPAIR_SUFFIX = (
|
||||
"\n\n【输出修复】你上一次的输出不合规:必须且只能以"
|
||||
" <state>四位状态码</state> 开头(如 <state>1002</state>),"
|
||||
"正文部分不得再出现 <state> 或 </state> 标签。"
|
||||
"请重新输出一条完全合规的回复。"
|
||||
)
|
||||
|
||||
|
||||
class CollectReplyError(Exception):
|
||||
"""采集回复不可用:前缀缺失/非法且修复失败。
|
||||
|
||||
不携带原始模型内容,避免向上层泄漏。
|
||||
"""
|
||||
|
||||
|
||||
def parse_state_reply(text: str) -> tuple[str, str]:
|
||||
"""解析 ``<state>XXXX</state>`` 前缀,返回 (stage_code, 正文)。
|
||||
|
||||
严格模式:无有效前缀、或正文再出现 state 标签(泄漏),
|
||||
都视为不合规并抛 ValueError,交由 run_collect 决定是否修复。
|
||||
"""
|
||||
match = STATE_PREFIX_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("回复缺少有效的 <state> 前缀")
|
||||
body = text[match.end():].strip()
|
||||
if _STATE_TAG_RE.search(body):
|
||||
raise ValueError("正文中再次出现 state 标签")
|
||||
return match.group(1), body
|
||||
|
||||
|
||||
def _try_parse(text: str) -> tuple[str, str] | None:
|
||||
"""宽松入口:不合规返回 None 而不是抛错(供修复流程判断)。"""
|
||||
try:
|
||||
return parse_state_reply(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# 注入提示词的对话历史轮数上限(每轮 = user + assistant 两条)
|
||||
_HISTORY_TURNS = 10
|
||||
|
||||
|
||||
def _format_history(state: AgentState) -> str:
|
||||
"""把 dialogue 格式化为提示词可用的历史文本;为空返回“(无)”。"""
|
||||
dialogue = state.get("dialogue") or []
|
||||
if not dialogue:
|
||||
return "(首轮对话)"
|
||||
recent = dialogue[-_HISTORY_TURNS * 2:]
|
||||
lines = []
|
||||
for msg in recent:
|
||||
role = "用户" if msg.get("role") == "user" else "助手"
|
||||
content = str(msg.get("content", ""))
|
||||
# 助手历史带 <state> 标签,去掉标签避免干扰模型本轮输出格式
|
||||
content = STATE_PREFIX_RE.sub("", content).strip()
|
||||
lines.append(f"{role}:{content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_known_fields(state: AgentState) -> str:
|
||||
"""把已确认字段格式化为提示词摘要;为空返回“(暂无)”。"""
|
||||
fields = state.get("accident_fields") or {}
|
||||
if not fields:
|
||||
return "(暂无)"
|
||||
return ";".join(f"{key}={value}" for key, value in fields.items())
|
||||
|
||||
|
||||
def _build_user_message(state: AgentState) -> str:
|
||||
"""把图状态翻译成本轮用户消息(代替工作流的变量注入)。
|
||||
|
||||
注入三部分上下文(计划作业 4-5“跳过已知项”):
|
||||
- 对话历史:模型看不到 checkpoint,历史必须显式注入,
|
||||
否则每轮都以为首次提问、重复询问已知信息;
|
||||
- 已确认字段:信息齐全时模型按提示词直接分流(2000/2010),
|
||||
不追加核对问题;
|
||||
- 当前阶段与计数:供模型做转人工等判断。
|
||||
"""
|
||||
return (
|
||||
f"【对话历史】\n{_format_history(state)}\n"
|
||||
f"【已确认信息】\n{_format_known_fields(state)}\n"
|
||||
f"【当前对话阶段】{state.get('stage_code') or COLLECT_STAGE}\n"
|
||||
f"【连续无回复次数】{state.get('no_reply_count') or 0}\n"
|
||||
f"【用户最新输入】{state.get('input_text', '')}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_candidates(state: AgentState, body: str) -> list[FieldCandidate]:
|
||||
"""从模型正文中提取字段候选(计划作业 4-4)。
|
||||
|
||||
参考版返回空列表:字段提取依赖模型的结构化输出,留给真实
|
||||
LLM 接入后实现(同一回复内 JSON 解析优先;确需独立提取调用时,
|
||||
记录原因/调用次数/延迟)。validate_candidate 的字段级校验
|
||||
已由单元测试覆盖,此处先接通数据流。
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
def _apply_candidates(
|
||||
state: AgentState, candidates: list[FieldCandidate]
|
||||
) -> tuple[dict, dict, dict]:
|
||||
"""把校验通过的候选写入正式字段。
|
||||
|
||||
返回 (accident_fields, confirm_status, turn_patch)。
|
||||
只有 CONFIRMED 的候选才写入正式字段并进入本轮差量;
|
||||
CANDIDATE/UNKNOWN 只更新 confirm_status,供后续核实节点消费
|
||||
(计划作业 4-5 的三态语义)。
|
||||
"""
|
||||
accident_fields = dict(state.get("accident_fields") or {})
|
||||
confirm_status = dict(state.get("confirm_status") or {})
|
||||
patch: dict = {}
|
||||
for cand in candidates:
|
||||
validated = validate_candidate(cand)
|
||||
confirm_status[validated.key] = validated.status
|
||||
if validated.status is FieldStatus.CONFIRMED:
|
||||
accident_fields[validated.key] = validated.value
|
||||
patch[validated.key] = validated.value
|
||||
return accident_fields, confirm_status, patch
|
||||
|
||||
|
||||
def _bookkeeping(state: AgentState) -> tuple[int, int]:
|
||||
"""代码侧上下文簿记:无回复计数 + 澄清计数。
|
||||
|
||||
返回 (no_reply_count, clarify_count)。注意这只是给 LLM 的
|
||||
判断输入,是否转人工由模型按提示词决定。
|
||||
|
||||
clarify_count 的“计入”需要语义完整性判断(短促词/ASR 乱码
|
||||
不计入,见 prompts/collection.md);参考版用“普通输入”做
|
||||
简化近似,接入真实 LLM 后按语义判定细化。
|
||||
"""
|
||||
event = state.get("client_event") or ""
|
||||
no_reply_count = state.get("no_reply_count") or 0
|
||||
if event == "no_reply":
|
||||
no_reply_count += 1
|
||||
elif (state.get("input_text") or "").strip():
|
||||
no_reply_count = 0
|
||||
|
||||
clarify_count = state.get("clarify_count") or 0
|
||||
if not event and (state.get("input_text") or "").strip():
|
||||
clarify_count += 1
|
||||
return no_reply_count, clarify_count
|
||||
|
||||
|
||||
async def run_collect(state: AgentState, llm: LLMService) -> dict:
|
||||
"""采集节点核心:一轮对话的 LLM 接入(参考实现)。
|
||||
|
||||
流程:一次模型调用 → 前缀校验(不合规修复一次)→
|
||||
白名单守卫(非法提议修复一次)→ 字段候选落库 → 返回状态增量。
|
||||
任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
|
||||
错误信息不携带原始模型内容。
|
||||
"""
|
||||
system_prompt = load_prompt(COLLECTION)
|
||||
user_message = _build_user_message(state)
|
||||
current = state.get("stage_code") or COLLECT_STAGE
|
||||
|
||||
# 1) 第一次模型调用(计划作业 4-3:一次调用同时产出前缀与正文)
|
||||
raw = await llm.complete(system_prompt, user_message)
|
||||
parsed = _try_parse(raw)
|
||||
|
||||
# 2) 前缀不合规:受控修复一次(最多多一次调用)
|
||||
if parsed is None:
|
||||
raw = await llm.complete(system_prompt + _REPAIR_SUFFIX, user_message)
|
||||
parsed = _try_parse(raw)
|
||||
if parsed is None:
|
||||
raise CollectReplyError(
|
||||
"模型回复缺少合法 <state> 前缀(修复一次后仍不合规)"
|
||||
)
|
||||
stage, _body = parsed
|
||||
|
||||
# 3) 白名单守卫:LLM 提议的转换必须合法(计划作业 3 校验语义)
|
||||
if not is_legal_transition(current, stage):
|
||||
repair_hint = (
|
||||
"\n另外:你上一条提议的状态码不合规。"
|
||||
f"当前阶段是 {current},只能选择合法的下一步状态码,"
|
||||
"不得跳步或使用未定义的状态码。"
|
||||
)
|
||||
raw = await llm.complete(
|
||||
system_prompt + _REPAIR_SUFFIX + repair_hint, user_message
|
||||
)
|
||||
parsed = _try_parse(raw)
|
||||
if parsed is None or not is_legal_transition(current, parsed[0]):
|
||||
raise CollectReplyError(
|
||||
f"模型提议了非法的阶段转换 {current}→{stage}"
|
||||
"(修复一次后仍不合规)"
|
||||
)
|
||||
stage, _body = parsed
|
||||
|
||||
# 4) 字段候选提取与三态落库(提取本身见 _extract_candidates)
|
||||
accident_fields, confirm_status, patch = _apply_candidates(
|
||||
state, _extract_candidates(state, raw)
|
||||
)
|
||||
no_reply_count, clarify_count = _bookkeeping(state)
|
||||
|
||||
# 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
|
||||
# dialogue 追加本轮消息,供下一轮历史注入(计划作业 4-5)
|
||||
dialogue = list(state.get("dialogue") or [])
|
||||
dialogue.append({"role": "user", "content": state.get("input_text", "")})
|
||||
dialogue.append({"role": "assistant", "content": raw})
|
||||
return {
|
||||
"turn_count": (state.get("turn_count") or 0) + 1,
|
||||
"no_reply_count": no_reply_count,
|
||||
"clarify_count": clarify_count,
|
||||
"response_text": raw,
|
||||
"stage_code": stage,
|
||||
"raw_status_code": stage,
|
||||
"accident_fields": accident_fields,
|
||||
"confirm_status": confirm_status,
|
||||
"turn_patch": patch,
|
||||
"dialogue": dialogue,
|
||||
}
|
||||
Reference in New Issue
Block a user