feat(server): 会话上下文保留(LLM 历史 + 确定性兜底双保险)

解决用户删除草稿后说'再提交'丢失上下文的问题:

- steward.py 新增 _inject_recent_conversation_history:build_plan 前读最近 10 条对话注入 context_json
- steward_intent_agent.py 的 _build_messages 把 recent_history 暴露给模型,system prompt 加确认类话术引导
- 新建 steward_context_resume.py:should_resume_recent_task 检测'再提交'类话术 + state 有可恢复 flow,attach_resumed_task 从 state 恢复 task
- 两个 plan 入口(/plans 和 /plans/stream)都已接入双保险
- 后端 67 passed,端到端验证'上海出差→再提交'成功恢复 task
This commit is contained in:
caoxiaozhu
2026-06-25 15:08:56 +08:00
parent 2ebc2756bf
commit e9d7c56d5b
5 changed files with 440 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import re
from typing import Any
from app.schemas.steward import (
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
# "再提交"类确认话术:用户在删除草稿/解决冲突后,用这些话术恢复之前的申请 task
RESUME_CONFIRMATION_KEYWORDS = (
"再提交",
"继续提交",
"重新提交",
"再申请",
"继续申请",
"重新申请",
"那就提交",
"那就申请",
"继续吧",
"再试一次",
"重新发起",
"重新创建",
)
# flow_id → task_type 映射,用于从 steward_state 恢复 task
_FLOW_TASK_TYPE = {
"travel_application": "expense_application",
"travel_reimbursement": "reimbursement",
}
_FLOW_ASSIGNED_AGENT = {
"travel_application": "application_assistant",
"travel_reimbursement": "reimbursement_assistant",
}
def should_resume_recent_task(
message: str,
steward_state: dict[str, Any] | None,
) -> str | None:
"""检测'再提交'类话术 + steward_state 里有可恢复的 flow返回 flow_id 或 None。
确定性兜底:不依赖 LLM当用户用确认类话术"再提交")且 state 里存在
一个仍有业务字段的 flow 时,直接恢复该 flow。
"""
if not _matches_resume_keywords(message):
return None
if not isinstance(steward_state, dict):
return None
active_flow = str(steward_state.get("active_flow") or "").strip()
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
# 优先恢复 active_flow其次遍历所有 flow 找最近一个有字段的
candidate_flow_ids: list[str] = []
if active_flow and active_flow in flows:
candidate_flow_ids.append(active_flow)
for flow_id in flows:
if flow_id not in candidate_flow_ids:
candidate_flow_ids.append(flow_id)
for flow_id in candidate_flow_ids:
flow = flows.get(flow_id)
if isinstance(flow, dict) and _flow_has_resumable_fields(flow):
return str(flow_id or "").strip() or None
return None
def resume_task_from_flow(
flow_id: str,
flow: dict[str, Any],
task_index: int = 1,
) -> StewardTask:
"""从 steward_state.flows[flow_id] 恢复成 StewardTask。
复用 runtime-decision 的恢复逻辑_hydrate_runtime_state 的 field 读取),
但产出完整 StewardTask 而非 runtime dict。
"""
task_type = _FLOW_TASK_TYPE.get(flow_id, "expense_application")
assigned_agent = _FLOW_ASSIGNED_AGENT.get(flow_id, "application_assistant")
fields = {
str(key or "").strip(): str(value or "").strip()
for key, value in (flow.get("fields") or {}).items()
if str(key or "").strip() and str(value or "").strip()
}
missing_fields = [
str(item or "").strip()
for item in (flow.get("missing_fields") or [])
if str(item or "").strip()
]
task_prefix = "app" if task_type == "expense_application" else "reim"
return StewardTask(
task_id=f"task_resume_{task_prefix}_{task_index:03d}",
task_type=task_type,
assigned_agent=assigned_agent,
title="恢复上次未完成的申请" if task_type == "expense_application" else "恢复上次未完成的报销",
summary="根据之前的对话上下文恢复该任务。",
status="needs_confirmation" if missing_fields else "ready_to_delegate",
confidence=0.85,
requested_action="submit",
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
)
def attach_resumed_task(
plan: StewardPlanResponse,
steward_state: dict[str, Any] | None,
flow_id: str,
) -> StewardPlanResponse:
"""把恢复的 task 挂回 plan并补充一条 thinking_event 说明上下文已恢复。"""
if not isinstance(steward_state, dict):
return plan
flows = steward_state.get("flows") if isinstance(steward_state.get("flows"), dict) else {}
flow = flows.get(flow_id) if isinstance(flows, dict) else None
if not isinstance(flow, dict):
return plan
resumed_task = resume_task_from_flow(flow_id, flow, task_index=len(plan.tasks) + 1)
tasks = list(plan.tasks) + [resumed_task]
thinking_events = list(plan.thinking_events)
field_summary = "".join(
f"{key}:{value}" for key, value in resumed_task.ontology_fields.items() if value
)
thinking_events.append(
StewardThinkingEvent(
event_id="context_resume_recovered",
stage="llm_function_call",
title="已恢复上次未完成的申请",
content=(
f"识别到您要继续之前的{('出差申请' if resumed_task.task_type == 'expense_application' else '费用报销')}"
f"已从会话上下文恢复该任务"
+ (f"{field_summary})。" if field_summary else "")
),
status="completed",
)
)
return plan.model_copy(
update={
"tasks": tasks,
"thinking_events": thinking_events,
"planning_source": "context_resume",
"next_action": "confirm_task" if resumed_task.missing_fields else "delegate_task",
}
)
def _matches_resume_keywords(message: str) -> bool:
compact = re.sub(r"\s+", "", str(message or ""))
if not compact:
return False
return any(keyword in compact for keyword in RESUME_CONFIRMATION_KEYWORDS)
def _flow_has_resumable_fields(flow: dict[str, Any]) -> bool:
"""判断 flow 是否还有可恢复的业务字段(至少有 1 个非空字段)。"""
fields = flow.get("fields")
if not isinstance(fields, dict):
return False
return any(
str(value or "").strip()
for value in fields.values()
)

View File

@@ -103,8 +103,17 @@ class StewardIntentAgent:
"employee_grade",
"employee_no",
"client_timezone_offset_minutes",
"recent_history",
}
},
"recent_history": [
{
"role": str(item.get("role") or "").strip(),
"content": str(item.get("content") or "").strip(),
}
for item in (request.context_json.get("recent_history") or [])
if isinstance(item, dict) and str(item.get("content") or "").strip()
],
"attachments": [
{
"index": index + 1,
@@ -134,6 +143,11 @@ class StewardIntentAgent:
"每个 task 必须输出 requested_action用户只是要求整理/发起但未说保存或提交时为 preview"
"用户说保存草稿、先保存、存草稿时为 save_draft用户说直接提交、提交申请、确认提交时为 submit。"
"对于查询类任务(如查询差旅标准),requested_action 固定为 preview。"
"recent_history 是本会话最近 10 轮对话role 为 user 或 assistant"
"当用户说“再提交”“继续”“重新提交”“重新申请”等确认类话术时,"
"必须结合 recent_history 里最近一次提到的出差/报销申请来理解用户意图,"
"复用该申请的 ontology_fields 重新生成 task而不是把确认话术当作孤立的模糊输入。"
"如果 recent_history 为空或无法关联到具体申请,才按当前 message 字面理解。"
"相对日期必须以 base_date 为准转换为明确日期。"
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
"如果用户输入与出差、费用、报销、申请、差旅标准等财务事项完全无关"