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:
+56
-77
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user