"""采集节点 LLM 接入(作业 4)。
职责边界:
- LLM 一次调用同时产出 ``XXXX正文``——
不得为复判状态再调用第二次(计划作业 4-3);
- 代码校验前缀合法性、阶段枚举与白名单守卫,
非法提议不写状态(受控修复重试:前缀/非法提议最多各修复一次);
- 字段候选经 fields.validate_candidate 校验后进入
accident_fields / confirm_status / turn_patch(三态语义);
- 澄清/无回复计数是代码侧簿记,只作为 LLM 判断的输入,
转不转人工由模型按提示词决定(代码不代替模型决定)。
失败语义(计划作业 4-10):前缀缺失/非法且修复失败时,抛出
``CollectReplyError``——不提交业务状态、不泄漏原始模型内容;
调用方(API 层)将其转换为可识别错误响应。
"""
from collections.abc import Callable
from datetime import datetime
import json
import re
from .fields import (
FORM_FIELDS,
validate_form_update,
)
from .llm import LLMService
from .prompts import COLLECTION, EXTRACTION, render_prompt
from .state import AgentState
from .time_context import observation_time, time_evidence, valid_time_reply
from .transitions import COLLECT_STAGE, is_legal_transition
STATE_PREFIX_RE = re.compile(r"^\s*\s*(\d{4})\s*")
_STATE_TAG_RE = re.compile(r"?state>")
# 采集阶段澄清阈值(来源:prompts/collection.md)
COLLECT_FAILURE_LIMIT = 5
# 受控修复指令(计划作业 4-10)。追加在系统提示词之后,
# 明确要求输出格式,供模型自我修正。
_REPAIR_SUFFIX = (
"\n\n【输出修复】你上一次的输出不合规:必须且只能以"
" 四位状态码 开头(如 1002),"
"正文部分不得再出现 或 标签。"
"请重新输出一条完全合规的回复。"
)
class CollectReplyError(Exception):
"""采集回复不可用:格式、转换或时间依据校验且修复失败。
不携带原始模型内容,避免向上层泄漏。
"""
def parse_state_reply(text: str) -> tuple[str, str]:
"""解析 ``XXXX`` 前缀,返回 (stage_code, 正文)。
严格模式:无有效前缀、或正文再出现 state 标签(泄漏),
都视为不合规并抛 ValueError,交由 run_collect 决定是否修复。
"""
match = STATE_PREFIX_RE.match(text)
if not match:
raise ValueError("回复缺少有效的 前缀")
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
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,
reply_validator: Callable[[str, str], bool] | 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])
and (reply_validator is None or reply_validator(parsed[0], parsed[1]))):
return raw, parsed[0]
raise CollectReplyError("模型回复格式、阶段转换或时间依据不合规(一次修复后仍失败)")
# 工作流 chatNode 的 history 配置为 50;默认对齐(Config.
# AGENT_HISTORY_MESSAGES,env 可调大,0 = 不截断对齐 FastGPT 全量语义)。
_HISTORY_MESSAGES_DEFAULT = 50
def conversation_history(state: AgentState) -> list[dict[str, str]]:
"""历史不拼入 user 消息,不剥除历史 assistant 的状态前缀。
截断窗口取 Config.AGENT_HISTORY_MESSAGES(import 时固化的类属性,
测试用 monkeypatch 调整);0 或负数视为不截断。
"""
from ..core.config import Config
limit = getattr(Config, "AGENT_HISTORY_MESSAGES", None)
if limit is None:
limit = _HISTORY_MESSAGES_DEFAULT
dialogue = state.get("dialogue") or []
if limit > 0:
dialogue = dialogue[-limit:]
return [dict(message) for message in dialogue]
def context_prompt(state: AgentState, *, time_context: dict | None = None) -> 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,
**({"time_evidence": time_context} if time_context is not None else {}),
}, ensure_ascii=False)
def _format_turn_context(moment: datetime, extra: dict | None = None) -> str:
"""workflow@20261022 的 块:动态上下文经 user 消息传递。"""
lines = [f"本轮参考时间:{moment.strftime('%Y-%m-%d %H:%M:%S %z')}"]
for key, value in (extra or {}).items():
lines.append(f"{key}:{value}")
return "\n" + "\n".join(lines) + "\n"
def _build_user_message(state: AgentState, *, now: datetime) -> str:
"""对齐 workflow@20261022 采集节点 userChatInput:
(本轮参考时间)+ (原始输入)。
"""
return (
f"{_format_turn_context(now)}\n\n"
f"\n{state.get('input_text', '')}\n"
)
# 采集阶段表单状态使用工作流的外部表单键(has_injury 等,见 fields.FORM_FIELDS);
# 内部键(ywrysw 等)属于 get_info 分组,作业 6 再建立映射。
# 提取调用的系统提示标记(不以 开头,不会被误当对话回复解析)
_EXTRACTION_MARKER = "【字段提取】"
async def _extract_turn_fields(
state: AgentState, llm: LLMService, *, now: datetime, time_context: dict,
) -> dict:
"""独立提取调用(计划作业 4-4:调用计数可审计——每轮在对话调用之外多一次)。
提示词为工作流「文本内容提取事故信息」原文(20261022 起时间/表单
经 content 的 传递);模型只输出 JSON;
校验见 fields.validate_form_update。提取失败不阻塞对话(返回 {})。
"""
system = (
_EXTRACTION_MARKER
+ "\n按下列系统提示词执行提取,只输出 JSON 对象,不输出解释。\n\n"
+ render_prompt(EXTRACTION, state, now=now)
+ context_prompt(state, time_context=time_context)
)
message = (
f"{_format_turn_context(now, {'当前表单': json.dumps(state.get('accident_fields') or {}, ensure_ascii=False)})}\n\n"
f"\n{state.get('input_text', '')}\n"
)
try:
raw = await llm.complete(
system,
message,
history=conversation_history(state),
)
data = json.loads(raw.strip())
except Exception:
return {}
if not isinstance(data, dict):
return {}
if time_context["historical_reference_missing"]:
# An unanchored historical relative expression cannot support a date.
# Keep all other independently supported fields from this turn.
data.pop("accident_time", None)
return validate_form_update(data)
def _has_meaningful_value(value) -> bool:
"""对应「更新表单」的 hasMeaningfulValue:None/空串无效(0 有效)。"""
return value is not None and value != ""
def _is_same_value(old, new) -> bool:
"""对应「更新表单」的 isSameValue:数字与数字字符串视为相等。"""
if old == new:
return True
if old is None or new is None:
return False
if old == "" or new == "":
return False
try:
return float(old) == float(new)
except (TypeError, ValueError):
return str(old).strip() == str(new).strip()
def _merge_form(state: AgentState, update: dict) -> tuple[dict, dict]:
"""按「更新表单」语义合并:白名单内、有效、有变化的字段进入差量。
返回 (合并后的完整表单, 本轮差量 turn_patch)。
"""
form = dict(state.get("accident_fields") or {})
patch: dict = {}
for key in FORM_FIELDS:
if key not in update:
continue
value = update[key]
if not _has_meaningful_value(value):
continue
if _is_same_value(form.get(key), value):
continue
form[key] = value
patch[key] = value
return form, patch
def _bookkeeping(state: AgentState) -> tuple[int, int]:
"""代码侧上下文簿记:无回复计数 + 澄清计数。
返回 (no_reply_count, clarify_count)。注意这只是给 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
return no_reply_count, state.get("clarify_count") or 0
async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
"""采集节点核心:一轮对话的 LLM 接入(参考实现)。
流程:一次模型调用 → 前缀校验(不合规修复一次)→
白名单守卫(非法提议修复一次)→ 字段候选落库 → 返回状态增量。
任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
错误信息不携带原始模型内容。
"""
moment = observation_time(now)
evidence = time_evidence(state, moment)
system_prompt = render_prompt(COLLECTION, state, now=moment) + context_prompt(state, time_context=evidence)
user_message = _build_user_message(state, now=moment)
current = state.get("stage_code") or COLLECT_STAGE
raw, stage = await validated_reply(
llm, system_prompt, user_message, current, history=conversation_history(state),
reply_validator=lambda code, body: valid_time_reply(code, body, evidence),
)
# 4) 字段提取与表单差量(提取调用在对话调用之外,见 _extract_turn_fields)
extracted = await _extract_turn_fields(state, llm, now=moment, time_context=evidence)
accident_fields, patch = _merge_form(state, extracted)
no_reply_count, clarify_count = _bookkeeping(state)
# 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
# dialogue 追加本轮消息,供下一轮历史注入(计划作业 4-5)
dialogue = list(state.get("dialogue") or [])
timestamps = dict(state.get("dialogue_timestamps") or {})
timestamps[str(len(dialogue))] = moment.isoformat()
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,
"turn_patch": patch,
"dialogue": dialogue,
"dialogue_timestamps": timestamps,
}