feat(agent): align investigation reports and selected context

This commit is contained in:
2026-10-10 16:02:47 +08:00
parent d58bd19039
commit a31b76a793
18 changed files with 1172 additions and 44 deletions
+299 -3
View File
@@ -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;
+55
View File
@@ -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<typeof taskReportSchema>;
+9 -11
View File
@@ -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);
+3 -3
View File
@@ -33,8 +33,8 @@ export const buildAgentTriggerPrompt = (trigger: AgentTriggerContext) => [
event_id: trigger.trigger_id,
})}</external_work_order>`,
"这是合成演练数据,不得表述为生产事实。",
"根据这张工单的事实动态规划本次调查,不套用预设路线。开始调查前调用 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");
+1 -1
View File
@@ -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();
+32 -3
View File
@@ -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(
+1
View File
@@ -12,6 +12,7 @@ export type RuntimeSessionContext = {
capabilities?: string[];
purpose?: "ordinary_chat" | "task_execution" | "task_collaboration";
taskId?: string;
reportRevision?: number;
workspaceDirectory?: string;
};
+6 -1
View File
@@ -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