From f60cebadb8caa1ba19070e669972b20f526a3f5d Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 4 Jun 2026 14:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=8F=E8=B4=A2=E7=AE=A1=E5=AE=B6?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E8=A7=84=E5=88=92=E4=B8=8E=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=BC=96=E6=8E=92=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善管家意图识别、模型计划构建与规划器调度 - 重构差旅报销提交编排器与管家计划流程前端交互 - 优化报销消息项样式与文档中心视图 - 新增小财管家与附件上传风险前置复核设计文档 - 补充管家规划器与文档中心测试覆盖 --- document/development/小财管家/CONCEPT.md | 390 +++++++++++++ document/development/小财管家/TODO.md | 58 ++ .../附件上传风险前置复核/CONCEPT.md | 86 +++ .../development/附件上传风险前置复核/TODO.md | 28 + server/src/app/api/v1/endpoints/steward.py | 14 +- .../src/app/services/steward_intent_agent.py | 5 +- .../services/steward_model_plan_builder.py | 2 +- server/src/app/services/steward_planner.py | 34 +- server/tests/test_steward_planner.py | 62 +++ .../travel-reimbursement-message-item.css | 252 ++++++++- ...travel-reimbursement-create-view-part4.css | 36 ++ .../travel/TravelReimbursementMessageItem.vue | 45 +- web/src/services/steward.js | 40 +- web/src/views/DocumentsCenterView.vue | 10 +- .../scripts/TravelReimbursementCreateView.js | 232 +++++++- web/src/views/scripts/stewardPlanModel.js | 449 +++++++++++++-- web/src/views/scripts/useStewardPlanFlow.js | 262 ++++++++- .../useTravelReimbursementSubmitComposer.js | 524 ++++++++++++++---- .../documents-center-status-filter.test.mjs | 4 + 19 files changed, 2337 insertions(+), 196 deletions(-) create mode 100644 document/development/小财管家/CONCEPT.md create mode 100644 document/development/小财管家/TODO.md create mode 100644 document/development/附件上传风险前置复核/CONCEPT.md create mode 100644 document/development/附件上传风险前置复核/TODO.md diff --git a/document/development/小财管家/CONCEPT.md b/document/development/小财管家/CONCEPT.md new file mode 100644 index 0000000..d0909d5 --- /dev/null +++ b/document/development/小财管家/CONCEPT.md @@ -0,0 +1,390 @@ +# 小财管家 + +## 功能一句话 + +小财管家是首页统一财务任务入口,负责把用户的自然语言和附件拆解为多个可确认、可追踪、可分派的申请与报销任务,再调用现有申请助手和报销助手完成执行闭环。 + +## 背景与问题 + +当前个人工作台已经提供首页输入框,并能通过本体解析把一句话路由到申请、报销、预算或知识等单一会话。这个能力适合单意图,但用户真实表达经常是多任务组合,例如同时包含出差申请、昨日交通费报销、历史出差费用报销以及多张附件。 + +现有问题: + +- 首页输入框当前会收敛为一个 `sessionType`,无法保留多任务计划。 +- 申请助手和报销助手已经具备单任务核对能力,但缺少上层任务拆解、归集和跨助手分派。 +- 附件上传后主要进入当前会话,缺少面向多任务的自动归集建议。 +- 财务动作需要确认后才能入库、绑定或提交,不能让大模型直接执行高风险动作。 +- 新增字段必须尊重本体字段,不能因为小财管家新增一套业务字段。 + +## 目标与非目标 + +### 目标 + +- 首页输入框定位为“小财管家”,作为用户默认财务任务入口。 +- 用户提交自然语言和附件后,先展示小财管家的任务识别与附件归集过程。 +- 支持把一句话拆成多个任务,第一版只验证费用申请和费用报销。 +- 支持多附件按费用场景、时间、地点、任务线索形成归集建议。 +- 遇到创建申请单、创建报销草稿、附件绑定、提交审批等动作时必须等待用户确认。 +- 保留现有申请助手和报销助手能力,小财管家只做上层编排和分派。 +- 外层意图识别必须优先使用大模型 function calling 输出结构化任务计划,规则逻辑只作为模型不可用或结构不合法时的兜底。 +- 前端展示“意图识别智能体”过程气泡,并用流式状态逐步呈现,不暴露模型内部推理链。 +- 所有业务字段先进入本体字段归一化,再进入下游助手、草稿、风险规则和持久化。 + +### 非目标 + +- 第一版不做万能智能体,不覆盖预算、审批、知识问答等全部场景。 +- 第一版不引入 LangChain 或 LangGraph,先复用项目内运行时模型配置和 OpenAI-compatible function calling 契约。 +- 第一版不自动提交审批,不绕过用户确认。 +- 第一版不新增业务语义字段;只新增任务编排态字段。 +- 第一版不重写申请助手、报销助手和现有 Orchestrator。 + +## 用户与场景 + +### 目标用户 + +- 普通员工:在首页一次性描述申请、报销和附件处理诉求。 +- 财务人员:查看任务拆解、附件归集和用户确认链路是否可追溯。 +- 审批/管理角色:后续可扩展为审批待办和预算提醒编排,但不进入第一版。 + +### 核心场景 + +用户在首页输入: + +```text +我想要申请7月2日去北京出差,辅助北京供电局的税务审核任务,并且我要报销昨天的交通费,还需要报销6月3日出差去上海的费用 +``` + +系统处理: + +1. 小财管家识别到三条候选任务。 +2. 将“昨天”按客户端日期解析为明确日期,例如 2026-06-03。 +3. 将“7月2日去北京出差”归为费用申请任务。 +4. 将“昨天的交通费”和“6月3日去上海出差费用”归为费用报销任务。 +5. 如果用户同时上传附件,系统先识别附件场景,再建议归集到对应任务。 +6. 需要创建申请单或报销草稿时,向用户展示核对摘要和确认动作。 + +## 功能能力 + +### 1. 任务识别与拆分 + +任务识别主链路是“小财管家意图识别智能体”: + +1. 后端读取系统设置中的主模型/备模型运行时配置。 +2. 将用户话术、客户端日期、附件元信息、上下文和 canonical ontology field 列表传入模型。 +3. 通过强制 function calling 调用 `submit_steward_intent_plan`。 +4. 模型只能返回结构化参数:`thinking_events`、`tasks`、`attachment_groups`。 +5. 服务端再次校验:任务类型只能是 `expense_application` / `reimbursement`,业务字段只能是 canonical ontology fields,附件名必须来自本次上传。 +6. 如果模型未配置、调用失败、未返回工具调用或结构不合法,才切换到规则兜底,并在过程摘要中标记兜底原因。 + +输入: + +- 用户自然语言 `message` +- 附件元信息 `attachments` +- 当前用户、部门、角色、客户端时间 +- 已有会话上下文,可选 + +输出: + +- `plan_id`:本次小财管家计划 ID +- `tasks`:多个任务条目 +- `thinking_events`:面向用户展示的过程摘要 +- `confirmation_groups`:需要用户确认的动作集合 +- `attachment_groups`:附件归集建议 + +任务条目包含: + +- `task_id`:编排态任务 ID +- `task_type`:`expense_application` 或 `reimbursement` +- `assigned_agent`:`application_assistant` 或 `reimbursement_assistant` +- `title`:任务标题 +- `summary`:任务摘要 +- `status`:`planned`、`needs_confirmation`、`ready_to_delegate`、`delegated`、`completed`、`blocked` +- `confidence`:识别置信度 +- `ontology_fields`:归一化后的本体字段 +- `missing_fields`:缺失字段 +- `confirmation_required`:是否需要确认后执行 + +### 2. 附件归集 + +附件归集基于以下信号: + +- 附件类型:发票、火车票、机票、酒店票、付款截图、招待票据等。 +- 费用场景:差旅、交通、招待、住宿、其他。 +- 日期:票据日期是否匹配任务时间。 +- 地点:票据地点是否匹配任务地点。 +- 金额:是否能参与报销草稿。 +- 置信度:低置信度必须提示用户核对。 + +输出示例: + +```json +{ + "group_id": "ag_travel_001", + "target_task_id": "task_reim_002", + "scene": "travel", + "scene_label": "差旅费用", + "attachment_names": ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"], + "excluded_attachment_names": ["客户招待发票.jpg"], + "confidence": 0.86, + "confirmation_required": true +} +``` + +### 3. 用户确认 + +必须确认的动作: + +- 创建费用申请单。 +- 创建报销草稿。 +- 将附件归集并绑定到某一任务。 +- 将附件关联到已有报销草稿。 +- 提交审批。 +- 修改已有草稿字段。 + +小财管家的确认动作采用“下一步优先”策略: + +- 同一个计划里同时存在申请和报销时,前端只展示当前下一步主动作,不一次性摊开全部确认按钮。 +- 下一步优先级为:费用申请单创建 > 报销单填写 > 附件归集确认。 +- 小财管家先思考和分析,说明下一步将要做的行为;用户输入“确定”或点击确认后,才进入行动。 +- 行动完成后重新检查剩余任务队列,继续进入“思考 -> 分析 -> 等待确认 -> 行动”的循环。 +- 申请任务完成后,再把剩余报销任务作为后续任务引导到报销助手。 +- 附件归集不作为第一屏主动作抢占申请流程;当进入报销任务时,相关附件随报销上下文带入。 + +可以自动执行的动作: + +- 任务拆解。 +- 本体字段归一化。 +- 附件分类。 +- 缺失字段检查。 +- 风险和规则预审。 +- 生成确认摘要。 + +### 4. 流式过程摘要 + +前端展示的是“意图识别智能体”的过程摘要,不是模型内部推理链;过程摘要必须独立于最终回复正文展示。 + +示例: + +```text +正在识别用户输入中的财务事项... +已识别到 3 个候选任务。 +正在按时间、地点和费用场景核对附件... +发现 3 张附件疑似属于差旅费用,1 张附件需要单独处理。 +等待你确认后,我再创建申请单或报销草稿。 +``` + +第一版通过 `POST /steward/plans/stream` 返回 `application/x-ndjson` 流式事件: + +- `thinking`:逐条追加到系统回复气泡上方的独立“意图识别智能体”折叠气泡。 +- `plan`:返回完整任务计划后,再渲染最终正文、任务卡片、附件归集和确认动作。 + +流式接口必须在模型 function calling 完成前先返回首个 `thinking` 事件,告知用户“意图识别智能体接管”。后续模型返回后再追加结构化拆解、字段映射、附件归集等过程摘要。 + +前端收到 `thinking` 事件后,也必须以 typewriter 方式逐字展示过程摘要,不能把一条完整思考事件一次性塞进折叠气泡。多条 `thinking` 事件应排队顺序输出,上一条内容打完后再输出下一条。 + +前端流式超时必须区分“首包等待”和“流式空闲等待”:首包应快速返回,收到首包后不能再用固定总时长中断仍在思考的模型调用,只能在长时间没有任何新事件时判定空闲超时。 + +流式过程中正文区域不输出任务结论;计划完成后意图识别气泡默认折叠,正文只保留用户需要确认和执行的信息。 + +计划完成后的最终正文也必须流式输出。前端不能把完整正文一次性替换到消息气泡里,而应进入 `typing` 状态按字符逐步追加正文;正文输出完成后,再把状态改为“等待用户确认”并展示确认按钮。 + +用户确认当前步骤后,小财管家隐式委派给申请能力或报销能力时,也必须保持同一套流式体验:先在系统气泡上方的小财管家思考折叠气泡中逐字展示“接收确认、协调能力、等待结果”等过程摘要;拿到申请核对表或报销核对结果后,再逐字输出正文。结构化表格、核对卡片、确认按钮可以在正文输出完成后一次性展示,但正文不能一次性替换进消息气泡。 + +小财管家委派期间不得打开右侧单助手执行流程面板,也不得把“申请助手 / 报销助手”的执行步骤显示成独立助手思考框。用户可见身份保持“小财管家”,具体调用哪个能力只作为小财管家自己的过程摘要,不切换为“财务助手”或单独助手会话。 + +### 5. 用户可见结果展示 + +小财管家的第一屏最终正文必须采用适中信息量的分段结构:让用户看懂系统理解了哪些财务事项、先后顺序是什么、每一步会交给哪个助手做什么;但不要把任务摘要、置信度、字段缺口和附件判断提前摊开。 + +第一屏推荐结构: + +1. `我会这样推进`:说明识别到几个财务事项,以及会逐步处理。 +2. 顺序列表:说明先做什么、后做什么,每步附一句负责助手和动作边界。 +3. 确认提示:请用户回复“确定”后开始第一步,并说明具体缺口会在对应步骤里再判断。 + +最终正文必须使用 Markdown 块结构渲染,至少包含标题、段落和顺序列表;标题与段落之间必须保留空行,并通过 `steward-plan-markdown` 专属样式拉开块间距。不能只依赖普通换行拼接文本,因为普通换行在对话气泡里会显得拥挤。 + +第一屏不展示任务详情卡片里的“还需要补充”,也不展示字段缺口说明。用户确认开始后,进入当前步骤的申请助手或报销助手,再由具体助手基于当前任务判断需要补充什么。 + +后续步骤如果需要展示“还需要补充”,必须是结构化列表,每个待补充项独立成行,包含字段业务名称和填写说明;不得把多个待补充项拼接成一行连续文本。 + +任务卡片和正文不得直接暴露本体字段名,例如 `transport_mode`、`amount`、`attachments`。本体字段只允许作为内部结构化数据进入后端、助手委派和持久化链路;用户界面必须翻译为业务中文,并提供可理解的填写说明: + +- `transport_mode` 展示为“出行方式”,说明可填写高铁、飞机、自驾、出租车等。 +- `amount` 在申请任务中展示为“预计金额”,在报销任务中展示为“报销金额”。 +- `attachments` 展示为“附件/凭证”,说明可上传发票、行程单、付款截图或其他证明材料。 +- `merchant_name` 展示为“商户/开票方”。 +- `customer_name` 展示为“客户或项目对象”。 + +## 本体字段约束 + +业务字段必须使用本体 canonical field: + +- `expense_type` +- `time_range` +- `location` +- `reason` +- `amount` +- `transport_mode` +- `attachments` +- `customer_name` +- `merchant_name` +- `department_name` +- `employee_name` +- `employee_no` + +兼容字段只能作为输入别名,例如: + +- `occurred_date` -> `time_range` +- `business_time` -> `time_range` +- `reason_value` -> `reason` +- `transport_type` -> `transport_mode` +- `application_transport_mode` -> `transport_mode` + +小财管家的编排态字段不进入业务语义本体: + +- `plan_id` +- `task_id` +- `planning_source` +- `model_call_traces` +- `task_status` +- `assigned_agent` +- `confirmation_status` +- `attachment_group_id` +- `thinking_event_id` + +这些字段只用于编排、展示和审计,不参与费用规则判断。 + +## 方案设计 + +### 后端 + +新增小财管家规划服务: + +- `schemas/steward.py`:定义请求、任务计划、附件归集、确认动作等契约。 +- `services/runtime_chat.py`:新增 `complete_with_tool_call`,复用主/备模型配置发送 `tools` 与 `tool_choice`。 +- `services/steward_intent_agent.py`:负责构造 `submit_steward_intent_plan` function schema 与模型调用。 +- `services/steward_model_plan_builder.py`:负责把模型工具参数转换为服务端可校验计划。 +- `services/steward_planner.py`:负责“大模型 function calling 优先、规则兜底”的编排和本体字段归一化。 +- `api/v1/endpoints/steward.py`:提供 `POST /steward/plans` 和 `POST /steward/plans/stream`。 + +后端第一版不直接落库业务单据,只返回计划和确认动作。确认后的执行仍走现有申请助手、报销助手和 Orchestrator。 + +### 前端 + +新增或改造能力: + +- 首页输入框标题和提示文案改为“小财管家”。 +- 工作台打开时默认使用 `sessionType=steward`。 +- 小财管家模式下隐藏“智能体切换”工具条。 +- 小财管家模式下不展示欢迎界面。 +- 小财管家模式下使用专属底部输入框,仅保留附件、自然语言输入和发送动作。 +- 小财管家模式下先流式渲染独立过程摘要,再渲染任务计划正文。 +- 用户确认当前下一步后,再切换/分派到申请助手或报销助手执行;多任务按顺序推进,不把所有任务动作一次性展示给用户。 + +### 执行流 + +```text +首页输入 + ↓ +小财管家计划接口 + ↓ +意图识别智能体 function calling + ↓ +思考过程流式输出 + 任务分析 + 下一步动作说明 + ↓ +等待用户输入“确定”或点击确认 + ↓ +小财管家隐式调用申请助手创建申请单核对结果 + ↓ +申请动作完成后重新思考剩余队列 + ↓ +继续等待确认并隐式调用报销助手填写报销单 + ↓ +执行结果汇总 +``` + +## 算法与公式 + +第一版主路径不以关键词规则定义“意图”,而是使用大模型 function calling 生成结构化计划。 +模型输出后由服务端做确定性校验、字段归一化和确认动作生成。 + +规则置信度评分仅用于模型不可用或模型返回结构不可用时的兜底路径。 + +任务置信度: + +$$ +confidence = \min(1, 0.35s_i + 0.25s_t + 0.2s_l + 0.2s_a) +$$ + +变量说明: + +- `s_i`:意图关键词得分,命中申请/报销核心动词。 +- `s_t`:时间得分,识别到明确日期、相对日期或时间范围。 +- `s_l`:地点得分,识别到城市、客户或业务对象。 +- `s_a`:附件/费用场景得分,识别到票据、交通、住宿、招待等费用线索。 + +附件归集置信度: + +$$ +group\_score = 0.4m_s + 0.3m_t + 0.2m_l + 0.1m_n +$$ + +变量说明: + +- `m_s`:附件场景与任务场景匹配度。 +- `m_t`:附件日期与任务日期匹配度。 +- `m_l`:附件地点与任务地点匹配度。 +- `m_n`:附件名称和任务关键词匹配度。 + +## 测试方案 + +### 后端单元测试 + +- function calling 路径能把模型工具参数转换为 `planning_source=llm_function_call` 的任务计划。 +- 模型返回 `occurred_date`、`transport_type`、`reason_value` 等别名时,服务端仍只输出 canonical 字段。 +- 一句话中同时包含申请和报销时,返回多个任务。 +- “昨天”能根据 `client_now_iso` 解析为明确日期。 +- `occurred_date`、`transport_type`、`reason_value` 等兼容字段不会作为业务 canonical 字段输出。 +- 多附件能生成差旅归集建议和排除项。 +- 创建/绑定/提交类动作必须带 `confirmation_required=true`。 + +### 前端测试 + +- 首页输入复杂话术后打开小财管家模式。 +- 小财管家模式标题显示“小财管家”,不展示智能体切换。 +- 过程摘要按步骤渐进展示。 +- 任务计划卡片展示申请任务和报销任务。 +- 附件归集建议展示包含项、排除项和确认按钮。 + +### 容器验证 + +后端测试必须在 `x-financial-main` 容器内执行: + +```bash +docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q server/tests/test_steward_planner.py +``` + +前端构建优先使用宿主机 `npm.cmd` 或项目既有脚本,并设置合理超时。 + +## 指标与验收 + +- 输入包含 3 个任务的示例话术时,至少识别出 1 个申请任务和 2 个报销任务。 +- 输入“明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费”时,必须识别出 1 个申请任务和 1 个报销任务。 +- 模型可用时,小财管家计划响应包含 `planning_source=llm_function_call`。 +- 小财管家计划响应中业务字段只出现 canonical ontology fields。 +- 附件场景混合时,能区分差旅相关附件和非差旅附件。 +- 前端弹窗标题为“小财管家”,并隐藏智能体切换。 +- 前端确认区只展示当前下一步主动作;存在申请任务时,第一步必须是“先创建申请单”。 +- 意图识别折叠气泡不得宽于正文气泡,且流式首包必须先于最终计划到达。 +- 用户未确认前,不创建申请单、不创建报销草稿、不绑定附件、不提交审批。 +- 后端定向测试通过。 + +## 风险与开放问题 + +- 模型供应商对 tools/function calling 的兼容度可能不同;第一版保留规则兜底和主备模型 failover。 +- 规则兜底无法覆盖所有自然语言,需要保留人工确认和低置信度提示。 +- 附件真实 OCR 归集依赖现有票据识别质量;第一版先使用附件名称和已有 OCR 摘要做轻量归集。 +- NDJSON 流式输出展示的是过程摘要,不是模型内部推理链。 +- 多任务之间可能共享日期、地点、申请单上下文,需要后续完善任务图依赖。 +- 如果未来接入 LangGraph,应基于当前计划契约迁移,而不是推翻现有申请/报销助手。 diff --git a/document/development/小财管家/TODO.md b/document/development/小财管家/TODO.md new file mode 100644 index 0000000..ba3147b --- /dev/null +++ b/document/development/小财管家/TODO.md @@ -0,0 +1,58 @@ +# 小财管家 TODO + +## 阶段一:调研与契约 + +- [x] 盘点首页输入框、工作台弹窗、会话路由和本体字段注册表。[CONCEPT: 背景与问题] 证据:已确认 `PersonalWorkbench.vue`、`useAppShell.js`、`TravelReimbursementCreateView.vue`、`ontology_field_registry.py`。 +- [x] 定义第一版只覆盖申请助手和报销助手,不引入 LangChain,但外层意图识别使用大模型 function calling。[CONCEPT: 目标与非目标] 证据:`CONCEPT.md` 已明确 MVP 边界和 function calling 主链路。 +- [x] 明确小财管家业务字段必须走 ontology canonical fields,编排字段不得进入业务本体。[CONCEPT: 本体字段约束] 证据:`CONCEPT.md` 已列出 canonical 字段与编排态字段。 + +## 阶段二:后端规划服务 + +- [x] 新增 `schemas/steward.py`,定义计划请求、任务、附件归集、确认动作和响应模型。[CONCEPT: 后端] 证据:`StewardPlanRequest`、`StewardTask`、`StewardAttachmentGroup`、`StewardConfirmationAction` 已新增。 +- [x] 扩展 `services/runtime_chat.py`,支持 OpenAI-compatible / Azure OpenAI 的 `tools` 与 `tool_choice` function calling。[CONCEPT: 后端] 证据:新增 `complete_with_tool_call`、`RuntimeChatToolCall` 和工具调用解析。 +- [x] 新增 `services/steward_intent_agent.py`,定义 `submit_steward_intent_plan` function schema 并调用系统主/备模型。[CONCEPT: 任务识别与拆分] 证据:模型调用入口已从 `StewardPlannerService` 注入。 +- [x] 新增 `services/steward_model_plan_builder.py`,将模型工具参数转换为服务端可校验计划。[CONCEPT: 后端] 证据:模型返回后仍会校验任务类型、canonical 字段和附件名。 +- [x] 改造 `services/steward_planner.py`,实现大模型 function calling 优先、规则规划兜底。[CONCEPT: 任务识别与拆分] 证据:`planning_source` 区分 `llm_function_call` 与 `rule_fallback`。 +- [x] 新增 `api/v1/endpoints/steward.py`,提供 `POST /steward/plans`。[CONCEPT: 后端] 证据:容器内接口 smoke 返回 `task_count=3`。 +- [x] 新增 `POST /steward/plans/stream`,以 NDJSON 返回 `thinking` 和最终 `plan` 事件。[CONCEPT: 流式过程摘要] 证据:真实接口 smoke 返回事件序列 `thinking,thinking,thinking,thinking,plan`。 +- [x] 调整 `POST /steward/plans/stream`,确保模型 function calling 完成前先返回首个 `thinking` 事件。[CONCEPT: 流式过程摘要] 证据:live smoke 首个事件为 `thinking/stream_start`。 +- [x] 在 `api/v1/router.py` 注册小财管家接口。[CONCEPT: 后端] 证据:运行中 `/api/v1/steward/plans` 返回 200。 +- [x] 保证所有输出到 `ontology_fields` 的业务字段只使用 canonical ontology fields。[CONCEPT: 本体字段约束] 证据:测试断言 `occurred_date`、`transport_type`、`reason_value` 不进入输出字段。 +- [x] 强化模型提示词和规则兜底,确保“未来出差/去某地几天/部署支撑”即使未出现“申请”也识别为费用申请。[CONCEPT: 任务识别与拆分] 证据:live smoke 将“明天出差北京3天...”拆为 `expense_application,reimbursement`。 + +## 阶段三:前端入口和弹窗 + +- [x] 将首页输入区主文案调整为“小财管家”,提示语体现可处理多任务。[CONCEPT: 前端] 证据:`PersonalWorkbench.vue` 标题和 placeholder 已更新。 +- [x] 增加 `steward` 会话类型,首页复杂输入默认进入小财管家模式。[CONCEPT: 前端] 证据:`SESSION_TYPE_STEWARD` 与首页 `sessionType` 已接入。 +- [x] 小财管家模式下隐藏“智能体切换”工具条。[CONCEPT: 前端] 证据:`shortcuts` 在 `isStewardSession` 下返回空数组。 +- [x] 小财管家模式下标题显示“小财管家”,副标题说明“统一财务任务编排入口”。[CONCEPT: 前端] 证据:`assistantHeaderTitle` 和 `assistantHeaderDescription` 已按 steward 分支处理。 +- [x] 小财管家模式下不展示欢迎界面。[CONCEPT: 前端] 证据:`useTravelReimbursementSessionState.js` 对 steward 空会话返回空消息,并过滤旧欢迎消息快照。 +- [x] 小财管家模式下使用专属底部输入框,不展示日期选择、差旅计算器和业务时间标签。[CONCEPT: 前端] 证据:`TravelReimbursementCreateView.vue` 按 `isStewardSession` 渲染 `steward-composer-row`。 +- [x] 新增前端小财管家计划服务,调用 `POST /steward/plans`。[CONCEPT: 后端] 证据:`web/src/services/steward.js` 已新增 `fetchStewardPlan`。 +- [x] 新增小财管家视图模型,生成过程摘要、任务计划卡片和附件归集卡片。[CONCEPT: 流式过程摘要] 证据:`stewardPlanModel.js` 和 `TravelReimbursementMessageItem.vue` 已接入 `stewardPlan`。 +- [x] 支持后端 `thinking` 事件真流式呈现为折叠式意图识别气泡。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 通过 `fetchStewardPlanStream` 接收 thinking 事件,并用 `typeStewardThinkingEvent` 将每条过程摘要逐字输出到折叠气泡。 +- [x] 支持小财管家最终正文逐字流式输出,正文完成前不展示确认按钮。[CONCEPT: 流式过程摘要] 证据:`useStewardPlanFlow.js` 新增 `typeStewardPlanText`,计划完成后进入 `typing` 状态逐字追加正文,完成后再注入 `suggestedActions`。 +- [x] 意图识别过程放在系统回复气泡上方,作为不同颜色的独立折叠气泡,完成后默认折叠。[CONCEPT: 流式过程摘要] 证据:`TravelReimbursementMessageItem.vue` 将 `steward-intent-bubble` 放在 `message-bubble` 上方,`steward-plan-block` 只渲染任务和附件结果。 +- [x] 统一小财管家思考折叠气泡与正文气泡宽度,避免思考气泡长于正文框。[CONCEPT: 流式过程摘要] 证据:`has-steward-plan` 消息栈统一为 760px,思考气泡和正文气泡同宽。 +- [x] 优化小财管家最终正文和任务卡片层次,用户可见内容不直接展示本体字段名。[CONCEPT: 用户可见结果展示] 证据:`stewardPlanModel.js` 第一屏使用 Markdown 标题、段落和顺序列表说明“先做什么、后做什么、交给哪个助手做什么”,但不展示置信度和字段缺口;`useStewardPlanFlow.js` 将第一屏标记为 `initialSummaryOnly`,`TravelReimbursementMessageItem.vue` 不再渲染第一屏任务详情卡片;后续步骤如需展示待补充项,再按独立列表行展示业务名称和填写说明。 + +## 阶段四:确认与分派 + +- [x] 为每个创建/绑定/提交类动作生成确认按钮,不确认不执行。[CONCEPT: 用户确认] 证据:接口返回 `confirmation_count=4`,前端转为 suggested actions。 +- [x] 将小财管家确认区改为“只展示当前下一步主动作”,存在申请任务时优先进入申请助手。[CONCEPT: 用户确认] 证据:`buildStewardSuggestedActions` 只返回下一步动作,优先 `confirm_create_application`。 +- [x] 支持用户输入“确定”触发小财管家的下一步动作,而不是重新生成计划。[CONCEPT: 用户确认] 证据:`useStewardPlanFlow` 会查找待确认的小财管家动作并执行。 +- [x] 支持小财管家隐藏委派申请/报销能力,执行后保留小财管家会话并继续引导剩余任务。[CONCEPT: 执行流] 证据:`sessionTypeOverride` 和 `stewardContinuation` 已接入前端提交流程。 +- [x] 支持小财管家确认后的隐式委派继续流式输出,正文完成后再展示申请核对表、报销核对卡片和确认按钮。[CONCEPT: 流式过程摘要] 证据:`useTravelReimbursementSubmitComposer.js` 新增 `typeStewardDelegatedMessage`,申请预览与 orchestrator 结果均先流式思考、再逐字正文、最后挂载结构化 payload;`npm.cmd --prefix web run build` 成功。 +- [x] 小财管家委派申请/报销能力期间不打开右侧单助手执行流程面板,用户可见身份保持“小财管家”。[CONCEPT: 流式过程摘要] 证据:`stewardDelegated` 分支跳过 flow step 与 review panel scope,并在最终消息设置 `assistantName: '小财管家'`;`stewardPlanModel.js` 助手标签兜底不再显示“财务助手”。 +- [x] 确认申请任务后,将任务摘要分派到现有申请助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=application` 和 `auto_submit=true`。 +- [x] 确认报销任务后,将任务摘要和附件带入现有报销助手会话。[CONCEPT: 执行流] 证据:确认动作携带 `session_type=expense`、`carry_files=true` 和 `auto_submit=true`。 +- [x] 附件归集建议确认后,将选中附件作为报销助手上下文继续处理。[CONCEPT: 附件归集] 证据:附件归集确认动作携带归集附件名称和排除附件名称。 + +## 阶段五:测试与验证 + +- [x] 新增 `server/tests/test_steward_planner.py`,覆盖多任务拆解、相对日期、附件归集、确认动作和流式事件。[CONCEPT: 测试方案] 证据:新增 4 个后端定向测试。 +- [x] 补充 function calling 定向测试,覆盖模型工具参数、canonical 字段清洗、附件归集和规则兜底。[CONCEPT: 后端单元测试] 证据:`test_steward_planner.py` 新增 fake function calling 路径,`test_runtime_chat_service.py` 新增 tools payload 用例。 +- [x] 后端测试在 Docker `x-financial-main:/app` 内执行,超时控制在 60s 内。[CONCEPT: 容器验证] 证据:`pytest -q server/tests/test_steward_planner.py server/tests/test_runtime_chat_service.py` 结果 `13 passed`。 +- [ ] 新增或更新前端定向测试,覆盖小财管家标题、隐藏智能体切换和计划展示。[CONCEPT: 前端测试] +- [x] 运行前端构建或定向测试,确认 UI 编译通过。[CONCEPT: 测试方案] 证据:`npm.cmd run build` 成功。 +- [x] 通过接口或页面可见结果证明用户最终看到小财管家计划和确认点。[CONCEPT: 指标与验收] 证据:容器接口返回 3 个任务、3 份归集附件、1 份排除附件和 4 个确认动作。 diff --git a/document/development/附件上传风险前置复核/CONCEPT.md b/document/development/附件上传风险前置复核/CONCEPT.md new file mode 100644 index 0000000..793a339 --- /dev/null +++ b/document/development/附件上传风险前置复核/CONCEPT.md @@ -0,0 +1,86 @@ +# 附件上传风险前置复核 + +## 功能一句话 + +报销附件上传并完成 OCR 识别后立即执行完整风险复核,提交审批时只做轻量最终校验、预算占用和流程流转。 + +## 背景与问题 + +当前报销单提交阶段会同步执行较重的风险检查,包括附件风险汇总、差旅规则、场景规则、规则中心风险、历史行为统计和风险观测写入。用户在点击提交后会等待较长时间,容易误认为页面卡住。 + +风险的主要依据来自已上传票据、OCR 识别结果、费用明细、关联申请单和员工历史行为。这些数据在附件上传完成后已经基本具备,因此完整风险复核应前移到上传完成阶段。 + +## 目标与非目标 + +目标: + +- 附件上传成功后自动刷新费用明细、附件风险、差旅/场景/规则中心风险和 AI 预审标识。 +- 风险复核结果写回 `claim.risk_flags_json`,并持久化规则中心风险观测。 +- 提交阶段不再重复跑完整 `_run_ai_submission_review()`。 +- 提交阶段只保留草稿完整性校验、预算占用、未处理阻断风险判断、状态流转、审计日志和助手会话清理。 + +非目标: + +- 不新增业务字段。 +- 不改变现有风险规则语义。 +- 不把提交改成真正的后端异步任务队列。 + +## 用户与场景 + +- 报销申请人:上传票据后立即看到风险建议和需补充说明,不必等到提交时才发现问题。 +- 直属领导和财务人员:收到单据时可看到提交前已生成的风险提示和用户处理结果。 +- 系统管理员:风险观测仍可进入后台统计。 + +## 功能能力 + +上传完成后: + +- 根据 OCR 结果回填费用明细类型、日期、金额、事由等已有字段。 +- 刷新附件级 `attachment_analysis` 风险。 +- 执行报销级风险复核,并生成 `ai_pre_review` 状态。 +- 对规则中心命中的风险写入 `risk_observations`。 + +提交审批时: + +- 如果存在高风险且用户未处理,继续阻止提交或要求说明/按职级测算。 +- 如果风险已处理,只做预算和流程流转。 +- 不再重复生成一套提交阶段风险。 + +## 方案设计 + +后端: + +- 在 `ExpenseClaimService.upload_claim_item_attachment()` 中,OCR、附件分析和 `_sync_claim_from_items()` 完成后,调用上传后风险复核 helper。 +- 新增 helper 复用现有 `_run_ai_submission_review()` 与 `_replace_ai_pre_review_flag()`,但保持单据状态为草稿。 +- 提交阶段读取既有风险结果,只做最终阻断风险判断,不重复调用 `_run_ai_submission_review()`。 + +前端: + +- 继续使用当前附件识别中的状态条。 +- 上传完成后通过接口返回的 `claim_risk_flags` 更新 AI 建议区和风险标识。 +- 提交时只显示轻量后台提交流程提示。 + +## 算法与公式 + +当前功能不涉及新的显式数学公式。风险评分和风险等级沿用现有规则中心、附件分析、差旅政策和风险观测逻辑。 + +## 测试方案 + +- 后端单元测试:附件上传后写入 `ai_pre_review` 和 `submission_review` 风险。 +- 后端单元测试:提交阶段不再调用完整 `_run_ai_submission_review()`。 +- 后端单元测试:上传后规则中心风险可写入 `risk_observations`。 +- 前端静态回归:提交确认仍为后台提交,不恢复阻塞弹窗。 +- 构建验证:`npm.cmd --prefix web run build`。 + +## 指标与验收 + +- 上传附件后,接口响应的 `claim_risk_flags` 包含最新复核结果。 +- 提交接口耗时不再包含完整风险复核耗时。 +- 提交后审批人仍能看到已前置生成的风险提示。 +- 后端和前端相关回归测试通过。 + +## 风险与开放问题 + +- 如果用户上传后又修改费用明细,现有 `update_claim_item()` 需要继续刷新附件风险和报销级风险。 +- 如果用户没有上传附件直接提交,提交阶段仍需要保留兜底风险复核或阻断提示。 +- 未来可进一步把上传后复核做成真正后台任务,但本次先保持同步接口返回最新风险结果。 diff --git a/document/development/附件上传风险前置复核/TODO.md b/document/development/附件上传风险前置复核/TODO.md new file mode 100644 index 0000000..18e4bcc --- /dev/null +++ b/document/development/附件上传风险前置复核/TODO.md @@ -0,0 +1,28 @@ +# 附件上传风险前置复核 TODO + +## 调研与契约 + +- [x] 盘点附件上传、预审、提交链路,确认完整风险复核当前在提交阶段重复执行。[CONCEPT: 背景与问题] +- [x] 明确上传后复核 helper 的输入输出契约,不新增业务字段。[CONCEPT: 方案设计] 证据:新增 `_refresh_claim_pre_review_flags()` 复用现有风险字段。 + +## 后端实现 + +- [x] 在附件上传完成后触发报销级风险复核,并保持单据状态为草稿。[CONCEPT: 功能能力] 证据:`upload_claim_item_attachment()` 调用 `_refresh_claim_pre_review_flags()`。 +- [x] 上传后风险复核写回 `ai_pre_review` 和 `submission_review` 风险结果。[CONCEPT: 功能能力] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。 +- [x] 规则中心风险在上传后写入 `risk_observations`,避免提交阶段集中写入。[CONCEPT: 方案设计] 证据:上传后复核复用 `_run_ai_submission_review()`,平台风险仍调用 `RiskObservationService.upsert_platform_risk_flags()`。 +- [x] 提交阶段改为读取既有风险结果,只做最终校验、预算占用和流转。[CONCEPT: 目标与非目标] 证据:`submit_claim()` 仅在缺少 `ai_pre_review` 时兜底复核。 +- [x] 保留“无附件直接提交”的兜底检查,避免绕过风险复核。[CONCEPT: 风险与开放问题] 证据:`test_submit_claim_runs_ai_review_and_routes_to_direct_manager` 通过。 + +## 前端实现 + +- [x] 确认上传完成后 UI 使用接口返回的 `claim_risk_flags` 刷新 AI 建议与行风险标识。[CONCEPT: 前端] 证据:`travel-request-detail-risk-advice.test.mjs` 通过。 +- [x] 确认提交阶段不恢复阻塞弹窗,只显示轻量后台提交提示。[CONCEPT: 前端] 证据:`travel-request-detail-submit-confirm.test.mjs` 通过。 + +## 测试与验证 + +- [x] 后端测试:附件上传后自动生成预审风险结果。[CONCEPT: 测试方案] 证据:`test_upload_attachment_refreshes_claim_pre_review` 通过。 +- [x] 后端测试:提交阶段不重复调用完整风险复核。[CONCEPT: 测试方案] 证据:`test_submit_claim_reuses_upload_pre_review_without_rerunning_review` 通过。 +- [x] 后端测试:风险观测仍被持久化。[CONCEPT: 测试方案] 证据:`test_risk_observation_storage_ready_is_cached_per_bind` 通过。 +- [x] 前端回归测试通过。[CONCEPT: 测试方案] 证据:54 个详情页风险/提交测试通过。 +- [x] `npm.cmd --prefix web run build` 通过。[CONCEPT: 测试方案] 证据:前端生产构建通过,仅保留既有 Rollup 注释与 chunk size 警告。 +- [x] Docker `x-financial-main` 容器内后端定向测试通过。[CONCEPT: 测试方案] 证据:核心上传前置复核、提交复用预审、申请/报销风险回归测试通过。 diff --git a/server/src/app/api/v1/endpoints/steward.py b/server/src/app/api/v1/endpoints/steward.py index 6626a8c..a0e5839 100644 --- a/server/src/app/api/v1/endpoints/steward.py +++ b/server/src/app/api/v1/endpoints/steward.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_db from app.schemas.common import ErrorResponse -from app.schemas.steward import StewardPlanRequest, StewardPlanResponse +from app.schemas.steward import StewardPlanRequest, StewardPlanResponse, StewardThinkingEvent from app.services.runtime_chat import RuntimeChatService from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_planner import StewardPlannerService @@ -55,6 +55,18 @@ async def _iter_steward_plan_events( payload: StewardPlanRequest, planner: StewardPlannerService, ) -> AsyncIterator[str]: + yield _encode_stream_event( + "thinking", + StewardThinkingEvent( + event_id="intent_agent_stream_start", + stage="stream_start", + title="意图识别智能体接管", + content="已收到任务描述,正在调用小财管家意图识别智能体拆解申请、报销和附件线索。", + status="running", + ).model_dump(mode="json"), + ) + await asyncio.sleep(0) + try: plan = planner.build_plan(payload) except ValueError as exc: diff --git a/server/src/app/services/steward_intent_agent.py b/server/src/app/services/steward_intent_agent.py index 009dc8d..1ac60ba 100644 --- a/server/src/app/services/steward_intent_agent.py +++ b/server/src/app/services/steward_intent_agent.py @@ -42,7 +42,7 @@ class StewardIntentAgent: }, max_tokens=1800, temperature=0.1, - timeout_seconds=18, + timeout_seconds=45, max_attempts=1, ) self.last_call_traces = result.calls_as_dicts() @@ -105,6 +105,9 @@ class StewardIntentAgent: "你必须通过 function calling 输出结构化计划,不能只返回普通文本。" "当前版本只支持 expense_application 和 reimbursement 两类任务;" "你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。" + "用户描述未来出差、差旅计划、去某地几天、部署、支撑、拜访或会议安排时," + "即使没有出现“申请”两个字,也必须优先识别为 expense_application。" + "用户描述已经发生的费用、昨天/前天费用、票据或明确报销诉求时,才识别为 reimbursement。" "所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields;" "如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。" "相对日期必须以 base_date 为准转换为明确日期。" diff --git a/server/src/app/services/steward_model_plan_builder.py b/server/src/app/services/steward_model_plan_builder.py index 3c5919e..736c03b 100644 --- a/server/src/app/services/steward_model_plan_builder.py +++ b/server/src/app/services/steward_model_plan_builder.py @@ -307,7 +307,7 @@ class StewardModelPlanBuilder: return "travel" if normalized in {"transport", "traffic", "交通", "交通费", "打车", "出租车"}: return "transport" - if normalized in {"entertainment", "meal", "招待", "接待", "餐饮", "业务招待"}: + if normalized in {"entertainment", "meal", "招待", "招待费", "接待", "接待费", "餐饮", "业务招待", "业务招待费"}: return "entertainment" if normalized in {"office", "办公", "办公用品"}: return "office" diff --git a/server/src/app/services/steward_planner.py b/server/src/app/services/steward_planner.py index 9c24027..ebcfda4 100644 --- a/server/src/app/services/steward_planner.py +++ b/server/src/app/services/steward_planner.py @@ -148,7 +148,7 @@ class StewardPlannerService: drafts: list[PlannedTaskDraft] = [] first_reimbursement = self._find_first_reimbursement_index(message) application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message - if self._looks_like_application(application_source): + if self._looks_like_application(application_source) or self._looks_like_future_travel_application(application_source): drafts.append( PlannedTaskDraft( task_type="expense_application", @@ -180,6 +180,31 @@ class StewardPlannerService: compact = re.sub(r"\s+", "", text) return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact)) + @staticmethod + def _looks_like_future_travel_application(text: str) -> bool: + compact = re.sub(r"\s+", "", text) + if not compact or "报销" in compact: + return False + business_signal = re.search( + r"出差|差旅|客户现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收", + compact, + ) + route_signal = re.search( + fr"(?:去|到|赴|前往)({'|'.join(CITY_NAMES)})", + compact, + ) + time_signal = re.search( + r"明天|后天|下周|下月|近期|月底|\d{1,2}月\d{1,2}(?:日|号)?|" + r"\d{4}[-/年]\d{1,2}[-/月]\d{1,2}(?:日)?|[0-9一二两三四五六七八九十]+天", + compact, + ) + planned_route_signal = re.search( + r"(?:去|到|赴|前往).{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|" + r"(?:出差|差旅).{0,24}(?:[0-9一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)", + compact, + ) + return bool((business_signal or route_signal) and (time_signal or planned_route_signal)) + def _build_task( self, draft: PlannedTaskDraft, @@ -352,7 +377,12 @@ class StewardPlannerService: @staticmethod def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float: compact = re.sub(r"\s+", "", segment) - intent_score = 1.0 if ("申请" in compact if task_type == "expense_application" else "报销" in compact) else 0.45 + if task_type == "expense_application": + intent_score = 1.0 if ( + "申请" in compact or StewardPlannerService._looks_like_future_travel_application(compact) + ) else 0.45 + else: + intent_score = 1.0 if "报销" in compact else 0.45 time_score = 1.0 if fields.get("time_range") else 0.0 location_score = 1.0 if fields.get("location") else 0.2 scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35 diff --git a/server/tests/test_steward_planner.py b/server/tests/test_steward_planner.py index 2b992e0..315d61f 100644 --- a/server/tests/test_steward_planner.py +++ b/server/tests/test_steward_planner.py @@ -68,6 +68,31 @@ class EmptyFunctionCallingIntentAgent: return None +class EntertainmentFunctionCallingIntentAgent: + def detect(self, request, *, base_date, canonical_fields): + return StewardIntentAgentResult( + payload={ + "thinking_events": [], + "tasks": [ + { + "task_type": "reimbursement", + "title": "业务招待费报销", + "summary": "报销昨天业务招待费。", + "confidence": 0.9, + "ontology_fields": { + "time_range": "昨天", + "expense_type": "业务招待费", + "reason": "业务招待", + }, + "missing_fields": [], + } + ], + "attachment_groups": [], + }, + model_call_traces=[], + ) + + def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: payload = StewardPlanRequest( message="我要报销昨天客户现场沟通的交通费", @@ -98,6 +123,19 @@ def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None assert result.thinking_events[0].stage == "llm_function_call" +def test_steward_planner_normalizes_llm_business_entertainment_expense_type() -> None: + payload = StewardPlanRequest( + message="报销昨天业务招待费", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService(intent_agent=EntertainmentFunctionCallingIntentAgent()).build_plan(payload) + + assert result.planning_source == "llm_function_call" + assert result.tasks[0].ontology_fields["expense_type"] == "entertainment" + assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03" + + def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None: payload = StewardPlanRequest( message="我要报销昨天的交通费", @@ -141,6 +179,29 @@ def test_steward_planner_splits_application_and_reimbursement_tasks() -> None: assert all(action.status == "pending" for action in result.confirmation_groups) +def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None: + payload = StewardPlanRequest( + message="明天出差北京3天,支撑国网仿生产部署,并且报销昨天业务招待费", + user_id="u001", + client_now_iso="2026-06-04T09:30:00+08:00", + ) + + result = StewardPlannerService().build_plan(payload) + + assert [task.task_type for task in result.tasks] == [ + "expense_application", + "reimbursement", + ] + assert result.tasks[0].assigned_agent == "application_assistant" + assert result.tasks[0].ontology_fields["time_range"] == "2026-06-05" + assert result.tasks[0].ontology_fields["location"] == "北京" + assert result.tasks[0].ontology_fields["expense_type"] == "travel" + assert result.tasks[0].ontology_fields["reason"] == "支撑国网仿生产部署" + assert result.tasks[1].assigned_agent == "reimbursement_assistant" + assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03" + assert result.tasks[1].ontology_fields["expense_type"] == "entertainment" + + def test_steward_planner_outputs_only_canonical_ontology_fields() -> None: payload = StewardPlanRequest( message="我要报销昨天的交通费", @@ -210,5 +271,6 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None: ] assert [event["event"] for event in events][:2] == ["thinking", "thinking"] + assert events[0]["data"]["stage"] == "stream_start" assert events[-1]["event"] == "plan" assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03" diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index 8757c95..dfb2291 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -29,6 +29,10 @@ gap: 8px; } +.message-row.has-steward-plan .message-stack { + width: min(100%, 760px); +} + .message-row.user .message-stack { order: 1; justify-items: end; @@ -64,8 +68,15 @@ box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14); } +.message-row.has-steward-plan .message-bubble { + width: 100%; + max-width: none; + box-sizing: border-box; +} + .steward-intent-bubble { - width: min(100%, 680px); + width: 100%; + box-sizing: border-box; border: 1px solid #c9ddea; border-radius: 4px; background: #eef6fb; @@ -144,6 +155,17 @@ line-height: 1.55; } +.steward-intent-event-list li span.typing::after { + content: ""; + width: 5px; + height: 12px; + display: inline-block; + margin-left: 3px; + vertical-align: -2px; + background: #3a7ca5; + animation: steward-plan-caret 900ms steps(1, end) infinite; +} + .steward-intent-empty { margin: 0; padding: 0 12px 12px; @@ -231,6 +253,229 @@ font-weight: 850; } +.steward-plan-markdown { + display: block; +} + +.steward-plan-markdown :deep(h2) { + margin: 0 0 10px; + padding-bottom: 8px; + border-bottom: 1px solid #dbe4ee; + color: #0f172a; + font-size: 15px; + font-weight: 860; + line-height: 1.42; +} + +.steward-plan-markdown :deep(hr) { + margin: 10px 0 14px; + border: 0; + border-top: 1px solid #e2e8f0; +} + +.steward-plan-markdown :deep(h3) { + margin: 16px 0 8px; + padding-left: 8px; + border-left: 3px solid var(--theme-primary, #3a7ca5); + color: #183247; + font-size: 13px; + font-weight: 840; + line-height: 1.45; +} + +.steward-plan-markdown :deep(h3:first-child) { + margin-top: 0; +} + +.steward-plan-markdown :deep(p) { + margin: 0 0 10px; + color: #334155; + line-height: 1.72; +} + +.steward-plan-markdown :deep(ul) { + margin: 0 0 12px; + padding-left: 18px; + display: grid; + gap: 7px; +} + +.steward-plan-markdown :deep(li) { + margin: 0; + color: #334155; + line-height: 1.65; +} + +.steward-plan-typing::after { + content: ""; + width: 6px; + height: 15px; + display: inline-block; + margin-left: 3px; + vertical-align: -2px; + background: var(--theme-primary, #3a7ca5); + animation: steward-plan-caret 900ms steps(1, end) infinite; +} + +@keyframes steward-plan-caret { + 0%, + 45% { + opacity: 1; + } + + 46%, + 100% { + opacity: 0; + } +} + +.steward-plan-block { + margin-top: 14px; + display: grid; + gap: 12px; +} + +.steward-task-list, +.steward-attachment-list { + display: grid; + gap: 10px; +} + +.steward-task-card, +.steward-attachment-card { + padding: 12px; + border: 1px solid #dbe7f2; + border-radius: 4px; + background: #f8fbfe; +} + +.steward-task-header, +.steward-attachment-card header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.steward-task-type, +.steward-attachment-card header span { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border: 1px solid #c7deef; + border-radius: 4px; + background: #eef7fc; + color: #24618a; + font-size: 12px; + font-weight: 780; +} + +.steward-task-agent, +.steward-attachment-card header small { + color: #64748b; + font-size: 12px; + font-weight: 720; +} + +.steward-task-body { + display: grid; + gap: 5px; +} + +.steward-task-title { + display: block; + color: #172a3a; + font-size: 14px; + font-weight: 850; + line-height: 1.45; +} + +.steward-task-summary, +.steward-attachment-card p { + margin: 0; + color: #5c7185; + font-size: 12px; + line-height: 1.62; +} + +.steward-task-meta, +.steward-attachment-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} + +.steward-task-meta span, +.steward-attachment-chip { + border: 1px solid #d5e2ee; + border-radius: 4px; + background: #ffffff; + color: #49677f; + font-size: 12px; + line-height: 1.4; + padding: 3px 7px; +} + +.steward-task-missing { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #d5e2ee; + display: grid; + gap: 8px; +} + +.steward-task-missing-label { + color: #425c72; + font-size: 12px; + font-weight: 820; +} + +.steward-task-missing-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.steward-task-missing-list li { + display: grid; + grid-template-columns: minmax(86px, 120px) minmax(0, 1fr); + align-items: start; + gap: 8px; + padding: 7px 8px; + border: 1px solid #e1e8f0; + border-radius: 4px; + background: #ffffff; +} + +.steward-task-missing-list strong { + color: #1f3448; + font-size: 12px; + font-weight: 820; + line-height: 1.45; +} + +.steward-task-missing-list small { + color: #6b7f92; + font-size: 12px; + line-height: 1.45; +} + +.steward-attachment-chip.include { + border-color: #c7deef; + background: #eef7fc; + color: #24618a; +} + +.steward-attachment-chip.exclude { + border-color: #ecd6c4; + background: #fff8f2; + color: #8a5a24; +} + .message-answer-markdown :deep(.markdown-table-wrap) { width: 100%; max-width: 100%; @@ -811,4 +1056,9 @@ max-width: 100%; border-radius: 12px; } + + .steward-task-missing-list li { + grid-template-columns: 1fr; + gap: 3px; + } } diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css index 726c029..984f9ae 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css @@ -141,6 +141,42 @@ padding: 3px 7px; } +.steward-task-missing { + margin-top: 9px; + padding-top: 9px; + border-top: 1px dashed #d5e2ee; + display: grid; + gap: 6px; +} + +.steward-task-missing-label { + color: #5d7489; + font-size: 12px; + font-weight: 780; +} + +.steward-task-missing-chip { + display: grid; + gap: 2px; + padding: 7px 8px; + border: 1px solid #e1e8f0; + border-radius: 4px; + background: #ffffff; +} + +.steward-task-missing-chip strong { + color: #1f3448; + font-size: 12px; + font-weight: 820; + line-height: 1.35; +} + +.steward-task-missing-chip small { + color: #6b7f92; + font-size: 11px; + line-height: 1.45; +} + .steward-attachment-chip.include { border-color: #c7deef; background: #eef7fc; diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index 3c5e8ca..3beb1df 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -1,7 +1,7 @@