feat(agent): trusted time evidence and collection prompt optimization
- time_context.py: server-side observation clocks for relative-time evidence (anti client-forgery); resolves X分钟前/小时前 against the observation timestamp of the turn that produced them, never re-basing history on the current clock; flags historical references without a reliable baseline - state: dialogue_timestamps keyed by dialogue message index, persisted with checkpoint; welcome seeds index 0 - collect: single clock per turn shared by reply/extraction/repair; time_evidence injected into runtime context; reply_validator hook rejects time claims lacking evidence (one shared repair budget); model still interprets facts and chooses actions - prompts: collection prompt restructured (shorter, state-bound-to-action table, gatekeeper principle, time source rules); extraction prompt aligned; workflow@20261021 json updated to match - evals: prompt_probes / prompt_compare / time_boundary probe scripts (supplementary checks, original cases untouched) - docs: collection-prompt-optimization.md (method + baselines), time-boundary-fix.md (problem, design, verification) - gitignore: editor artifact dirs (.agents/.claude/.vscode, skills-lock) Verified: 171 unit tests pass (16 new time-context tests)
This commit is contained in:
+31
-12
@@ -16,6 +16,7 @@
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
@@ -26,6 +27,7 @@ from .fields import (
|
||||
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*<state>\s*(\d{4})\s*</state>")
|
||||
@@ -45,7 +47,7 @@ _REPAIR_SUFFIX = (
|
||||
|
||||
|
||||
class CollectReplyError(Exception):
|
||||
"""采集回复不可用:前缀缺失/非法且修复失败。
|
||||
"""采集回复不可用:格式、转换或时间依据校验且修复失败。
|
||||
|
||||
不携带原始模型内容,避免向上层泄漏。
|
||||
"""
|
||||
@@ -82,11 +84,13 @@ async def validated_reply(
|
||||
*,
|
||||
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
|
||||
@@ -98,9 +102,10 @@ async def validated_reply(
|
||||
)
|
||||
raw = await llm.complete(prompt, user_message, history=history)
|
||||
parsed = _try_parse(raw)
|
||||
if parsed is not None and validator(current, parsed[0]):
|
||||
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("模型回复格式或阶段转换不合规(一次修复后仍失败)")
|
||||
raise CollectReplyError("模型回复格式、阶段转换或时间依据不合规(一次修复后仍失败)")
|
||||
|
||||
|
||||
# 工作流 chatNode 的 history 配置为 50;保留独立消息及其角色。
|
||||
@@ -112,7 +117,7 @@ def conversation_history(state: AgentState) -> list[dict[str, str]]:
|
||||
return [dict(message) for message in (state.get("dialogue") or [])[-_HISTORY_MESSAGES:]]
|
||||
|
||||
|
||||
def context_prompt(state: AgentState) -> str:
|
||||
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,
|
||||
@@ -121,6 +126,7 @@ def context_prompt(state: AgentState) -> str:
|
||||
"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)
|
||||
|
||||
|
||||
@@ -137,7 +143,9 @@ def _build_user_message(state: AgentState) -> str:
|
||||
_EXTRACTION_MARKER = "【字段提取】"
|
||||
|
||||
|
||||
async def _extract_turn_fields(state: AgentState, llm: LLMService) -> dict:
|
||||
async def _extract_turn_fields(
|
||||
state: AgentState, llm: LLMService, *, now: datetime, time_context: dict,
|
||||
) -> dict:
|
||||
"""独立提取调用(计划作业 4-4:调用计数可审计——每轮在对话调用之外多一次)。
|
||||
|
||||
提示词为工作流「文本内容提取事故信息」原文(含当前时间与当前表单
|
||||
@@ -147,7 +155,8 @@ async def _extract_turn_fields(state: AgentState, llm: LLMService) -> dict:
|
||||
system = (
|
||||
_EXTRACTION_MARKER
|
||||
+ "\n按下列系统提示词执行提取,只输出 JSON 对象,不输出解释。\n\n"
|
||||
+ render_prompt(EXTRACTION, state)
|
||||
+ render_prompt(EXTRACTION, state, now=now)
|
||||
+ context_prompt(state, time_context=time_context)
|
||||
)
|
||||
try:
|
||||
raw = await llm.complete(
|
||||
@@ -160,6 +169,10 @@ async def _extract_turn_fields(state: AgentState, llm: LLMService) -> dict:
|
||||
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)
|
||||
|
||||
|
||||
@@ -221,7 +234,7 @@ def _bookkeeping(state: AgentState) -> tuple[int, int]:
|
||||
return no_reply_count, state.get("clarify_count") or 0
|
||||
|
||||
|
||||
async def run_collect(state: AgentState, llm: LLMService) -> dict:
|
||||
async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
|
||||
"""采集节点核心:一轮对话的 LLM 接入(参考实现)。
|
||||
|
||||
流程:一次模型调用 → 前缀校验(不合规修复一次)→
|
||||
@@ -229,22 +242,27 @@ async def run_collect(state: AgentState, llm: LLMService) -> dict:
|
||||
任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
|
||||
错误信息不携带原始模型内容。
|
||||
"""
|
||||
system_prompt = render_prompt(COLLECTION, state) + context_prompt(state)
|
||||
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)
|
||||
current = state.get("stage_code") or COLLECT_STAGE
|
||||
raw, stage = await validated_reply(
|
||||
llm, system_prompt, user_message, current, history=conversation_history(state)
|
||||
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)
|
||||
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 {
|
||||
@@ -257,4 +275,5 @@ async def run_collect(state: AgentState, llm: LLMService) -> dict:
|
||||
"accident_fields": accident_fields,
|
||||
"turn_patch": patch,
|
||||
"dialogue": dialogue,
|
||||
"dialogue_timestamps": timestamps,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user