feat: add AI knowledge analysis MVP

This commit is contained in:
2026-07-05 00:47:52 -07:00
parent 6aa72d3b43
commit e204974b57
32 changed files with 8705 additions and 4 deletions

View File

@@ -0,0 +1,314 @@
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,
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 } 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,
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 normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput {
const allowed = new Map(citations.map((citation) => [citation.id, citation]));
const merged = output.citations.filter((citation) => allowed.has(citation.id));
const seenIds = new Set(merged.map((citation) => citation.id));
const missing = citations.filter((citation) => !seenIds.has(citation.id));
return {
...output,
citations: [...merged, ...missing],
};
}
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)),
},
};
}
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 saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes: residentContext.context.dataScopes,
category: "retrieval_failed",
});
return { success: false, reason: knowledgeResults.reason, status: knowledgeResults.status };
}
const dataScopes = knowledgeResults.data.results.length > 0
? [...residentContext.context.dataScopes, "knowledge" as const]
: residentContext.context.dataScopes;
const knowledgeCitations: AiCitation[] = knowledgeResults.data.results.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。",
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","embeddingModel":"${runtimeConfig.config.embeddingModel}"}。`,
`可用 citations${JSON.stringify(citations)}`,
`住民上下文:\n${residentContext.context.promptContext}`,
`知识库片段:\n${knowledgeResults.data.results.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);
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 normalizedOutput = normalizeCitations(output, citations);
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),
},
};
}