feat(asset-ai): generate contextual presentations and drilldown plans
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 17:52:33 +08:00
parent b5a48b6417
commit dc0923b566
33 changed files with 6193 additions and 1165 deletions
+109 -12
View File
@@ -7,6 +7,9 @@ import { vehicleRepository } from "../vehicles/repository.js";
import { visibleVehicles } from "./model.js";
import { initialPlan, planConversation } from "./planner.js";
import { execute, applyAction } from "./execute.js";
import { executeComposition } from "./composition.js";
import { generatePresentation } from "./generative.js";
import { applyGeneratedAction } from "./generated-action.js";
import {
getSession,
getNode,
@@ -16,6 +19,7 @@ import {
acquire,
conversationContext,
} from "./session.js";
import { resultScope, followupSuggestions } from "./presentation.js";
import { gapStore } from "./gaps.js";
import {
CATEGORY_LABELS,
@@ -23,6 +27,7 @@ import {
GROUP_LABELS,
type AssistantRequest,
type AssistantResult,
type AnalysisQuery,
type GapReason,
type GapStatus,
} from "../../../shared/assets/assistant.js";
@@ -248,20 +253,26 @@ router.post("/ask", async (c) => {
const context = conversationContext(currentSession, baseNode);
const previous = context.previous;
let plan = previous || initialPlan();
let queries: AnalysisQuery[] | undefined;
let warning: string | undefined;
let reply: string | undefined;
let answerKind: "answer" | "clarify" | undefined;
let suggestions: string[] | undefined;
let unanswered: AssistantResult["unanswered"];
let correction: string | undefined;
let engine: "deepseek" | "local" = "local";
let parent = baseNode;
if (input.action) {
if (!previous) throw new Error("请先完成一次统计。");
plan = applyAction(
previous,
input.action,
await execute(vehicles, previous),
);
demand = `${CATEGORY_LABELS[previous.category]} ${GROUP_LABELS[previous.groupBy]} ${input.action.type}`;
const currentData = await execute(vehicles, previous);
if (input.action.type === "explore") {
const exploration = applyGeneratedAction(previous, input.action, currentData, baseNode?.presentation);
plan = exploration.plan;
demand = exploration.label;
} else {
plan = applyAction(previous, input.action, currentData);
demand = `${CATEGORY_LABELS[previous.category]} ${GROUP_LABELS[previous.groupBy]} ${input.action.type}`;
}
} else {
demand = input
.message!.trim()
@@ -277,11 +288,14 @@ router.post("/ask", async (c) => {
signal: controller.signal,
});
plan = decision.plan;
queries = decision.queries;
reason = decision.reason;
warning = decision.warning;
reply = decision.reply;
answerKind = decision.answerKind;
suggestions = decision.suggestions;
unanswered = decision.unanswered;
correction = decision.correction;
engine = decision.engine;
if (decision.baseResultId)
parent = getNode(currentSession, decision.baseResultId);
@@ -294,8 +308,12 @@ router.post("/ask", async (c) => {
? "组织回答与下一步建议"
: "查询数据并计算统计结果",
});
let result: AssistantResult =
plan.kind === "clarify"
// Reserve enough session slots before executing or publishing any part of an answer.
if (currentSession.nodes.size + 1 + (queries?.length || 0) > 100)
throw new Error("本次对话分析步骤已满,请新建对话。");
let result: AssistantResult = queries?.length
? await executeComposition(vehicles, queries, input.subject, controller.signal)
: plan.kind === "clarify"
? {
plan,
title:
@@ -315,8 +333,21 @@ router.post("/ask", async (c) => {
}
: await execute(vehicles, plan);
if (controller.signal.aborted) return;
plan = result.plan;
if (result.unanswered?.length) {
unanswered = [...(unanswered || []), ...result.unanswered];
reason ||= "generation";
}
result.engine = engine;
if (suggestions?.length) result.suggestions = suggestions;
if (plan.kind === "clarify" && suggestions?.length) result.suggestions = suggestions;
result.scope = resultScope(plan, input.subject);
result.suggestions = followupSuggestions(result);
if (plan.kind === "clarify" && !result.sections) result.outcome = reason === "unsupported" ? "unsupported" : answerKind === "answer" ? "help" : "clarification";
if (unanswered?.length) {
result.unanswered = unanswered;
if (plan.kind !== "clarify") result.outcome = "partial";
}
if (correction) result.correction = { message: correction, resultId: parent ? trail(currentSession, parent).at(-1)?.resultId : undefined };
if (warning) result.warning = warning;
if (plan.kind !== "clarify") {
const includes = Object.entries(plan.filters).map(
@@ -378,6 +409,28 @@ router.post("/ask", async (c) => {
demand = `${demand.slice(0, 400)}(承接:${scope})`.slice(0, 600);
}
}
if (result.plan.kind !== "clarify" || result.sections?.some(section => section.result)) {
await emit({ type: "step", label: "根据结果设计展示与下钻路径" });
await generatePresentation(result, input.message || `${baseNode?.question || "当前分析"};${demand}`, controller.signal);
if (result.presentationStatus === "generated" || result.sections?.some(section => section.result?.presentationStatus === "generated")) {
engine = "deepseek";
result.engine = engine;
}
}
if (controller.signal.aborted) return;
for (const item of result.sections ? result.sections.flatMap(section => section.result ? [section.result] : []) : [result]) {
if (!item.presentation && item.presentationWarning)
item.warning = [item.warning, item.presentationWarning].filter(Boolean).join(" ");
}
const missingPresentation = result.sections
? result.sections.filter(section => section.result?.presentationStatus === "fallback").map(section => ({ question: `${section.question}(定制展示与下钻)`, reason: section.result!.presentationWarning || "展示方案未能生成,已保留统计结果。" }))
: result.presentationStatus === "fallback" ? [{ question: `${input.message || demand}(定制展示与下钻)`, reason: result.presentationWarning || "展示方案未能生成,已保留统计结果。" }] : [];
if (missingPresentation.length) {
unanswered = [...(unanswered || []), ...missingPresentation];
result.unanswered = unanswered;
result.outcome = "partial";
reason ||= "generation";
}
const node = addNode(currentSession, {
plan,
parent: parent?.id,
@@ -386,14 +439,18 @@ router.post("/ask", async (c) => {
reason,
question: input.message || demand,
answer: result.summary,
container: !!result.sections,
presentation: result.presentation,
resultContext:
plan.kind === "clarify"
? undefined
: {
groups: result.groups.slice(0, 100),
inventoryBreakdown: result.inventoryBreakdown,
metrics: result.metrics,
insights: result.insights,
summary: result.summary,
analyses: parent?.resultContext?.analyses,
},
});
result = {
@@ -402,15 +459,55 @@ router.post("/ask", async (c) => {
resultId: node.id,
breadcrumbs: trail(currentSession, node),
};
if (reason) {
if (!result.sections && node.resultContext?.analyses) {
node.resultContext.analyses = node.resultContext.analyses.map(analysis => analysis.id === parent?.id ? {
id: node.id, title: result.title, plan, summary: result.summary, metrics: result.metrics,
} : analysis);
result.analyses = node.resultContext.analyses;
}
if (result.sections) {
for (const section of result.sections) {
if (!section.result) continue;
const child = section.result;
const childNode = addNode(currentSession, {
plan: child.plan, parent: node.id, label: section.title,
demand: `${section.question}(原问题:${demand})`.slice(0, 600),
question: section.question, answer: child.summary,
presentation: child.presentation,
resultContext: {
groups: child.groups.slice(0, 100), metrics: child.metrics,
insights: child.insights, summary: child.summary,
inventoryBreakdown: child.inventoryBreakdown,
},
});
child.engine = engine;
child.sessionId = currentSession.id;
child.resultId = childNode.id;
child.breadcrumbs = trail(currentSession, childNode);
}
result.analyses = result.sections.flatMap(section => section.result ? [{
id: section.result.resultId!, title: section.title, plan: section.result.plan,
summary: section.result.summary, metrics: section.result.metrics,
}] : []);
if (node.resultContext) node.resultContext.analyses = result.analyses;
for (const section of result.sections) {
if (section.result?.resultId) {
const childNode = getNode(currentSession, section.result.resultId)!;
if (childNode.resultContext) childNode.resultContext.analyses = result.analyses;
}
}
}
const gapReason = reason || (unanswered?.length ? "unsupported" : undefined);
if (gapReason) {
if (unanswered?.length) demand = `${unanswered.map(item => `${item.question}:${item.reason}`).join(";")}(原问题:${demand})`.slice(0, 600);
let recorded = false;
try {
await gapStore.record(user.userId, demand, reason);
await gapStore.record(user.userId, demand, gapReason);
recorded = true;
} catch {
/* Report failure explicitly, never pretend it was persisted. */
}
result.gap = { recorded, reason, demand };
result.gap = { recorded, reason: gapReason, demand };
}
await emit({
type: "step",