Files
teatea-pension/modules/ai/server/analysis.ts

407 lines
13 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 "server-only";
import { ChatOpenAI } from "@langchain/openai";
import { and, desc, eq } from "drizzle-orm";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type {
AiCitation,
AiErrorCategory,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput,
} from "@/modules/ai/types";
import {
canViewAnalysisScopes,
parseDataScopes,
validateElderAiAnalysisOutput,
} from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import { getDatabase } from "@/modules/core/server/db";
import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
import type { AuthContext } from "@/modules/core/types";
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
return new ChatOpenAI({
apiKey: config.apiKey,
model: config.chatModel,
maxTokens: 1800,
temperature: 0.2,
configuration,
});
}
function toIsoString(value: Date): string {
return value.toISOString();
}
function mapErrorCategory(error: unknown): AiErrorCategory {
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) {
return "timeout";
}
return "provider_error";
}
function getBriefErrorReason(category: AiErrorCategory): string {
if (category === "missing_config") {
return "AI 服务未配置";
}
if (category === "schema_validation_failed") {
return "AI 返回结构不符合固定分析格式";
}
if (category === "retrieval_failed") {
return "知识库检索失败";
}
if (category === "timeout") {
return "AI 服务响应超时";
}
return "AI 服务暂时不可用";
}
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function extractJsonFromText(value: string): unknown {
const trimmed = value.trim();
const direct = safeJsonParse(trimmed);
if (direct) {
return direct;
}
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start < 0 || end <= start) {
return null;
}
return safeJsonParse(trimmed.slice(start, end + 1));
}
function collectReferencedCitationIds(output: ElderAiAnalysisOutput): Set<string> {
const referencedIds = new Set<string>();
for (const finding of output.keyFindings) {
for (const citationId of finding.citationIds) {
referencedIds.add(citationId);
}
}
for (const recommendation of output.recommendations) {
for (const citationId of recommendation.citationIds) {
referencedIds.add(citationId);
}
}
return referencedIds;
}
function normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput | null {
const allowed = new Map(citations.map((citation) => [citation.id, citation]));
for (const citation of output.citations) {
if (!allowed.has(citation.id)) {
return null;
}
}
const referencedIds = collectReferencedCitationIds(output);
for (const citationId of referencedIds) {
if (!allowed.has(citationId)) {
return null;
}
}
return {
...output,
citations: citations.filter((citation) => referencedIds.has(citation.id)),
};
}
async function saveFailedAnalysis(input: {
context: AuthContext;
organizationId: string;
elderId: string;
dataScopes: string[];
category: AiErrorCategory;
}): Promise<void> {
const database = getDatabase();
await database.insert(elderAiAnalyses).values({
organizationId: input.organizationId,
elderId: input.elderId,
actorAccountId: input.context.account.id,
status: "failed",
dataScopes: input.dataScopes,
errorCategory: input.category,
errorReason: getBriefErrorReason(input.category),
});
await recordAuditLog({
actor: input.context.account,
organizationId: input.organizationId,
action: "ai.elder_analysis.generate",
targetType: "elder",
targetId: input.elderId,
result: "failure",
reason: getBriefErrorReason(input.category),
});
}
function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem {
const scopes = parseDataScopes(row.dataScopes);
const restricted = !canViewAnalysisScopes(scopes, permissions);
if (restricted) {
return {
id: row.id,
elderId: row.elderId,
status: row.status,
dataScopes: scopes,
createdAt: toIsoString(row.createdAt),
restricted: true,
};
}
const result = validateElderAiAnalysisOutput(row.resultJson);
const errorCategory = row.errorCategory as AiErrorCategory;
return {
id: row.id,
elderId: row.elderId,
status: row.status,
dataScopes: scopes,
createdAt: toIsoString(row.createdAt),
restricted: false,
result: result ?? undefined,
errorCategory: errorCategory || undefined,
errorReason: row.errorReason || undefined,
};
}
export async function listElderAiAnalyses(
context: AuthContext,
elderId: string,
): Promise<ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>> {
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
}
const database = getDatabase();
const rows = await database
.select()
.from(elderAiAnalyses)
.where(and(eq(elderAiAnalyses.organizationId, organizationId), eq(elderAiAnalyses.elderId, elderId)))
.orderBy(desc(elderAiAnalyses.createdAt))
.limit(20);
return {
success: true,
data: {
history: rows.map((row) => rowToHistoryItem(row, context.permissions)),
},
};
}
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem {
return {
elderName,
...rowToHistoryItem(row, permissions),
};
}
export async function listAiAnalysisBoard(
context: AuthContext,
limit = 8,
): Promise<ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>> {
if (!context.permissions.includes("ai:read")) {
return { success: false, reason: "无权查看 AI 分析", status: 403 };
}
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
}
const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8;
const rowLimit = Math.min(20, Math.max(1, normalizedLimit));
const database = getDatabase();
const [rows, elderRows] = await Promise.all([
database
.select()
.from(elderAiAnalyses)
.where(eq(elderAiAnalyses.organizationId, organizationId))
.orderBy(desc(elderAiAnalyses.createdAt))
.limit(rowLimit),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
]);
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
return {
success: true,
data: {
items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)),
},
};
}
export async function generateElderAiAnalysis(
context: AuthContext,
elderId: string,
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
const runtimeConfig = getAiRuntimeConfig();
if (!runtimeConfig.success) {
const organizationId = context.organization?.id;
if (organizationId) {
await saveFailedAnalysis({
context,
organizationId,
elderId,
dataScopes: ["elder"],
category: "missing_config",
});
}
return { success: false, reason: runtimeConfig.reason, status: 500 };
}
const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const knowledgeResults = context.permissions.includes("knowledge:read")
? await retrieveKnowledge(
residentContext.context.organizationId,
`${residentContext.context.elderName}\n${residentContext.context.promptContext}`,
)
: { success: true as const, data: { results: [] } };
if (!knowledgeResults.success) {
await recordAuditLog({
actor: context.account,
organizationId: residentContext.context.organizationId,
action: "ai.knowledge.retrieve",
targetType: "elder",
targetId: elderId,
result: "failure",
reason: getBriefErrorReason("retrieval_failed"),
});
}
const knowledgeItems = knowledgeResults.success ? knowledgeResults.data.results : [];
const knowledgeUnavailableReason = knowledgeResults.success ? "" : getBriefErrorReason("retrieval_failed");
const dataScopes = knowledgeItems.length > 0
? [...residentContext.context.dataScopes, "knowledge" as const]
: residentContext.context.dataScopes;
const knowledgeCitations: AiCitation[] = knowledgeItems.map((item, index) => ({
id: `kb-${index + 1}`,
sourceType: "knowledge",
sourceId: item.chunkId,
title: item.title,
excerpt: item.content.slice(0, 240),
}));
const citations = [...residentContext.context.citations, ...knowledgeCitations];
const model = createChatModel(runtimeConfig.config);
const prompt = [
"你是养老机构运营辅助分析智能体。只输出 JSON不要 Markdown。",
"分析必须是建议性、非诊断性,不能创建业务记录或承诺操作。",
"只能使用提供的上下文和知识库片段,证据必须引用 citation id。",
"输出 JSON 字段overallRiskLevel(low|medium|high|critical|unknown), summary, keyFindings, recommendations, dataGaps, citations, confidence, modelSummary。",
"keyFindings[] 字段category, severity(info|warning|critical), evidence, citationIds。",
"recommendations[] 字段title, priority(low|normal|high|urgent), rationale, suggestedNextStep, citationIds。",
"citations[] 只返回你实际引用过的 citation 对象citationIds 必须来自可用 citations。",
knowledgeUnavailableReason ? `知识库检索不可用:${knowledgeUnavailableReason}。请在 dataGaps 中包含“知识库检索不可用,分析未使用内部知识库”。` : "",
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","knowledgeRetrieval":"keyword"}。`,
`可用 citations${JSON.stringify(citations)}`,
`住民上下文:\n${residentContext.context.promptContext}`,
`知识库片段:\n${knowledgeItems.map((item, index) => `[kb-${index + 1}] ${item.title}\n${item.content}`).join("\n\n") || "无"}`,
].join("\n\n");
let rawOutput: unknown;
try {
const response = await model.invoke(prompt, {
response_format: { type: "json_object" },
});
const content = Array.isArray(response.content)
? response.content.map((item) => (typeof item === "string" ? item : "")).join("\n")
: String(response.content);
rawOutput = extractJsonFromText(content);
} catch (error) {
const category = mapErrorCategory(error);
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes,
category,
});
return { success: false, reason: getBriefErrorReason(category), status: 502 };
}
const output = validateElderAiAnalysisOutput(rawOutput);
if (!output) {
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes,
category: "schema_validation_failed",
});
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
}
const outputWithDataGaps = knowledgeUnavailableReason
? {
...output,
dataGaps: [...output.dataGaps, "知识库检索不可用,分析未使用内部知识库"],
}
: output;
const normalizedOutput = normalizeCitations(outputWithDataGaps, citations);
if (!normalizedOutput) {
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes,
category: "schema_validation_failed",
});
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
}
const database = getDatabase();
const rows = await database
.insert(elderAiAnalyses)
.values({
organizationId: residentContext.context.organizationId,
elderId,
actorAccountId: context.account.id,
status: "completed",
dataScopes,
resultJson: normalizedOutput,
citationsJson: normalizedOutput.citations,
modelSummaryJson: normalizedOutput.modelSummary,
})
.returning();
const row = rows[0];
if (!row) {
return { success: false, reason: "AI 分析保存失败", status: 500 };
}
await recordAuditLog({
actor: context.account,
organizationId: residentContext.context.organizationId,
action: "ai.elder_analysis.generate",
targetType: "elder",
targetId: elderId,
result: "success",
reason: "AI 分析已生成",
});
return {
success: true,
data: {
analysis: rowToHistoryItem(row, context.permissions),
},
};
}