Files
ZNJJ-api-server/src/agent/collect.py
T

246 lines
9.9 KiB
Python
Raw Normal View History

"""采集节点 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,
}