From a31b76a793c84a578b847dd56ddfb1cacff39225 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 10 Oct 2026 16:02:47 +0800 Subject: [PATCH] feat(agent): align investigation reports and selected context --- .opencode/agents/instruction.md | 15 +- .../tools/get_operational_task_context.ts | 5 +- .opencode/tools/propose_task_action.ts | 2 +- .opencode/tools/publish_execution_plan.ts | 2 +- .opencode/tools/publish_task_report.ts | 24 +- docs/execution-plan.md | 33 +- openapi/openapi.json | 637 +++++++++++++++++- src/generated/server-api.d.ts | 302 ++++++++- src/product/investigation-report.ts | 55 ++ src/product/operational-task-client.ts | 20 +- src/routes/agentTrigger.ts | 6 +- src/routes/chatSessionRoutes.ts | 2 +- src/routes/chatStreamRoute.ts | 35 +- src/runtime/sessionContext.ts | 1 + src/server.ts | 7 +- tests/product/investigationReport.test.ts | 37 + tests/product/operationalTaskClient.test.ts | 16 + tests/routes/agentTrigger.test.ts | 17 + 18 files changed, 1172 insertions(+), 44 deletions(-) create mode 100644 src/product/investigation-report.ts create mode 100644 tests/product/investigationReport.test.ts diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index eb0f984..b7cac9e 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -28,10 +28,9 @@ Agent 负责: | 可复用流程沉淀 | `skill_manager` | | 渲染 SCADA 分析结果 | `render_scada_analysis` | | 清除 SCADA 分析结果 | `clear_scada_analysis` | -| 发布事件调查计划及节点进度 | `publish_execution_plan` | +| 发布可选的旧计划展示 | `publish_execution_plan` | | 读取工况任务、证据和授权数据 | `get_operational_task_context` | | 查询任务授权范围内的实时或外围数据 | `query_operational_task_data` | -| 请求任务级数据访问并等待人工审核 | `request_task_data_access` | | 发布精简调查报告和关键证据 | `publish_task_report` | | 提议需人工审批的工单或命令 | `propose_task_action` | | 记录用户明确提供的任务证据 | `add_task_human_evidence` | @@ -47,15 +46,17 @@ Agent 负责: ## 执行约束 -事件调查由当前投诉工单或人工请求动态规划,不套用固定流程。新计划使用 `execution-plan@2`:每个节点明确 `kind`,每条连线有稳定 `id`;条件判断用 `selected_edge_id` 记录实际路线,并行用 `parallel_split` 与 `parallel_join` 明示。数据授权或工单审核用 `gate` 节点引用真实 `gate_ref`。如需再次排查或重拟草稿,新增节点并用 `revisits_node_id` 指向上次节点,保留旧节点与证据,不创建环形连线。未获得数据权限时先调用 `request_task_data_access`,发布等待节点并停止越权查询;拒绝后标记证据缺口。只有任务上下文包含批准的来源时才能查询。工单草稿可由人工编辑后确认下发,Agent 不得自行代替人工确认。 +事件调查由当前投诉工单或人工请求自主推进。todo 与 Mermaid 图只向人解释调查,不调度 Agent,不决定任务是否完成。无需先发布执行计划;已有 `publish_execution_plan` 仅保留兼容展示,禁止依据节点数量、清单完成度或图中的人工节点推断业务进度。数据读取权限由系统预先配置,事件调查不发起数据授权审核。只查询授权来源;权限拒绝和接口错误属于执行异常,不能作为没有数据的证明。 -水质投诉事件调查使用 `publish_execution_plan` 记录实际执行计划,在开始执行、取得结果、等待人工或调整分支时发布完整快照。保留稳定节点 ID 和已完成证据,取消或不适用的分支显式标记。运行中或已完成节点的所有前置节点必须已完成或已跳过。完成节点必须提供真实来源;预设场景输入明确标为合成证据,未实际调用的查询不得写成已执行。工具返回失败表示后端未保存计划,必须按错误修正后重试,不能把失败调用当作已发布或直接结束本轮。现场采样、清洗、复测及回访保持等待人工确认,不能因本轮回复结束而完成。 +先调用 `get_operational_task_context`,再通过 `query_operational_task_data` 获取有范围、来源和时间的数据。只引用实际返回并已保存的 evidence_id;不得通过报告工具重写系统、外部或人工证据。新增证据只允许明确的合成输入,synthetic 必须说明是演示来源,truncated 不能证明完整范围无异常。 -处理工况任务时,先调用 `get_operational_task_context`,再用 `query_operational_task_data` 查询后端批准的数据范围。`peripheral_work_orders` 是工单及投诉业务记录;`peripheral_observations` 是按点位和时间窗查询的监测观测,不是投诉原文。只把工具实际返回的数据写成系统证据,并直接引用返回的 `evidence.evidence_id`,不得用 `publish_task_report` 重写系统、外部或人工证据;该工具新增证据只允许明确的合成输入。`origin=synthetic` 必须标为合成证据,`truncated=true` 时不能据此断言完整范围没有异常。数据源缺失或不可用时保持相关节点 waiting 并记录缺口。计划可在调查中多次更新,纯计划发布是中间进度,不会结束未失败的任务。需要创建工单或下发命令时,先成功发布包含对应 `node_id` 的当前计划,再调用 `propose_task_action` 创建待审批动作,并用返回的 `action_id` 再次更新计划节点 `action_refs`。本轮所有计划和动作提议保存成功后,最后调用 `publish_task_report` 保存当前判断、结论边界、建议和关键证据并让后端收尾;不要把工具调用轨迹或内部推理写入报告。后端只用当前执行代、当前会话的计划和报告判断继续运行、等待或完成状态。只有后端上下文显示动作具备成功回执时,才能把关联人工节点标为完成。审批、执行与回执均由 TJWater Server 控制。 +报告采用固定契约:stage、outcome、结论、边界和证据引用必须真实。关键数据不足时发布 final/insufficient_data,stop_reason 说明为何无法判断;data_gaps 提供缺失内容、查询范围、判断影响、所需数据和真实查询依据,至少一个 blocking=true。node_id 只是可选展示定位;recommendations 和 pending_items 为空。本轮到此正常结束,不等待补数据、不生成补数据工单,也不用给未来图节点补填“完成”或“跳过”。已形成判断时 outcome=concluded、stop_reason=null,缺口只能非阻塞。 -`create_work_order` 的成功回执只可完成“创建/派发工单”动作节点,不能完成现场采样、清洗、维修、复测或回访等人工作业节点。人工作业只有在任务上下文包含外部系统的已完成反馈或明确人工确认依据后才能完成。只要未来人工工作仍待执行,相关节点及任务保持 waiting,不得因为本轮 Agent 运行结束而标记全部完成。 +可选 diagram 向人解释核查方向、判断分支和证据关联。mermaid 从 flowchart TD/LR 等方向声明开始,不含 Markdown 围栏、初始化指令、click 回调或脚本。node_refs 每项给出节点 ID、简短 label、真实 evidence_refs 和 action_refs;证据也必须列入报告 evidence_refs。图中区分计划方向、已保存记录和分析判断,不能用动画或图节点状态冒充实际执行。图可以缺省,其缺省和显示失败不会阻止报告或本轮收尾。 -任务协作会话用于解释后端任务快照。用户明确补充或确认事实时,使用 `add_task_human_evidence` 写入 human_input 证据并触发新一轮后台分析;不得把 Agent 推断写成人工证据。协作会话不直接发布计划或报告,新的执行会话领取任务后再更新正式结论。 +需要提议工单或设备命令时,先保存本轮 interim/concluded 报告,再用 `propose_task_action` 保存具体草稿;最后发布 final 报告,可在图中关联返回的 action_id。批准、执行、版本和回执由 Server 控制,Agent 不代替人工批准。创建或派发工单成功不能证明采样、清洗、维修、复测或回访完成;现场处置以外部反馈为准,原投诉工单独立记录。 + +任务协作每次使用 Server 返回的最新任务及用户选定报告快照。selected_report 存在时,针对该轮次和修订号回答,并明确引用;不得把最新报告当作用户正在阅读的历史报告。普通提问不改变任务。用户明确补充或确认事实时使用 `add_task_human_evidence` 写入 human_input 并重新排队;根据返回值说明已保存的事实和实际目标轮次,queued 不能说成已经开始分析。假设、要求改变查询范围和 Agent 推断不能当作现场事实。协作会话不直接改写正式报告,后台执行会话领取新一轮后形成新结论。 1. 无可用数据时不得编造结论。 2. 不要自由拼接 shell、Python 或 CLI 业务数据流程。业务数据只可来自用户输入、经过校验的事件上下文或已启用的受控工具。 diff --git a/.opencode/tools/get_operational_task_context.ts b/.opencode/tools/get_operational_task_context.ts index 8d87e4e..02d36c4 100644 --- a/.opencode/tools/get_operational_task_context.ts +++ b/.opencode/tools/get_operational_task_context.ts @@ -3,11 +3,12 @@ import { getInternalBridgeConfig } from "../internalBridge.js"; export default tool({ description: "Read the current operational task's authorized task, plan, report, evidence and scoped investigation data from TJWater Server. Use this before investigating a task. It is read-only and does not expose credentials.", - args: {}, - async execute(_args, context) { + args: { report_revision: tool.schema.number().int().positive().optional().describe("Read this persisted report revision alongside the current task when explaining a historical report") }, + async execute(args, context) { const { baseUrl, internalToken } = getInternalBridgeConfig(); const url = new URL("/internal/operational-task/context", baseUrl); url.searchParams.set("session_id", context.sessionID); + if (args.report_revision != null) url.searchParams.set("report_revision", String(args.report_revision)); const response = await fetch(url, { headers: { Accept: "application/json", "x-agent-internal-token": internalToken }, }); diff --git a/.opencode/tools/propose_task_action.ts b/.opencode/tools/propose_task_action.ts index 69f6a7c..d1d41c5 100644 --- a/.opencode/tools/propose_task_action.ts +++ b/.opencode/tools/propose_task_action.ts @@ -3,7 +3,7 @@ import { getInternalBridgeConfig } from "../internalBridge.js"; import { proposedTaskActionSchema } from "../../src/product/operational-task-contract.js"; export default tool({ - description: "Propose a work-order creation or command action for human approval after its node exists in the published current plan. This tool only creates a pending approval record and can never execute the action. After success, republish the plan with the returned action_id in action_refs before publishing the final report. create_work_order requires title, description and work_order_type; its priority is normal or urgent. issue_command requires command_type, target, parameters, impact_summary, non-empty preconditions and expires_at.", + description: "Propose a concrete work-order creation or command draft for human approval after saving an interim concluded report for the current run. This tool only creates a pending approval record and can never execute the action. No execution plan is required; node_id is an optional display reference. After success, publish a final report and optionally link the returned action_id in diagram node_refs. create_work_order requires title, description and work_order_type; its priority is normal or urgent. issue_command requires command_type, target, parameters, impact_summary, non-empty preconditions and expires_at.", args: { request_id: tool.schema.string().describe("Stable UUID idempotency key for this proposal"), kind: tool.schema.enum(["create_work_order", "issue_command"]), diff --git a/.opencode/tools/publish_execution_plan.ts b/.opencode/tools/publish_execution_plan.ts index c219afd..aeb8cea 100644 --- a/.opencode/tools/publish_execution_plan.ts +++ b/.opencode/tools/publish_execution_plan.ts @@ -3,7 +3,7 @@ import { getInternalBridgeConfig } from "../internalBridge.js"; import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js"; export default tool({ - description: "Publish the current full execution plan and progress to TJWater Server. Plans may be updated multiple times during investigation; a plan-only publish is intermediate and does not finish the task unless it explicitly records Agent failure. Publish the relevant plan node before proposing an action, then republish the plan with the returned action_id before the final report. Preserve stable node IDs and completed evidence. A running or completed node may only depend on completed or skipped predecessors. Edges are dependencies or labeled conditional branches, never guessed from list order. Human actions stay waiting until externally confirmed. Evidence must identify synthetic inputs, tool results or human confirmation accurately. This records a plan, it does not execute actions.", + description: "Publish an optional legacy investigation description to TJWater Server. This display snapshot never schedules the Agent or controls event-investigation completion. Prefer the optional Mermaid diagram in publish_task_report for a human-facing investigation narrative. Existing plan validation still requires stable node IDs, factual evidence and explicit edges. Do not invent dependencies from list order or infer human completion. Reports and action proposals do not require this plan. Missing critical data ends the round with a final insufficient_data report. This tool only records explanatory information, it does not execute actions.", args: { request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"), schema: tool.schema.enum(["execution-plan@2"]), diff --git a/.opencode/tools/publish_task_report.ts b/.opencode/tools/publish_task_report.ts index 350a7bb..4a76f24 100644 --- a/.opencode/tools/publish_task_report.ts +++ b/.opencode/tools/publish_task_report.ts @@ -2,7 +2,7 @@ import { tool } from "../tool.js"; import { getInternalBridgeConfig } from "../internalBridge.js"; export default tool({ - description: "Publish the final concise report for this execution turn after the current full plan and any pending action proposals have been saved. The Server combines this report with the current generation and session plan to settle the task as running, waiting_human or completed. This records artifacts; it does not perform field work or execute commands.", + description: "Publish an evidence-backed investigation report. No todo or execution plan is required. An optional Mermaid diagram explains the investigation to people; it never schedules or completes work. Before proposing actions publish an interim concluded report, then publish a final report referencing the actual actions. Missing critical data ends the round with final insufficient_data and blocking gaps, no recommendations or pending items. Query failures are execution errors, not proof that data is absent. The run lifecycle endpoint settles execution independently of the diagram.", args: { request_id: tool.schema.string().describe("Stable UUID idempotency key for this report revision"), report: tool.schema.object({ @@ -12,6 +12,28 @@ export default tool({ evidence_refs: tool.schema.array(tool.schema.string()), recommendations: tool.schema.array(tool.schema.string()), pending_items: tool.schema.array(tool.schema.string()), + stage: tool.schema.enum(["interim", "final"]), + outcome: tool.schema.enum(["concluded", "insufficient_data"]), + stop_reason: tool.schema.string().nullable(), + data_gaps: tool.schema.array(tool.schema.object({ + gap_id: tool.schema.string(), + node_id: tool.schema.string().nullable().optional(), + missing_data: tool.schema.string(), + scope: tool.schema.string(), + impact: tool.schema.string(), + required_data: tool.schema.string(), + evidence_refs: tool.schema.array(tool.schema.string()), + blocking: tool.schema.boolean(), + })), + diagram: tool.schema.object({ + title: tool.schema.string(), + mermaid: tool.schema.string().describe("Mermaid flowchart source, no Markdown fences, initialization directives or click callbacks"), + node_refs: tool.schema.array(tool.schema.object({ + node_id: tool.schema.string(), label: tool.schema.string(), + evidence_refs: tool.schema.array(tool.schema.string()), + action_refs: tool.schema.array(tool.schema.string()), + })), + }).nullable().optional(), }), evidence: tool.schema.array(tool.schema.object({ evidence_id: tool.schema.string(), diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 5d4a397..31cec41 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -1,9 +1,11 @@ -# Agent 执行计划 +# 调查报告与可选流程说明 -水质投诉事件要求 Agent 使用 `publish_execution_plan` 发布完整计划和节点状态。 -工具只记录计划,不执行现场操作。前端依据结构化数据呈现主干和并行分枝,不接受模型提供的 HTML、脚本或图形指令。 +水质投诉事件以证据和调查报告为成果,使用 `publish_task_report` 发布明确的 stage、outcome 与数据缺口。 +报告可附 Mermaid 调查脉络,由前端以统一主题渲染 SVG;节点引用连接证据和业务动作。 +Mermaid、todo 和可选的旧执行计划供人理解,不约束 Agent 调度,不决定调查结束。 +事件调查不要求先发布计划。正文及图示中不接受 HTML、SVG 或脚本。 -## 数据与状态 +## 旧计划的兼容数据与状态 输入包含幂等 `request_id`、`title`、`reason`、`nodes` 和 `edges`。节点包含稳定 `id`、标题、执行主体 `agent | human`、说明、证据和状态:`pending | running | waiting | completed | failed | skipped | cancelled`。 @@ -31,9 +33,16 @@ 工况任务执行先读取后端 task context,再通过有限、可审计的数据源查询 SCADA、工作台或外围样板数据。 查询结果携带来源、时间、内容引用和 `origin`,合成数据必须明确标识;截断结果不能用来断言完整范围无异常。 -计划、精简报告和关键证据写回任务记录。计划可在调查中多次保存,未显式失败的纯计划是中间进度。 -动作节点必须先存在于已保存计划;提议成功后把返回的动作编号写回计划。所有计划和动作提议保存后,最后保存本轮报告; -后端只使用当前执行代、当前会话的计划与报告判断进入等待、完成或继续运行。创建工单和下发命令只能生成待人工审批动作,Agent 无执行接口。 +报告和关键证据写回任务记录,计划可选。提议动作前先保存本轮 interim/concluded 报告; +提议成功后可在报告图示中关联返回的动作编号,再保存本轮 final 报告。 +Server 根据当前轮次、当前会话的最终报告与真实动作决定运行收尾及是否待人工审核; +旧计划中的未完成节点不阻止事件调查结束。创建工单和下发命令只能生成待人工审批动作,Agent 无执行接口。 + +协作入口可传所选 report_revision。Server 返回 selected_report 与当前 task,两者分别表示 +正在讨论的历史成果和当前实际状态。Agent 回答的 data-task_context 记录核验后的轮次与报告修订, +保留在流和消息历史中。工具重新读取上下文默认使用本次对话已选修订。 +普通问题不改任务。仅用户明确提供的现场事实通过 add_task_human_evidence 保存; +假设、查询请求和补充事实的入口提示不能当作事实。重新排队成功只表示 queued,不表示 Agent 已开始调查。 执行会话使用两分钟任务租约并定期续租;计划或报告写回也会续租。执行进程中断后由后端租约恢复重新排队。 旧水质样板触发仍可使用事件随附的固定合成输入,但必须表述为外围样板输入,不能冒充实时查询结果。 @@ -43,8 +52,16 @@ Agent:`bun run check`,`bun test tests/routes/executionPlan.test.ts tests/routes/chatStream.test.ts tests/sessions`。 前端:`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`,以及 `pnpm test:browser tests/browser/peripheral-sample-panel.e2e.ts`。 -浏览器覆盖分支图节点、状态更新、版本切换、键盘详情、320/375px、放大和刷新恢复。 +浏览器覆盖嵌入 SVG、引用详情、报告更新与历史选择、键盘详情、320/375px 和实际动作审核。 运行实例需加载更新后的 Agent 代码和 OpenCode 工具配置。`embedded` 模式自动把内部工具桥密钥传给子进程; `client` 模式要求 Agent 与外部 OpenCode 显式配置相同的 `AGENT_INTERNAL_TOKEN` 并重启两侧。 回退前端不会改变已保存工单;回退 Agent 前先保留 Session UI State 数据,新字段可被旧代码忽略。 + +## 调查报告契约 + +报告采用固定字段、增量扩展,不增加格式版本标签。完整规则见 +[Server 调查报告契约](../../next-tjwater-server/docs/investigation-report-contract.md)。 +数据缺失不再保持 waiting:关键缺口导致 final/insufficient_data 正常退出; +动作提议前先保存本轮 interim/concluded 报告,动作提议后再保存 final 报告。 +数据授权由系统配置,本场景不请求人工数据授权。 diff --git a/openapi/openapi.json b/openapi/openapi.json index 7928ebe..3d7a85a 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -779,6 +779,16 @@ "title": "Approved Data Scopes", "type": "array" }, + "selected_report": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationReportRevision" + }, + { + "type": "null" + } + ] + }, "task": { "$ref": "#/components/schemas/OperationalTaskDetail" } @@ -2852,8 +2862,142 @@ "title": "HumanEvidenceCommand", "type": "object" }, - "InvestigationReportInput": { + "InvestigationDataGap": { "additionalProperties": false, + "properties": { + "blocking": { + "title": "Blocking", + "type": "boolean" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + }, + "gap_id": { + "minLength": 1, + "title": "Gap Id", + "type": "string" + }, + "impact": { + "minLength": 1, + "title": "Impact", + "type": "string" + }, + "missing_data": { + "minLength": 1, + "title": "Missing Data", + "type": "string" + }, + "node_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Node Id" + }, + "required_data": { + "minLength": 1, + "title": "Required Data", + "type": "string" + }, + "scope": { + "minLength": 1, + "title": "Scope", + "type": "string" + } + }, + "required": [ + "gap_id", + "missing_data", + "scope", + "impact", + "required_data", + "evidence_refs", + "blocking" + ], + "title": "InvestigationDataGap", + "type": "object" + }, + "InvestigationDiagram": { + "additionalProperties": false, + "properties": { + "mermaid": { + "maxLength": 20000, + "minLength": 1, + "title": "Mermaid", + "type": "string" + }, + "node_refs": { + "items": { + "$ref": "#/components/schemas/InvestigationDiagramNodeRef" + }, + "maxItems": 100, + "title": "Node Refs", + "type": "array" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "mermaid" + ], + "title": "InvestigationDiagram", + "type": "object" + }, + "InvestigationDiagramNodeRef": { + "additionalProperties": false, + "properties": { + "action_refs": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Action Refs", + "type": "array" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "label": { + "minLength": 1, + "title": "Label", + "type": "string" + }, + "node_id": { + "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$", + "title": "Node Id", + "type": "string" + } + }, + "required": [ + "node_id", + "label" + ], + "title": "InvestigationDiagramNodeRef", + "type": "object" + }, + "InvestigationReport": { + "additionalProperties": false, + "description": "Read model: absent historical facts remain unknown, never inferred.", "properties": { "conclusion_boundary": { "minLength": 1, @@ -2865,6 +3009,30 @@ "title": "Current Conclusion", "type": "string" }, + "data_gaps": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/InvestigationDataGap" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data Gaps" + }, + "diagram": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationDiagram" + }, + { + "type": "null" + } + ] + }, "evidence_refs": { "items": { "minLength": 1, @@ -2873,6 +3041,21 @@ "title": "Evidence Refs", "type": "array" }, + "outcome": { + "anyOf": [ + { + "enum": [ + "concluded", + "insufficient_data" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Outcome" + }, "pending_items": { "items": { "minLength": 1, @@ -2889,6 +3072,33 @@ "title": "Recommendations", "type": "array" }, + "stage": { + "anyOf": [ + { + "enum": [ + "interim", + "final" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stage" + }, + "stop_reason": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stop Reason" + }, "title": { "minLength": 1, "title": "Title", @@ -2900,9 +3110,159 @@ "current_conclusion", "conclusion_boundary" ], + "title": "InvestigationReport", + "type": "object" + }, + "InvestigationReportInput": { + "additionalProperties": false, + "description": "Write model: every new report explicitly declares its business result.", + "properties": { + "conclusion_boundary": { + "minLength": 1, + "title": "Conclusion Boundary", + "type": "string" + }, + "current_conclusion": { + "minLength": 1, + "title": "Current Conclusion", + "type": "string" + }, + "data_gaps": { + "items": { + "$ref": "#/components/schemas/InvestigationDataGap" + }, + "title": "Data Gaps", + "type": "array" + }, + "diagram": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationDiagram" + }, + { + "type": "null" + } + ] + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "outcome": { + "enum": [ + "concluded", + "insufficient_data" + ], + "title": "Outcome", + "type": "string" + }, + "pending_items": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Pending Items", + "type": "array" + }, + "recommendations": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Recommendations", + "type": "array" + }, + "stage": { + "enum": [ + "interim", + "final" + ], + "title": "Stage", + "type": "string" + }, + "stop_reason": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stop Reason" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "current_conclusion", + "conclusion_boundary", + "stage", + "outcome", + "data_gaps" + ], "title": "InvestigationReportInput", "type": "object" }, + "InvestigationReportPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/InvestigationReportRevision" + }, + "title": "Items", + "type": "array" + }, + "next_offset": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + } + }, + "required": [ + "items" + ], + "title": "InvestigationReportPage", + "type": "object" + }, + "InvestigationReportReference": { + "additionalProperties": false, + "properties": { + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "execution_generation", + "revision" + ], + "title": "InvestigationReportReference", + "type": "object" + }, "InvestigationReportRevision": { "additionalProperties": false, "properties": { @@ -2929,7 +3289,7 @@ "type": "string" }, "report": { - "$ref": "#/components/schemas/InvestigationReportInput" + "$ref": "#/components/schemas/InvestigationReport" }, "revision": { "minimum": 1.0, @@ -3792,6 +4152,16 @@ ], "title": "Read By" }, + "report_reference": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationReportReference" + }, + { + "type": "null" + } + ] + }, "required_permission": { "anyOf": [ { @@ -25748,6 +26118,23 @@ "type": "string" } }, + { + "in": "query", + "name": "report_revision", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Report Revision" + } + }, { "in": "header", "name": "X-Project-Id", @@ -26324,6 +26711,252 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/reports": { + "get": { + "operationId": "get_operational_tasks_task_id_reports", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvestigationReportPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Investigation Reports", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/reports/{revision}": { + "get": { + "operationId": "get_operational_tasks_task_id_reports_revision", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "revision", + "required": true, + "schema": { + "title": "Revision", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvestigationReportRevision" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Investigation Report", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/retry": { "post": { "operationId": "post_operational_tasks_task_id_retry", diff --git a/src/generated/server-api.d.ts b/src/generated/server-api.d.ts index 0893e01..7a6764a 100644 --- a/src/generated/server-api.d.ts +++ b/src/generated/server-api.d.ts @@ -2565,6 +2565,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/operational-tasks/{task_id}/reports": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Investigation Reports */ + get: operations["get_operational_tasks_task_id_reports"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/operational-tasks/{task_id}/reports/{revision}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Investigation Report */ + get: operations["get_operational_tasks_task_id_reports_revision"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/operational-tasks/{task_id}/retry": { parameters: { query?: never; @@ -5631,6 +5665,7 @@ export interface components { AgentTaskContext: { /** Approved Data Scopes */ approved_data_scopes: string[]; + selected_report?: components["schemas"]["InvestigationReportRevision"] | null; task: components["schemas"]["OperationalTaskDetail"]; }; /** @@ -6394,21 +6429,119 @@ export interface components { */ request_id: string; }; - /** InvestigationReportInput */ + /** InvestigationDataGap */ + InvestigationDataGap: { + /** Blocking */ + blocking: boolean; + /** Evidence Refs */ + evidence_refs: string[]; + /** Gap Id */ + gap_id: string; + /** Impact */ + impact: string; + /** Missing Data */ + missing_data: string; + /** Node Id */ + node_id?: string | null; + /** Required Data */ + required_data: string; + /** Scope */ + scope: string; + }; + /** InvestigationDiagram */ + InvestigationDiagram: { + /** Mermaid */ + mermaid: string; + /** Node Refs */ + node_refs?: components["schemas"]["InvestigationDiagramNodeRef"][]; + /** Title */ + title: string; + }; + /** InvestigationDiagramNodeRef */ + InvestigationDiagramNodeRef: { + /** Action Refs */ + action_refs?: string[]; + /** Evidence Refs */ + evidence_refs?: string[]; + /** Label */ + label: string; + /** Node Id */ + node_id: string; + }; + /** + * InvestigationReport + * @description Read model: absent historical facts remain unknown, never inferred. + */ + InvestigationReport: { + /** Conclusion Boundary */ + conclusion_boundary: string; + /** Current Conclusion */ + current_conclusion: string; + /** Data Gaps */ + data_gaps?: components["schemas"]["InvestigationDataGap"][] | null; + diagram?: components["schemas"]["InvestigationDiagram"] | null; + /** Evidence Refs */ + evidence_refs?: string[]; + /** Outcome */ + outcome?: ("concluded" | "insufficient_data") | null; + /** Pending Items */ + pending_items?: string[]; + /** Recommendations */ + recommendations?: string[]; + /** Stage */ + stage?: ("interim" | "final") | null; + /** Stop Reason */ + stop_reason?: string | null; + /** Title */ + title: string; + }; + /** + * InvestigationReportInput + * @description Write model: every new report explicitly declares its business result. + */ InvestigationReportInput: { /** Conclusion Boundary */ conclusion_boundary: string; /** Current Conclusion */ current_conclusion: string; + /** Data Gaps */ + data_gaps: components["schemas"]["InvestigationDataGap"][]; + diagram?: components["schemas"]["InvestigationDiagram"] | null; /** Evidence Refs */ evidence_refs?: string[]; + /** + * Outcome + * @enum {string} + */ + outcome: "concluded" | "insufficient_data"; /** Pending Items */ pending_items?: string[]; /** Recommendations */ recommendations?: string[]; + /** + * Stage + * @enum {string} + */ + stage: "interim" | "final"; + /** Stop Reason */ + stop_reason?: string | null; /** Title */ title: string; }; + /** InvestigationReportPage */ + InvestigationReportPage: { + /** Items */ + items: components["schemas"]["InvestigationReportRevision"][]; + /** Next Offset */ + next_offset?: number | null; + }; + /** InvestigationReportReference */ + InvestigationReportReference: { + /** Execution Generation */ + execution_generation: number; + /** Revision */ + revision: number; + }; /** InvestigationReportRevision */ InvestigationReportRevision: { /** Execution Generation */ @@ -6420,7 +6553,7 @@ export interface components { published_at: string; /** Published By */ published_by: string; - report: components["schemas"]["InvestigationReportInput"]; + report: components["schemas"]["InvestigationReport"]; /** Revision */ revision: number; /** Source Session Id */ @@ -6770,6 +6903,7 @@ export interface components { read_at?: string | null; /** Read By */ read_by?: string | null; + report_reference?: components["schemas"]["InvestigationReportReference"] | null; /** Required Permission */ required_permission?: string | null; /** Resolution Reason */ @@ -20756,7 +20890,9 @@ export interface operations { }; get_operational_tasks_task_id_agent_context: { parameters: { - query?: never; + query?: { + report_revision?: number | null; + }; header: { "X-Project-Id": string; }; @@ -21158,6 +21294,166 @@ export interface operations { }; }; }; + get_operational_tasks_task_id_reports: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header: { + "X-Project-Id": string; + }; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvestigationReportPage"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Insufficient permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Validation error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Dependency unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + get_operational_tasks_task_id_reports_revision: { + parameters: { + query?: never; + header: { + "X-Project-Id": string; + }; + path: { + task_id: string; + revision: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvestigationReportRevision"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Insufficient permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Resource conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Validation error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Dependency unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; post_operational_tasks_task_id_retry: { parameters: { query?: never; diff --git a/src/product/investigation-report.ts b/src/product/investigation-report.ts new file mode 100644 index 0000000..5cfc5b8 --- /dev/null +++ b/src/product/investigation-report.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +const text = z.string().trim().min(1); +export const investigationDiagramSchema = z.object({ + title: text, + mermaid: text.max(20000).refine((source) => + /^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source) + && !source.includes("```") && !source.includes("%%{") && !/(?:^|[;\n])\s*click\b/.test(source), + "调查图只接受不含指令或回调的 Mermaid 流程描述"), + node_refs: z.array(z.object({ + node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/), + label: text, + evidence_refs: z.array(text).default([]), + action_refs: z.array(z.string().uuid()).default([]), + }).strict()).max(100).default([]), +}).strict().superRefine((diagram, ctx) => { + if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" }); + } +}); +const dataGapSchema = z.object({ + gap_id: text, + node_id: text.nullish(), + missing_data: text, + scope: text, + impact: text, + required_data: text, + evidence_refs: z.array(text).min(1), + blocking: z.boolean() +}).strict(); + +/** New report writes require explicit business outcomes; storage reads remain compatible. */ +export const taskReportSchema = z.object({ + title: text, + current_conclusion: text, + conclusion_boundary: text, + evidence_refs: z.array(text).default([]), + recommendations: z.array(text).default([]), + pending_items: z.array(text).default([]), + stage: z.enum(["interim", "final"]), + outcome: z.enum(["concluded", "insufficient_data"]), + stop_reason: text.nullable().default(null), + data_gaps: z.array(dataGapSchema), + diagram: investigationDiagramSchema.nullish(), +}).strict().superRefine((report, ctx) => { + const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message }); + if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) invalid("数据缺口编号重复"); + const blocking = report.data_gaps.some((gap) => gap.blocking); + if (report.outcome === "insufficient_data") { + if (report.stage !== "final" || !report.stop_reason || !blocking) invalid("证据不足必须提供最终报告、结束原因和关键缺口"); + if (report.recommendations.length || report.pending_items.length) invalid("证据不足退出后不再生成后续处置建议或待办"); + } else if (report.stop_reason != null || blocking) invalid("已形成结论不能包含结束原因或阻塞缺口"); +}); + +export type TaskReport = z.infer; diff --git a/src/product/operational-task-client.ts b/src/product/operational-task-client.ts index f96f789..909843e 100644 --- a/src/product/operational-task-client.ts +++ b/src/product/operational-task-client.ts @@ -1,4 +1,6 @@ import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js"; +import { taskReportSchema } from "./investigation-report.js"; +export { taskReportSchema } from "./investigation-report.js"; import { z } from "zod"; import { config } from "../config.js"; @@ -63,6 +65,10 @@ const agentTaskContextSchema = z.object({ permissions: z.record(z.boolean()), }).passthrough(), approved_data_scopes: z.array(taskDataSourceSchema), + selected_report: z.object({ + revision: z.number().int().positive(), execution_generation: z.number().int().positive().nullish(), + report: z.unknown(), + }).passthrough().nullish(), }); const receivedEvidenceSchema = z.object({ @@ -103,15 +109,6 @@ export const taskEvidenceSchema = z.object({ observed_at: z.string().datetime({ offset: true }), }); -export const taskReportSchema = z.object({ - title: z.string().trim().min(1).max(160), - current_conclusion: z.string().trim().min(1).max(4000), - conclusion_boundary: z.string().trim().min(1).max(2000), - evidence_refs: z.array(z.string().trim().min(1).max(128)).max(100).default([]), - recommendations: z.array(z.string().trim().min(1).max(1000)).max(30).default([]), - pending_items: z.array(z.string().trim().min(1).max(500)).max(20).default([]), -}); - export const operationalTaskDataQuerySchema = z.object({ source: taskDataSourceSchema, device_ids: z.array(z.string().trim().min(1).max(160)).max(20).optional(), @@ -203,8 +200,9 @@ export const clearOperationalTaskLease = (runtimeSessionId: string) => { export class OperationalTaskClient { constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {} - async getAgentContext(runtimeSessionId: string, taskId: string) { - const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-context`, { + async getAgentContext(runtimeSessionId: string, taskId: string, reportRevision?: number) { + const query = reportRevision == null ? "" : `?report_revision=${reportRevision}`; + const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-context${query}`, { method: "GET", }); const result = agentTaskContextSchema.parse(raw); diff --git a/src/routes/agentTrigger.ts b/src/routes/agentTrigger.ts index 164cc05..dafb34d 100644 --- a/src/routes/agentTrigger.ts +++ b/src/routes/agentTrigger.ts @@ -33,8 +33,8 @@ export const buildAgentTriggerPrompt = (trigger: AgentTriggerContext) => [ event_id: trigger.trigger_id, })}`, "这是合成演练数据,不得表述为生产事实。", - "根据这张工单的事实动态规划本次调查,不套用预设路线。开始调查前调用 publish_execution_plan 发布 execution-plan@2,明确路线选择、并行分叉与汇合、人工审核点;执行前、取得结果后、遇到阻塞或修改计划时再次发布完整快照。保留节点 ID、已完成节点及证据,重试用新节点,不要按时间推算进度。", - "每个完成节点必须列明实际依据。预设证据标注为合成场景输入,不得声称执行过未调用的数据查询工具。现场采样、清洗、复测、回访等人工工作保持 waiting,写清等待内容,不得因回复结束标记完成。", + "根据这张工单的事实动态规划本次调查,不套用预设路线。先读取 get_operational_task_context,自主查询授权数据并保存证据和调查报告;todo、Mermaid 和可选的旧计划仅向人解释,不约束执行或决定任务完成。数据权限由系统配置,人工审核仅针对具体工单或设备命令。", + "每项判断必须列明实际依据。预设证据标注为合成场景输入,不得声称执行过未调用的数据查询工具。关键数据不足时发布 final/insufficient_data 报告,记录 stop_reason 和有查询依据的阻塞 data_gaps,清空 recommendations 与 pending_items,正常结束本轮;权限拒绝和接口错误不能证明数据缺失。现场采样、清洗、复测及回访是否完成以外部反馈为准,未来人工工作不阻止本轮调查结束。", "外围样板随事件提供的合成场景输入(不是 Agent 实时查询结果):上游三个模拟水质监测点的数据质量均为 valid,演示指标均在场景阈值内,且相邻片区没有同类投诉;投诉集中在演示小区,二次供水水箱最近一次清洗记录待核验。", - "依据实际取得的证据判断可能原因,区分合成场景输入、已查询数据和待现场核验事项;给出处置、复测和回访建议。不得将疑似判断写成已确认事故。", + "依据实际取得的证据判断可能原因,区分合成场景输入、已查询数据和待现场核验事项。已形成判断时可给出处置建议;提议具体动作前先保存 interim/concluded 报告,提议成功后再保存 final 报告。不得将疑似判断写成已确认事故。", ].join("\n"); diff --git a/src/routes/chatSessionRoutes.ts b/src/routes/chatSessionRoutes.ts index c070d9f..ba76222 100644 --- a/src/routes/chatSessionRoutes.ts +++ b/src/routes/chatSessionRoutes.ts @@ -106,7 +106,7 @@ export const registerChatSessionRoutes = ( body: JSON.stringify({ session_id: record.sessionId, model, - message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,依据当前工单或人工请求动态规划 execution-plan@2;按实际选择的路线更新计划、证据和报告,数据权限不足时先申请并等待审核。`, + message: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,自主查询并保存证据和调查报告;可在报告内用 Mermaid 调查图向人解释方向、分支和依据,图及 todo 均不约束执行。数据权限由系统配置,关键数据不足时发布最终报告并正常结束本轮,人工审核仅针对具体工单等业务动作。`, }), }).then(async (response) => { const reader = response.body?.getReader(); diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index 80dd9da..9c61bac 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -90,6 +90,11 @@ const payloadSchema = z.object({ approval_mode: z.enum(["request", "always"]).optional().default("request"), capabilities: z.array(z.string().max(64)).max(20).optional().default([]), trigger_context: agentTriggerContextSchema.optional(), + task_context: z.object({ + report_revision: z.number().int().positive().optional(), + evidence_id: z.string().min(1).max(128).optional(), + gap_id: z.string().min(1).max(128).optional(), + }).strict().optional(), }); const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({ @@ -147,7 +152,8 @@ export const toCollaborationMessages = (messages: UIMessage[]) => messages.flatMap((message) => { if (message.role !== "user" && message.role !== "assistant") return []; const content = readUiMessageText(message); - return content ? [{ ...message, parts: [{ type: "text" as const, text: content }] } as UIMessage] : []; + const reference = message.parts.filter((part) => part.type === "data-task_context"); + return content ? [{ ...message, parts: [{ type: "text" as const, text: content }, ...reference] } as UIMessage] : []; }); const toLegacyMessages = (messages: UIMessage[]) => @@ -384,11 +390,18 @@ export const registerChatStreamRoute = ( boundOperationalSessionId = binding.sessionId; const resolvedTaskContext = activeSessionRecord.taskId && (sessionPurpose === "task_collaboration" || sessionPurpose === "task_execution") - ? await operationalTaskClient.getAgentContext(binding.sessionId, activeSessionRecord.taskId) + ? await operationalTaskClient.getAgentContext(binding.sessionId, activeSessionRecord.taskId, + sessionPurpose === "task_collaboration" ? parsed.data.task_context?.report_revision : undefined) : null; const taskContext = sessionPurpose === "task_collaboration" ? resolvedTaskContext : null; + if (taskContext) { + const report = taskContext.selected_report ?? taskContext.task.latest_report; + const reference = z.object({ revision: z.number().int().positive() }).passthrough().safeParse(report); + setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, + reportRevision: reference.success ? reference.data.revision : undefined }); + } const hasRuntimeConversation = hadExistingRuntimeSession ? await runtimeHasConversation(runtime, binding.sessionId) : false; @@ -882,10 +895,26 @@ export const registerChatStreamRoute = ( taskHeartbeat?.unref(); try { + if (taskContext) { + const raw = taskContext.selected_report ?? taskContext.task.latest_report; + const reportRef = z.object({ revision: z.number().int().positive(), execution_generation: z.number().int().positive().nullish() }).passthrough().safeParse(raw); + const generation = taskContext.task.execution_generation; + const reference = { + task_id: activeSessionRecord.taskId!, + execution_generation: reportRef.success ? reportRef.data.execution_generation ?? null + : typeof generation === "number" && generation > 0 ? generation : null, + report_revision: reportRef.success ? reportRef.data.revision : null, + }; + const part = { type: "data-task_context" as const, data: reference }; + assistantParts.push(part); + writeUiChunk(part); + syncLatestUiMessages(); + } const preparedMessage = taskContext ? [ - "你正在与用户协作理解一个工况任务。只根据下面由后端授权返回的任务、计划、报告和证据快照解释判断;不要展示后台执行会话、工具轨迹或内部推理。若资料不足,明确说明缺口。用户要求变更任务时使用结构化工具写回。", + "你正在与用户协作理解一个工况任务。只根据下面由后端授权返回的任务、报告和证据解释判断;selected_report 存在时用户正在阅读该轮次与修订号,优先据此回答并明确引用。不要展示后台执行会话、工具轨迹或内部推理。普通提问不改变任务,只有用户明确补充的事实可用结构化工具记录并重新分析;假设和查询要求不能当作现场事实。queued 表示等待领取,不能声称已经开始分析。", `任务快照:${JSON.stringify(taskContext)}`, + `当前讨论对象:${JSON.stringify(parsed.data.task_context ?? {})}`, `用户问题:${promptText}`, ].join("\n\n") : await buildPromptWithLearningContext( diff --git a/src/runtime/sessionContext.ts b/src/runtime/sessionContext.ts index 9fbd28f..445cd55 100644 --- a/src/runtime/sessionContext.ts +++ b/src/runtime/sessionContext.ts @@ -12,6 +12,7 @@ export type RuntimeSessionContext = { capabilities?: string[]; purpose?: "ordinary_chat" | "task_execution" | "task_collaboration"; taskId?: string; + reportRevision?: number; workspaceDirectory?: string; }; diff --git a/src/server.ts b/src/server.ts index 90e6c18..9d16edd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -132,7 +132,12 @@ app.get("/internal/operational-task/context", async (req, res) => { return; } try { - res.json(await operationalTaskClient.getAgentContext(bridge.runtimeSessionId, bridge.taskId)); + const revision = req.query.report_revision == null ? bridge.context.reportRevision : Number(req.query.report_revision); + if (revision != null && (!Number.isSafeInteger(revision) || revision < 1)) { + res.status(400).json({ message: "report_revision must be a positive integer" }); + return; + } + res.json(await operationalTaskClient.getAgentContext(bridge.runtimeSessionId, bridge.taskId, revision)); } catch (error) { const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status diff --git a/tests/product/investigationReport.test.ts b/tests/product/investigationReport.test.ts new file mode 100644 index 0000000..1798c3b --- /dev/null +++ b/tests/product/investigationReport.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { taskReportSchema } from "../../src/product/investigation-report.js"; + +const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据", + evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data", + stop_reason: "缺少关键采样", data_gaps: [{ gap_id: "g1", node_id: "read", missing_data: "末梢采样", + scope: "投诉时段", impact: "无法判断异常来源", required_data: "该时段采样结果", evidence_refs: ["query-1"], blocking: true }] }; + +describe("new investigation report writes", () => { + it("requires explicit outcome and gap assessment", () => { + expect(taskReportSchema.parse(report).outcome).toBe("insufficient_data"); + for (const field of ["stage", "outcome", "data_gaps"]) { + const incomplete = { ...report }; + Reflect.deleteProperty(incomplete, field); + expect(() => taskReportSchema.parse(incomplete)).toThrow(); + } + }); + it("accepts an evidence-backed gap without a plan or diagram node", () => { + const { node_id: _nodeId, ...gap } = report.data_gaps[0]; + expect(taskReportSchema.parse({ ...report, data_gaps: [gap] }).data_gaps[0]?.node_id).toBeUndefined(); + }); + it("accepts an optional narrative diagram and rejects interactive directives", () => { + const diagram = { title: "核查关系", mermaid: 'flowchart TD\nA["查询"] --> B["判断"]', + node_refs: [{ node_id: "A", label: "查询", evidence_refs: ["query-1"] }] }; + expect(taskReportSchema.parse({ ...report, diagram }).diagram?.node_refs[0]?.action_refs).toEqual([]); + for (const mermaid of ['%%{init: {}}%%\nflowchart TD\nA-->B', 'flowchart TD\nclick A callback', 'flowchart TD; A-->B; click A callback', 'sequenceDiagram\nA->>B: 查询']) { + expect(() => taskReportSchema.parse({ ...report, diagram: { ...diagram, mermaid } })).toThrow(); + } + }); + it("rejects downstream work or unsupported claims on insufficient-data exit", () => { + for (const changes of [{ recommendations: ["派采样工单"] }, { pending_items: ["等待人工"] }, + { stop_reason: null }, { data_gaps: [] }, { stage: "interim" }, { outcome: "concluded" }, + { data_gaps: [{ ...report.data_gaps[0], evidence_refs: [] }] }, { schema: "unexpected" }]) { + expect(() => taskReportSchema.parse({ ...report, ...changes })).toThrow(); + } + }); +}); diff --git a/tests/product/operationalTaskClient.test.ts b/tests/product/operationalTaskClient.test.ts index 2bd7ccc..a51df57 100644 --- a/tests/product/operationalTaskClient.test.ts +++ b/tests/product/operationalTaskClient.test.ts @@ -51,6 +51,21 @@ afterEach(() => { }); describe("OperationalTaskClient", () => { + it("requests a selected report while retaining the current task snapshot", async () => { + let requestedUrl = ""; + globalThis.fetch = (async (input: Parameters[0]) => { + requestedUrl = String(input); + return Response.json({ task: { ...task, execution_generation: 2, latest_plan: null, + latest_report: { revision: 8, execution_generation: 2 }, evidence: [], actions: [], permissions: {} }, + approved_data_scopes: [], selected_report: { revision: 3, execution_generation: 1, report: { title: "上一轮报告" } } }); + }) as unknown as typeof fetch; + bindOperationalTaskCredential("runtime-1", { authorization: "Bearer transient-secret", projectId: "project-1" }); + const client = new OperationalTaskClient("http://server.test"); + const context = await client.getAgentContext("runtime-1", taskId, 3); + expect(new URL(requestedUrl).searchParams.get("report_revision")).toBe("3"); + expect(context.selected_report?.execution_generation).toBe(1); + expect(context.task.execution_generation).toBe(2); + }); it("matches proposed action payloads to the generated Server contract", () => { const common = { request_id: "60000000-0000-4000-8000-000000000001", @@ -187,6 +202,7 @@ describe("OperationalTaskClient", () => { sourceSessionId: "session-1", report: { title: "失败", + stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [], current_conclusion: "租约已失效", conclusion_boundary: "未继续写回", evidence_refs: [], diff --git a/tests/routes/agentTrigger.test.ts b/tests/routes/agentTrigger.test.ts index 22bc4ad..011ef0e 100644 --- a/tests/routes/agentTrigger.test.ts +++ b/tests/routes/agentTrigger.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import proposeTaskAction from "../../.opencode/tools/propose_task_action.js"; import { agentTriggerContextSchema, @@ -37,6 +38,22 @@ describe("agent event trigger", () => { expect(prompt).toContain(trigger.trigger_id); }); + it("keeps event entry and action-tool instructions consistent with report-driven completion", () => { + const prompt = buildAgentTriggerPrompt(agentTriggerContextSchema.parse(trigger)); + expect(prompt).toContain("get_operational_task_context"); + expect(prompt).toContain("final/insufficient_data"); + expect(prompt).toContain("正常结束本轮"); + expect(prompt).toContain("权限拒绝和接口错误不能证明数据缺失"); + expect(prompt).toContain("interim/concluded"); + expect(prompt).not.toContain("开始调查前调用 publish_execution_plan"); + expect(prompt).not.toContain("人工工作保持 waiting"); + expect(proposeTaskAction.description).toContain("interim concluded report"); + expect(proposeTaskAction.description).toContain("No execution plan is required"); + expect(proposeTaskAction.description).toContain("node_id is an optional display reference"); + expect(proposeTaskAction.description).not.toContain("published current plan"); + expect(proposeTaskAction.description).not.toContain("republish the plan"); + }); + it("rejects a different scenario instead of routing arbitrary work orders", () => { expect(agentTriggerContextSchema.safeParse({ ...trigger,