456 lines
15 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|