fix: harden AI analysis and seed defaults

This commit is contained in:
2026-07-08 04:38:22 -07:00
parent 9a437f4cbf
commit 6a1add3422
15 changed files with 653 additions and 167 deletions

View File

@@ -1,8 +1,11 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { ElderAiAnalysisOutput } from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
aiKnowledgeChunks,
aiKnowledgeEntries,
alertRules,
alertTriggers,
beds,
@@ -11,6 +14,7 @@ import {
careTasks,
chronicConditions,
deviceAssets,
elderAiAnalyses,
elders,
familyContacts,
familyFeedback,
@@ -198,6 +202,28 @@ type SeedFamilyFeedback = {
status: "open" | "in_progress" | "resolved" | "closed";
};
type SeedAiKnowledgeEntry = {
scope: "platform" | "organization";
title: string;
category: string;
tags: string;
body: string;
status: "enabled" | "disabled";
};
type SeedPreparedAiAnalysis = {
elderName: string;
createdHoursAgo: number;
dataScopes: string[];
result: ElderAiAnalysisOutput;
};
const DEFAULT_AI_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -433,8 +459,8 @@ const DEFAULT_ELDERS: SeedElder[] = [
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "首次入住评估已归档" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "照护计划已同步至护理组" },
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
@@ -902,7 +928,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
source: "设施巡检",
createdHoursAgo: 2,
},
{
@@ -910,7 +936,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
source: "护理排班",
createdHoursAgo: 6,
acknowledgedHoursAgo: 5,
},
@@ -918,8 +944,8 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
description: "重点照护房夜间巡房提醒已恢复,事件记录已归档便于后续追踪。",
source: "夜间巡房系统",
createdHoursAgo: 14,
acknowledgedHoursAgo: 13,
resolvedHoursAgo: 11,
@@ -1136,6 +1162,354 @@ const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
},
];
export const DEFAULT_AI_KNOWLEDGE_ENTRIES: SeedAiKnowledgeEntry[] = [
{
scope: "platform",
title: "夜间低血氧处理流程",
category: "医疗护理SOP",
tags: "血氧,吸氧,夜间,慢阻肺,应急复核",
body: "夜间连续血氧低于 92% 时,护理组需先确认探头位置、手部温度和体位,再记录复测值。血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。慢阻肺老人应同步检查吸氧设备、管路连接和医嘱流量,复核后在生命体征、护理任务和事件记录中闭环。",
status: "enabled",
},
{
scope: "platform",
title: "跌倒高风险住民照护要点",
category: "安全照护SOP",
tags: "防跌倒,夜间巡房,转移位,环境安全",
body: "跌倒高风险老人应保持床旁通道清晰,夜间保留低位照明,呼叫器放在伸手可及位置。转移位、如厕和康复训练需按照照护等级安排单人或双人协助;近期血压波动、认知障碍、术后康复、骨质疏松老人应提高巡视频次。发生跌倒或险情后,先评估意识、疼痛和活动能力,再通知家属并记录完整经过。",
status: "enabled",
},
{
scope: "organization",
title: "重点照护房夜间巡房规范",
category: "机构制度",
tags: "夜班,巡房,重点照护,交接班",
body: "重点照护房夜班至少每两小时巡房一次;存在血氧异常、吞咽风险、卧床压疮风险或夜间迷路风险的老人需补充专项观察。巡房记录应包含老人状态、体位、设备连接、呼叫器可用性和异常处理结果。未按计划完成的巡房任务需要夜班主管在交接班前补录原因和后续安排。",
status: "enabled",
},
{
scope: "organization",
title: "吞咽困难软食与呛咳观察",
category: "营养照护",
tags: "吞咽,软食,呛咳,营养,体重",
body: "吞咽困难老人进食时保持坐位,餐中小口慢食,避免催促。软食、增稠液和营养补充剂按医嘱执行,餐后半小时内不平卧。出现连续呛咳、声音湿哑、口唇发紫、进食量下降或体重持续下降时,应通知医生、营养师和家属,并将观察结果同步到护理任务。",
status: "enabled",
},
{
scope: "organization",
title: "异常情况家属同步规则",
category: "服务沟通",
tags: "家属沟通,异常同步,护理反馈,探访",
body: "涉及跌倒、血氧异常、发热、连续拒食、护理任务明显延误或设备故障影响照护时,责任护理组应在处置完成后同步家属。沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间;对仍在处理中事项,应在交接班中标注负责人,避免多次解释不一致。",
status: "enabled",
},
];
export const DEFAULT_PREPARED_AI_ANALYSES: SeedPreparedAiAnalysis[] = [
{
elderName: "钱松柏",
createdHoursAgo: 1,
dataScopes: ["elder", "health", "care", "incident", "facility", "knowledge"],
result: {
overallRiskLevel: "critical",
summary: "钱松柏夜间血氧低于 90%,且既往有慢阻肺和夜间吸氧记录,应优先完成呼吸状态复核、吸氧设备检查和医生评估。",
keyFindings: [
{
category: "呼吸风险",
severity: "critical",
evidence: "最新设备记录显示夜间血氧 89%,慢阻肺病史和长期吸入治疗增加夜间低氧风险。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
category: "设备闭环",
severity: "warning",
evidence: "系统事件已记录 S101 医养观察房血氧告警,需要确认吸氧设备、管路和医嘱流量。",
citationIds: ["resident-qsb-incident", "kb-night-spo2"],
},
],
recommendations: [
{
title: "立即复测血氧并通知值班医生",
priority: "urgent",
rationale: "血氧低于 90% 属于需要即时复核的高风险信号,且老人存在慢阻肺基础病。",
suggestedNextStep: "夜班护理组先复测血氧、检查吸氧设备和体位,再将复核结果同步给值班医生。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
title: "把低氧事件纳入交接班追踪",
priority: "high",
rationale: "告警、设备检查和医疗复核需要跨班次闭环,避免只完成单点处置。",
suggestedNextStep: "在护理任务中补充复测时间、吸氧流量确认结果和下一次观察时间。",
citationIds: ["resident-qsb-incident"],
},
],
dataGaps: ["缺少最近一次医生复核结论和吸氧流量确认记录。"],
citations: [
{
id: "resident-qsb-vitals",
sourceType: "resident_context",
sourceId: "elder:钱松柏:vitals",
title: "钱松柏生命体征与慢病记录",
excerpt: "慢阻肺,夜间吸氧;最新血氧记录 89%,心率 104需关注呼吸状态。",
},
{
id: "resident-qsb-incident",
sourceType: "resident_context",
sourceId: "elder:钱松柏:incident",
title: "S101 医养观察房血氧告警",
excerpt: "钱松柏夜间血氧持续低于阈值,需要护理组和医生复核。",
},
{
id: "kb-night-spo2",
sourceType: "knowledge",
sourceId: "夜间低血氧处理流程",
title: "夜间低血氧处理流程",
excerpt: "血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。",
},
],
confidence: 0.86,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "马淑芬",
createdHoursAgo: 3,
dataScopes: ["elder", "health", "care", "knowledge"],
result: {
overallRiskLevel: "high",
summary: "马淑芬存在吞咽困难、近期体重下降和晚餐软食观察任务,当前重点是防呛咳、保证摄入量并完成营养师复评。",
keyFindings: [
{
category: "吞咽与营养",
severity: "warning",
evidence: "健康档案记录脑梗后吞咽功能下降、近期体重下降;护理计划要求晚餐软食并观察呛咳。",
citationIds: ["resident-msf-health", "kb-swallowing"],
},
{
category: "护理优先级",
severity: "warning",
evidence: "晚餐软食进食观察任务为紧急优先级,需在进食时段完成并记录摄入情况。",
citationIds: ["resident-msf-care"],
},
],
recommendations: [
{
title: "进食时段安排专人观察",
priority: "high",
rationale: "吞咽风险与体重下降同时出现,餐中观察比事后补记更能降低呛咳风险。",
suggestedNextStep: "重点护理组在晚餐时记录坐位、进食量、呛咳情况和餐后半小时体位。",
citationIds: ["resident-msf-care", "kb-swallowing"],
},
{
title: "同步营养师复评体重趋势",
priority: "normal",
rationale: "近期体重偏低已进入复核,需结合摄入量决定是否调整加餐。",
suggestedNextStep: "汇总三天进食量、体重和呛咳观察后交由营养师复评。",
citationIds: ["resident-msf-health"],
},
],
dataGaps: ["缺少最近三天完整进食量记录。"],
citations: [
{
id: "resident-msf-health",
sourceType: "resident_context",
sourceId: "elder:马淑芬:health",
title: "马淑芬健康档案",
excerpt: "脑梗后吞咽功能下降,近期体重下降;软食,进食时保持坐位,餐后半小时内不平卧。",
},
{
id: "resident-msf-care",
sourceType: "resident_context",
sourceId: "elder:马淑芬:care",
title: "晚餐软食进食观察任务",
excerpt: "晚餐软食进食观察为紧急护理任务,需记录进食和呛咳情况。",
},
{
id: "kb-swallowing",
sourceType: "knowledge",
sourceId: "吞咽困难软食与呛咳观察",
title: "吞咽困难软食与呛咳观察",
excerpt: "吞咽困难老人进食时保持坐位,餐中小口慢食;出现连续呛咳、声音湿哑或进食量下降时应同步医生、营养师和家属。",
},
],
confidence: 0.8,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "王桂兰",
createdHoursAgo: 5,
dataScopes: ["elder", "health", "care", "family", "knowledge"],
result: {
overallRiskLevel: "medium",
summary: "王桂兰近期血压偏高但记录链路完整,建议继续晨晚复测、观察胸闷症状,并在家属探访前同步照护重点。",
keyFindings: [
{
category: "血压趋势",
severity: "warning",
evidence: "近期生命体征显示血压偏高,健康档案要求晨晚血压记录并在胸闷时立即联系医生。",
citationIds: ["resident-wgl-vitals"],
},
{
category: "家属沟通",
severity: "info",
evidence: "主要联系人每周六探访,可在探访前同步血压复测和低盐饮食执行情况。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
recommendations: [
{
title: "保持晨晚血压复测",
priority: "normal",
rationale: "当前为持续偏高而非单次异常,连续趋势比单点数值更适合指导照护安排。",
suggestedNextStep: "护理组在晨晚复测后记录数值、用药时间和是否胸闷。",
citationIds: ["resident-wgl-vitals"],
},
{
title: "探访前同步照护重点",
priority: "low",
rationale: "家属是主要联系人,提前同步可减少探访时的信息差。",
suggestedNextStep: "前台或责任护理员在探访前准备血压趋势、饮食执行和护理安排摘要。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
dataGaps: [],
citations: [
{
id: "resident-wgl-vitals",
sourceType: "resident_context",
sourceId: "elder:王桂兰:vitals",
title: "王桂兰生命体征与健康档案",
excerpt: "高血压十余年;最新血压偏高,需晨晚血压记录,胸闷或血压持续升高时立即联系医生。",
},
{
id: "resident-wgl-family",
sourceType: "resident_context",
sourceId: "elder:王桂兰:family",
title: "王桂兰家属联系人",
excerpt: "主要联系人张敏,每周六探访。",
},
{
id: "kb-family-sync",
sourceType: "knowledge",
sourceId: "异常情况家属同步规则",
title: "异常情况家属同步规则",
excerpt: "沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间。",
},
],
confidence: 0.74,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
];
function buildSeedKnowledgeContent(entry: SeedAiKnowledgeEntry): string {
return [entry.title, entry.category, entry.tags, entry.body].filter(Boolean).join("\n");
}
function getSeedKnowledgeKey(entry: Pick<SeedAiKnowledgeEntry, "scope" | "title">): string {
return `${entry.scope}:${entry.title}`;
}
async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [existingKnowledgeRows, elderRows, existingAnalysisRows] = await Promise.all([
database
.select({ scope: aiKnowledgeEntries.scope, title: aiKnowledgeEntries.title })
.from(aiKnowledgeEntries)
.where(
or(
and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId)),
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
),
),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
database.select({ elderId: elderAiAnalyses.elderId }).from(elderAiAnalyses).where(eq(elderAiAnalyses.organizationId, organizationId)),
]);
const existingKnowledgeKeys = new Set(existingKnowledgeRows.map(getSeedKnowledgeKey));
const missingKnowledgeEntries = DEFAULT_AI_KNOWLEDGE_ENTRIES.filter((entry) => !existingKnowledgeKeys.has(getSeedKnowledgeKey(entry)));
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const analyzedElderIds = new Set(existingAnalysisRows.map((analysis) => analysis.elderId));
const missingAnalyses = DEFAULT_PREPARED_AI_ANALYSES.filter((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
return elderId !== undefined && !analyzedElderIds.has(elderId);
});
if (missingKnowledgeEntries.length === 0 && missingAnalyses.length === 0) {
return;
}
const now = new Date();
await database.transaction(async (transaction) => {
if (missingKnowledgeEntries.length > 0) {
const knowledgeRows = await transaction
.insert(aiKnowledgeEntries)
.values(
missingKnowledgeEntries.map((entry) => ({
scope: entry.scope,
organizationId: entry.scope === "organization" ? organizationId : null,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
createdAt: now,
updatedAt: now,
})),
)
.returning();
if (knowledgeRows.length !== missingKnowledgeEntries.length) {
throw new Error("AI 知识库初始化失败");
}
const sourceByKey = new Map(missingKnowledgeEntries.map((entry) => [getSeedKnowledgeKey(entry), entry]));
await transaction.insert(aiKnowledgeChunks).values(
knowledgeRows.map((row) => {
const source = sourceByKey.get(getSeedKnowledgeKey(row));
if (!source) {
throw new Error("AI 知识库分块初始化失败");
}
return {
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: 0,
content: buildSeedKnowledgeContent(source),
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
createdAt: now,
};
}),
);
}
if (missingAnalyses.length > 0) {
await transaction.insert(elderAiAnalyses).values(
missingAnalyses.map((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
if (!elderId) {
throw new Error("AI 分析记录初始化失败");
}
return {
organizationId,
elderId,
status: "completed" as const,
dataScopes: analysis.dataScopes,
resultJson: analysis.result,
citationsJson: analysis.result.citations,
modelSummaryJson: analysis.result.modelSummary,
createdAt: new Date(now.getTime() - analysis.createdHoursAgo * 60 * 60 * 1000),
};
}),
);
}
});
}
function hasRows(rows: unknown[]): boolean {
return rows.length > 0;
}
@@ -1150,6 +1524,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
await seedDefaultAiWorkspaceData(organizationId);
return;
}
@@ -1608,4 +1983,5 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
});
await seedDefaultAiWorkspaceData(organizationId);
}