Files
ln-bi/src/server/routes/asset-assistant/index.ts
T

440 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { bodyLimit } from "hono/body-limit";
import type { AuthUser } from "../../auth/types.js";
import { canManageFeedback } from "../../auth/types.js";
import { vehicleRepository } from "../vehicles/repository.js";
import { visibleVehicles } from "./model.js";
import { initialPlan, planConversation } from "./planner.js";
import { execute, applyAction } from "./execute.js";
import {
getSession,
getNode,
addNode,
trail,
deleteSession,
acquire,
conversationContext,
} from "./session.js";
import { gapStore } from "./gaps.js";
import {
CATEGORY_LABELS,
GAP_STATUSES,
GROUP_LABELS,
type AssistantRequest,
type AssistantResult,
type GapReason,
type GapStatus,
} from "../../../shared/assets/assistant.js";
const router = new Hono<{ Variables: { user: AuthUser } }>();
router.use("*", bodyLimit({ maxSize: 16000 }));
router.use("*", async (c, next) => {
if (!c.get("user")) return c.json({ error: "请先登录。" }, 401);
c.header("Cache-Control", "no-store");
await next();
});
router.get("/capabilities", (c) => {
const enabled =
!!process.env.DEEPSEEK_API_KEY &&
process.env.ASSET_AI_EXTERNAL_ENABLED !== "0";
return c.json({
engine: enabled ? "deepseek" : "local",
model: enabled ? "deepseek-flash" : null,
message: enabled
? "支持自然语言理解、连续追问和多层分析"
: "当前使用本地基础问数;配置 DeepSeek 后可开启自然语言分析",
});
});
router.get("/gaps", async (c) => {
const user = c.get("user"),
manage = canManageFeedback(user.roles);
if (c.req.query("scope") === "all" && !manage)
return c.json({ error: "无权限。" }, 403);
try {
return c.json({
items: await gapStore.list(
c.req.query("scope") === "all" ? null : user.userId,
),
canManage: manage,
});
} catch {
return c.json({ error: "需求记录暂不可用,请检查内部存储配置。" }, 503);
}
});
router.patch("/gaps/:id", async (c) => {
if (!canManageFeedback(c.get("user").roles))
return c.json({ error: "无权限。" }, 403);
const body = await c.req.json().catch(() => null),
id = Number(c.req.param("id"));
if (
!Number.isSafeInteger(id) ||
id <= 0 ||
!body ||
!Object.hasOwn(GAP_STATUSES, body.status)
)
return c.json({ error: "参数无效。" }, 400);
try {
await gapStore.update(id, body.status as GapStatus);
return c.json({ ok: true });
} catch {
return c.json({ error: "保存失败,请重试。" }, 503);
}
});
router.post("/feedback", async (c) => {
const body = await c.req.json().catch(() => null);
if (
!body ||
typeof body.sessionId !== "string" ||
typeof body.resultId !== "string" ||
(body.subject !== null && typeof body.subject !== "string")
)
return c.json({ error: "参数无效。" }, 400);
try {
const session = getSession(c.get("user"), body.subject, body.sessionId),
node = getNode(session, body.resultId)!;
if (!node.flagged) {
await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
node.flagged = true;
}
return c.json({ ok: true });
} catch {
return c.json({ error: "记录失败或会话已过期,请重试。" }, 400);
}
});
router.delete("/session/:id", async (c) => {
const subject = c.req.query("subject") || null;
try {
deleteSession(c.get("user"), subject, c.req.param("id"));
return c.json({ ok: true });
} catch {
return c.json({ error: "会话已过期。" }, 404);
}
});
router.post("/export", async (c) => {
const body = await c.req.json().catch(() => null);
if (
!body ||
typeof body.sessionId !== "string" ||
typeof body.resultId !== "string" ||
(body.subject !== null && typeof body.subject !== "string")
)
return c.json({ error: "参数无效。" }, 400);
const release = acquire(c.get("user").userId);
if (!release) return c.json({ error: "操作过于频繁。" }, 429);
try {
const session = getSession(c.get("user"), body.subject, body.sessionId),
node = getNode(session, body.resultId)!;
if (node.plan.kind === "clarify")
return c.json({ error: "暂无可导出结果。" }, 400);
const vehicles = visibleVehicles(
await vehicleRepository.getVehicles(),
c.get("user"),
body.subject,
);
const result = await execute(vehicles, node.plan);
const records = [
[
"车牌",
"车型",
"型号",
"区域",
"城市",
"部门",
"运营状态",
"异动类型",
"预计完成时间",
"日期",
"业务类型",
],
...result.rows.map((r) => [
r.plate,
r.type,
r.model,
r.region,
r.city,
r.department,
r.status,
r.abnormalType || "",
r.targetCompletionTime || "",
r.date || "",
r.event || "",
]),
];
const csv = records
.map((row) =>
row
.map(
(v) =>
'"' +
String(v)
.replace(/^[=+@\-\t\r\n]/, "'$&")
.replaceAll('"', '""') +
'"',
)
.join(","),
)
.join("\r\n");
c.header("Content-Type", "text/csv; charset=utf-8");
c.header(
"Content-Disposition",
'attachment; filename="asset-analysis.csv"',
);
return c.body("\uFEFF" + csv);
} catch {
return c.json({ error: "导出失败或权限已变化,请重新查询。" }, 400);
} finally {
release();
}
});
router.post("/ask", async (c) => {
const user = c.get("user");
const raw = await c.req.json().catch(() => null);
if (
!raw ||
typeof raw !== "object" ||
Array.isArray(raw) ||
Object.keys(raw).some(
(k) =>
!["message", "subject", "sessionId", "baseResultId", "action"].includes(
k,
),
) ||
(raw.subject != null &&
(typeof raw.subject !== "string" || raw.subject.length > 150)) ||
(raw.sessionId != null &&
(typeof raw.sessionId !== "string" || raw.sessionId.length > 64)) ||
(raw.baseResultId != null &&
(typeof raw.baseResultId !== "string" || !raw.sessionId)) ||
!!raw.action === !!raw.message ||
(raw.message != null &&
(typeof raw.message !== "string" ||
!raw.message.trim() ||
raw.message.length > 2000)) ||
(raw.action && (typeof raw.action !== "object" || !raw.baseResultId))
)
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
const input: AssistantRequest = { ...raw, subject: raw.subject || null };
const release = acquire(user.userId);
if (!release)
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
let session, base;
try {
session = getSession(user, input.subject, input.sessionId);
base = getNode(session, input.baseResultId);
} catch {
release();
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
}
const currentSession = session,
baseNode = base;
return streamSSE(c, async (stream) => {
const controller = new AbortController();
stream.onAbort(() => controller.abort());
const emit = (event: object) =>
stream.writeSSE({ data: JSON.stringify(event) });
let demand = "查询执行",
reason: GapReason | undefined;
try {
await emit({
type: "step",
label: input.action ? "延续当前分析条件" : "理解问题与对话上下文",
});
const vehicles = visibleVehicles(
await vehicleRepository.getVehicles(),
user,
input.subject,
);
if (controller.signal.aborted) return;
const context = conversationContext(currentSession, baseNode);
const previous = context.previous;
let plan = previous || initialPlan();
let warning: string | undefined;
let reply: string | undefined;
let answerKind: "answer" | "clarify" | undefined;
let suggestions: 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}`;
} else {
demand = input
.message!.trim()
.replace(/sk-[a-z0-9]{8,}/gi, "[凭据已隐藏]")
.slice(0, 600);
const decision = await planConversation({
message: input.message!,
previous,
history: context.history,
resultContext: context.resultContext,
candidates: context.candidates,
vehicles,
signal: controller.signal,
});
plan = decision.plan;
reason = decision.reason;
warning = decision.warning;
reply = decision.reply;
answerKind = decision.answerKind;
suggestions = decision.suggestions;
engine = decision.engine;
if (decision.baseResultId)
parent = getNode(currentSession, decision.baseResultId);
}
if (controller.signal.aborted) return;
await emit({
type: "step",
label:
plan.kind === "clarify"
? "组织回答与下一步建议"
: "查询数据并计算统计结果",
});
let result: AssistantResult =
plan.kind === "clarify"
? {
plan,
title:
answerKind === "answer"
? "资产问数助手"
: reason
? "这项分析还需要补充能力"
: "继续完善分析",
summary:
reply || plan.clarification || "请补充想看的指标与范围。",
source: "资产问数助手",
fetchedAt: new Date().toISOString(),
groups: [],
rows: [],
metrics: [],
answerKind: answerKind === "answer" ? "help" : "clarification",
}
: await execute(vehicles, plan);
if (controller.signal.aborted) return;
result.engine = engine;
if (suggestions?.length) result.suggestions = suggestions;
if (warning) result.warning = warning;
if (plan.kind !== "clarify") {
const includes = Object.entries(plan.filters).map(
([key, values]) =>
`${GROUP_LABELS[key as keyof typeof GROUP_LABELS]}:${values.join("、")}`,
);
const excludes = Object.entries(plan.excludeFilters || {}).map(
([key, values]) =>
`排除${GROUP_LABELS[key as keyof typeof GROUP_LABELS]}:${values.join("、")}`,
);
result.interpretation = [
plan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[plan.category],
...includes,
...excludes,
...(plan.exception
? [
{
overdue: "已超预计完成时间",
dueSoon: "未来 7 天到期",
missingDueDate: "未填写预计完成时间",
}[plan.exception],
]
: []),
...(plan.vehicleIds ? [`指定 ${plan.vehicleIds.length} 台车辆`] : []),
...(plan.kind === "flow" ? [`${plan.start} 至 ${plan.end}`] : []),
].join(" · ");
if (parent) {
const baseLabel = trail(currentSession, parent).at(-1)?.label;
if (baseLabel)
result.contextNote = `承接「${baseLabel}」${parent.id !== baseNode?.id ? "的历史分析" : ""}`;
}
}
const label =
plan.kind === "clarify"
? "对话补充"
: `${CATEGORY_LABELS[plan.category]} · ${Object.values(plan.filters).flat().join(" / ") || (plan.kind === "flow" ? `${plan.start}—${plan.end}` : GROUP_LABELS[plan.groupBy])}`;
if (parent) {
const sourcePlan = conversationContext(currentSession, parent).previous;
if (sourcePlan) {
const scope = [
CATEGORY_LABELS[sourcePlan.category],
...Object.values(sourcePlan.filters).flat(),
...Object.values(sourcePlan.excludeFilters || {})
.flat()
.map((v) => `排除${v}`),
...(sourcePlan.kind === "flow"
? [`交退换 ${sourcePlan.start}—${sourcePlan.end}`]
: []),
...(sourcePlan.exception
? [
{
overdue: "已超期",
dueSoon: "未来 7 天到期",
missingDueDate: "缺预计完成时间",
}[sourcePlan.exception],
]
: []),
].join(" · ");
demand = `${demand.slice(0, 400)}(承接:${scope})`.slice(0, 600);
}
}
const node = addNode(currentSession, {
plan,
parent: parent?.id,
label,
demand,
reason,
question: input.message || demand,
answer: result.summary,
resultContext:
plan.kind === "clarify"
? undefined
: {
groups: result.groups.slice(0, 100),
metrics: result.metrics,
insights: result.insights,
summary: result.summary,
},
});
result = {
...result,
sessionId: currentSession.id,
resultId: node.id,
breadcrumbs: trail(currentSession, node),
};
if (reason) {
let recorded = false;
try {
await gapStore.record(user.userId, demand, reason);
recorded = true;
} catch {
/* Report failure explicitly, never pretend it was persisted. */
}
result.gap = { recorded, reason, demand };
}
await emit({
type: "step",
label: "分析完成",
});
await emit({ type: "result", result });
} catch {
if (!controller.signal.aborted) {
let recorded = false;
try {
await gapStore.record(user.userId, demand, "generation");
recorded = true;
} catch {
/* No raw error or query payload in logs. */
}
await emit({
type: "error",
message: `查询未完成,请检查条件后重试。${recorded ? "已记录本次未完成的需求。" : "需求记录暂未保存。"}`,
});
}
} finally {
release();
}
});
});
export default router;