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:
Eric Wang
2026-10-22 09:41:32 +08:00
parent a890f41f6f
commit 9e28f69b3c
14 changed files with 827 additions and 588 deletions
+31 -12
View File
@@ -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,
}