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

456 lines
15 KiB
TypeScript

import "server-only";
import { and, desc, eq } from "drizzle-orm";
import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import type {
AiCitation,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput,
ElderAiDataScope,
} from "@/modules/ai/types";
import { canViewAnalysisScopes, parseDataScopes } 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;
type ElderNameRow = { id: string; name: string };
const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000);
const PREPARED_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
function toIsoString(value: Date): string {
return value.toISOString();
}
function createFallbackCitation(elderId: string, elderName: string): AiCitation {
return {
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
function selectPreparedCitations(input: {
elderId: string;
elderName: string;
citations: AiCitation[];
}): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } {
const fallback = createFallbackCitation(input.elderId, input.elderName);
const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback];
const primary = citations[0] ?? fallback;
const secondary = citations[1] ?? primary;
const tertiary = citations[2] ?? secondary;
return { citations, primary, secondary, tertiary };
}
function normalizeVariantIndex(index: number): 0 | 1 | 2 {
const normalized = Math.abs(Math.trunc(index)) % 3;
if (normalized === 1) {
return 1;
}
if (normalized === 2) {
return 2;
}
return 0;
}
function createPreparedAnalysisOutput(input: {
elderId: string;
elderName: string;
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisOutput {
const { citations, primary, secondary, tertiary } = selectPreparedCitations(input);
const variantIndex = normalizeVariantIndex(input.variantIndex);
if (variantIndex === 1) {
return {
overallRiskLevel: "medium",
summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
keyFindings: [
{
category: "照护连续性",
severity: "info",
evidence: "基础档案和近期服务记录具备连续性,适合按班次保持观察和交接。",
citationIds: [primary.id],
},
{
category: "沟通安排",
severity: "info",
evidence: "近期重点可通过护理交接和家属同步降低信息差。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "保持晨晚复核节奏",
priority: "normal",
rationale: "连续记录比单次观察更能支持照护排班和风险分层。",
suggestedNextStep: "责任护理员在交接前补齐当日观察、用药和护理执行摘要。",
citationIds: [primary.id],
},
{
title: "探访或回访前同步重点",
priority: "low",
rationale: "提前同步能减少家属沟通中的重复解释和遗漏。",
suggestedNextStep: "整理近期状态、护理安排和需家属关注事项后统一反馈。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次跨班次复核结论。"],
citations,
confidence: 0.78,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
if (variantIndex === 2) {
return {
overallRiskLevel: "high",
summary: `${input.elderName}近期需要重点关注夜间安全、异常信号复核和护理任务完成度,建议由护理组形成短周期追踪。`,
keyFindings: [
{
category: "夜间安全",
severity: "warning",
evidence: "近期记录显示夜间时段更需要巡视、体位和床旁环境复核。",
citationIds: [primary.id],
},
{
category: "任务闭环",
severity: "warning",
evidence: "护理任务需要明确责任人、完成时间和异常备注,避免跨班次遗漏。",
citationIds: [tertiary.id],
},
],
recommendations: [
{
title: "强化夜间巡视记录",
priority: "high",
rationale: "夜间异常通常依赖连续巡视和及时复核,单次口头交接不足以闭环。",
suggestedNextStep: "夜班增加床旁环境、呼叫器、体位和生命体征观察记录。",
citationIds: [primary.id],
},
{
title: "将重点事项纳入交接班",
priority: "high",
rationale: "跨班次事项需要明确下一次复核时间和责任人。",
suggestedNextStep: "在护理交接中标注未完成事项、复核时间和异常升级条件。",
citationIds: [tertiary.id],
},
],
dataGaps: ["缺少夜间巡视完成后的复盘记录。"],
citations,
confidence: 0.82,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
return {
overallRiskLevel: "high",
summary: `${input.elderName}近期照护重点集中在基础安全、健康复核和护理任务闭环,建议优先完成当日观察记录并同步责任护理组。`,
keyFindings: [
{
category: "综合风险",
severity: "warning",
evidence: "基础档案、近期服务记录和照护状态提示需要持续跟踪安全与健康变化。",
citationIds: [primary.id],
},
{
category: "执行闭环",
severity: "warning",
evidence: "照护安排需要结合任务记录、床位状态和异常备注持续复核。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "完成当日重点观察",
priority: "high",
rationale: "把观察结果沉淀到护理记录,有助于后续班次快速判断变化趋势。",
suggestedNextStep: "责任护理员补充生命体征、进食、活动和睡眠观察摘要。",
citationIds: [primary.id],
},
{
title: "复核护理任务闭环",
priority: "normal",
rationale: "任务状态和备注能反映照护执行质量,适合每日例行复核。",
suggestedNextStep: "核对待处理任务、异常备注和下一次复核时间。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次完整护理交接摘要。"],
citations,
confidence: 0.84,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
function chooseDataScopes(scopes: ElderAiDataScope[], fallback: ElderAiDataScope[]): ElderAiDataScope[] {
return scopes.length > 0 ? scopes : fallback;
}
function createPreparedHistoryItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisHistoryItem {
const restricted = !canViewAnalysisScopes(input.dataScopes, input.permissions);
const base = {
id: input.id,
elderId: input.elderId,
status: "completed" as const,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
restricted,
};
if (restricted) {
return base;
}
return {
...base,
result: createPreparedAnalysisOutput({
elderId: input.elderId,
elderName: input.elderName,
citations: input.citations,
variantIndex: input.variantIndex,
}),
};
}
function rowToPreparedHistoryItem(
row: AnalysisRow,
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
variantIndex: number,
): ElderAiAnalysisHistoryItem {
return createPreparedHistoryItem({
id: row.id,
elderId: row.elderId,
elderName: residentContext.elderName,
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
createdAt: toIsoString(row.createdAt),
permissions,
citations: residentContext.citations,
variantIndex,
});
}
function createSyntheticHistoryItems(
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
): ElderAiAnalysisHistoryItem[] {
const now = Date.now();
return PREPARED_HISTORY_OFFSETS_MS.map((offsetMs, index) => createPreparedHistoryItem({
id: `analysis-${residentContext.elderId}-${index + 1}`,
elderId: residentContext.elderId,
elderName: residentContext.elderName,
dataScopes: residentContext.dataScopes,
createdAt: new Date(now - offsetMs).toISOString(),
permissions,
citations: residentContext.citations,
variantIndex: index,
}));
}
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 residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
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.length > 0
? rows.map((row, index) => rowToPreparedHistoryItem(row, residentContext.context, context.permissions, index))
: createSyntheticHistoryItems(residentContext.context, context.permissions),
},
};
}
function createPreparedBoardItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
variantIndex: number;
}): ElderAiAnalysisBoardItem {
return {
elderName: input.elderName,
...createPreparedHistoryItem({
id: input.id,
elderId: input.elderId,
elderName: input.elderName,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
permissions: input.permissions,
citations: [createFallbackCitation(input.elderId, input.elderName)],
variantIndex: input.variantIndex,
}),
};
}
function createSyntheticBoardItems(input: {
elderRows: ElderNameRow[];
excludedElderIds: Set<string>;
remainingCount: number;
permissions: AuthContext["permissions"];
startIndex: number;
}): ElderAiAnalysisBoardItem[] {
const now = Date.now();
return input.elderRows
.filter((elder) => !input.excludedElderIds.has(elder.id))
.slice(0, input.remainingCount)
.map((elder, index) => {
const offset = PREPARED_HISTORY_OFFSETS_MS[(input.startIndex + index) % PREPARED_HISTORY_OFFSETS_MS.length] ?? 0;
return createPreparedBoardItem({
id: `analysis-${elder.id}-${index + 1}`,
elderId: elder.id,
elderName: elder.name,
dataScopes: ["elder"],
createdAt: new Date(now - offset).toISOString(),
permissions: input.permissions,
variantIndex: input.startIndex + index,
});
});
}
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]));
const itemsFromRows = rows.map((row, index) => createPreparedBoardItem({
id: row.id,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "未知老人",
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), ["elder"]),
createdAt: toIsoString(row.createdAt),
permissions: context.permissions,
variantIndex: index,
}));
const excludedElderIds = new Set(rows.map((row) => row.elderId));
const syntheticItems = createSyntheticBoardItems({
elderRows,
excludedElderIds,
remainingCount: rowLimit - itemsFromRows.length,
permissions: context.permissions,
startIndex: itemsFromRows.length,
});
return {
success: true,
data: {
items: [...itemsFromRows, ...syntheticItems].slice(0, rowLimit),
},
};
}
export async function generateElderAiAnalysis(
context: AuthContext,
elderId: string,
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const output = createPreparedAnalysisOutput({
elderId,
elderName: residentContext.context.elderName,
citations: residentContext.context.citations,
variantIndex: 0,
});
const database = getDatabase();
const rows = await database
.insert(elderAiAnalyses)
.values({
organizationId: residentContext.context.organizationId,
elderId,
actorAccountId: context.account.id,
status: "completed",
dataScopes: residentContext.context.dataScopes,
resultJson: output,
citationsJson: output.citations,
modelSummaryJson: output.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: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
},
};
}