feat(agent): align prompts and runtime with workflow@20261020

- archive workflow/20261020 export (diff vs 20261016: collection prompt
  only - state-decides-body rule, route when info complete)
- prompts: collection.md updated; 6 photo/confirm prompts archived with
  source nodeIds; render_prompt replaces {{$vars}} (Asia/Shanghai clock,
  person fields, explicit 'not provided' for missing prefills)
- llm: message history passed as user/assistant roles (chatNode history=50);
  optional LLM_TEMPERATURE; FakeLLMService supports history-aware responders
- collect: context via system supplement, raw user input as user message
  (Text Editor#2 parity), shared validated_reply (one call, one repair)
- welcome: Assigned Reply#3 original text, stored in dialogue history
- transitions: 2004 plate-confirm entries (2003->2004/2005, 2004->2005)
- verified: prompt files byte-identical to workflow JSON (parametrized test)
This commit is contained in:
Eric Wang
2026-10-21 15:10:51 +08:00
parent 1c6b1a513d
commit a5407e03b2
14 changed files with 7881 additions and 98 deletions
+56 -77
View File
@@ -15,6 +15,8 @@
调用方(API 层)将其转换为可识别错误响应。
"""
from collections.abc import Callable
import json
import re
from .fields import (
@@ -23,7 +25,7 @@ from .fields import (
validate_candidate,
)
from .llm import LLMService
from .prompts import COLLECTION, load_prompt
from .prompts import COLLECTION, render_prompt
from .state import AgentState
from .transitions import COLLECT_STAGE, is_legal_transition
@@ -73,51 +75,60 @@ def _try_parse(text: str) -> tuple[str, str] | None:
return None
# 注入提示词的对话历史轮数上限(每轮 = user + assistant 两条)
_HISTORY_TURNS = 10
async def validated_reply(
llm: LLMService,
system_prompt: str,
user_message: str,
current: str,
*,
validator: Callable[[str, str], bool] = is_legal_transition,
history: list[dict[str, str]] | None = None,
) -> tuple[str, str]:
"""模型同时产出下一阶段和正文;格式与转换共用一次修复预算。
只校验模型提议,不按输入长度或关键字替模型决定。非法输出不写回
图状态;修复仍失败则抛出不含模型原文的错误。
"""
for attempt in range(2):
prompt = system_prompt
if attempt:
prompt += (
_REPAIR_SUFFIX
+ f"\n当前阶段为 {current}。请重新检查该阶段允许的转换"
"和当前 clientMode,不得跳步或切换到另一模式的出口。"
)
raw = await llm.complete(prompt, user_message, history=history)
parsed = _try_parse(raw)
if parsed is not None and validator(current, parsed[0]):
return raw, parsed[0]
raise CollectReplyError("模型回复格式或阶段转换不合规(一次修复后仍失败)")
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)
# 工作流 chatNode 的 history 配置为 50;保留独立消息及其角色。
_HISTORY_MESSAGES = 50
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 conversation_history(state: AgentState) -> list[dict[str, str]]:
"""历史不拼入 user 消息,不剥除历史 assistant 的状态前缀。"""
return [dict(message) for message in (state.get("dialogue") or [])[-_HISTORY_MESSAGES:]]
def context_prompt(state: AgentState) -> str:
"""运行时上下文随 system 提示提供;业务判断仍由模型负责。"""
return "\n\n【当前会话上下文】\n" + json.dumps({
"stage_code": state.get("stage_code") or COLLECT_STAGE,
"client_mode": state.get("client_mode") or "direct",
"person_index": state.get("person_index") or 1,
"accident_fields": state.get("accident_fields") or {},
"person_fields": state.get("person_fields") or {},
"no_reply_count_before_this_turn": state.get("no_reply_count") or 0,
}, ensure_ascii=False)
def _build_user_message(state: AgentState) -> str:
"""把图状态翻译成本轮用户消息(代替工作流的变量注入)。
"""对齐工作流 Text Editor#2:只传用户本轮原始文本。"""
return state.get("input_text", "")
注入三部分上下文(计划作业 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]:
@@ -159,9 +170,8 @@ 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
@@ -170,10 +180,7 @@ def _bookkeeping(state: AgentState) -> tuple[int, int]:
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
return no_reply_count, state.get("clarify_count") or 0
async def run_collect(state: AgentState, llm: LLMService) -> dict:
@@ -184,41 +191,13 @@ async def run_collect(state: AgentState, llm: LLMService) -> dict:
任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
错误信息不携带原始模型内容。
"""
system_prompt = load_prompt(COLLECTION)
system_prompt = render_prompt(COLLECTION, state) + context_prompt(state)
user_message = _build_user_message(state)
current = state.get("stage_code") or COLLECT_STAGE
raw, stage = await validated_reply(
llm, system_prompt, user_message, current, history=conversation_history(state)
)
# 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(