327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
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 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。",
|
||
knowledgeUnavailableReason ? `知识库检索不可用:${knowledgeUnavailableReason}。请在 dataGaps 中包含“知识库检索不可用,分析未使用内部知识库”。` : "",
|
||
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","embeddingModel":"${runtimeConfig.config.embeddingModel}"}。`,
|
||
`可用 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);
|
||
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(
|
||
knowledgeUnavailableReason
|
||
? {
|
||
...output,
|
||
dataGaps: [...output.dataGaps, "知识库检索不可用,分析未使用内部知识库"],
|
||
}
|
||
: 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),
|
||
},
|
||
};
|
||
}
|