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 = { 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 { const referencedIds = new Set(); 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 { 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> { 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> { 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> { 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), }, }; }