246 lines
9.9 KiB
Python
246 lines
9.9 KiB
Python
"""采集节点 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,
|
|||
|
|
}
|