fix: use prepared AI analysis outputs
This commit is contained in:
@@ -1,23 +1,16 @@
|
||||
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 { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||
import type {
|
||||
AiCitation,
|
||||
AiErrorCategory,
|
||||
ElderAiAnalysisBoardItem,
|
||||
ElderAiAnalysisHistoryItem,
|
||||
ElderAiAnalysisOutput,
|
||||
ElderAiDataScope,
|
||||
} from "@/modules/ai/types";
|
||||
import {
|
||||
canViewAnalysisScopes,
|
||||
parseDataScopes,
|
||||
validateElderAiAnalysisOutput,
|
||||
} 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";
|
||||
@@ -25,180 +18,255 @@ 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 };
|
||||
|
||||
function createChatModel(config: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
chatModel: string;
|
||||
requestTimeoutMs: number;
|
||||
maxTokens: number;
|
||||
maxRetries: number;
|
||||
}) {
|
||||
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
||||
return new ChatOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
model: config.chatModel,
|
||||
maxTokens: config.maxTokens,
|
||||
maxRetries: config.maxRetries,
|
||||
temperature: 0.2,
|
||||
timeout: config.requestTimeoutMs,
|
||||
configuration,
|
||||
});
|
||||
}
|
||||
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 mapErrorCategory(error: unknown): AiErrorCategory {
|
||||
if (!(error instanceof Error)) {
|
||||
return "provider_error";
|
||||
}
|
||||
|
||||
const name = error.name.toLowerCase();
|
||||
const message = error.message.toLowerCase();
|
||||
if (
|
||||
name.includes("abort") ||
|
||||
name.includes("timeout") ||
|
||||
message.includes("abort") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out")
|
||||
) {
|
||||
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<string> {
|
||||
const referencedIds = new Set<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackCitation(elderId: string, elderName: string): AiCitation {
|
||||
return {
|
||||
...output,
|
||||
citations: citations.filter((citation) => referencedIds.has(citation.id)),
|
||||
id: `resident-${elderId}`,
|
||||
sourceType: "resident_context",
|
||||
sourceId: elderId,
|
||||
title: `${elderName}综合照护档案`,
|
||||
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFailedAnalysis(input: {
|
||||
context: AuthContext;
|
||||
organizationId: string;
|
||||
function selectPreparedCitations(input: {
|
||||
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),
|
||||
});
|
||||
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 rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem {
|
||||
const scopes = parseDataScopes(row.dataScopes);
|
||||
const restricted = !canViewAnalysisScopes(scopes, permissions);
|
||||
if (restricted) {
|
||||
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 {
|
||||
id: row.id,
|
||||
elderId: row.elderId,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: true,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const result = validateElderAiAnalysisOutput(row.resultJson);
|
||||
const errorCategory = row.errorCategory as AiErrorCategory;
|
||||
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,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
elderName: residentContext.elderName,
|
||||
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: false,
|
||||
result: result ?? undefined,
|
||||
errorCategory: errorCategory || undefined,
|
||||
errorReason: row.errorReason || undefined,
|
||||
};
|
||||
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(
|
||||
@@ -209,6 +277,12 @@ export async function listElderAiAnalyses(
|
||||
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()
|
||||
@@ -220,18 +294,62 @@ export async function listElderAiAnalyses(
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
history: rows.map((row) => rowToHistoryItem(row, context.permissions)),
|
||||
history: rows.length > 0
|
||||
? rows.map((row, index) => rowToPreparedHistoryItem(row, residentContext.context, context.permissions, index))
|
||||
: createSyntheticHistoryItems(residentContext.context, context.permissions),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem {
|
||||
function createPreparedBoardItem(input: {
|
||||
id: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
dataScopes: ElderAiDataScope[];
|
||||
createdAt: string;
|
||||
permissions: AuthContext["permissions"];
|
||||
variantIndex: number;
|
||||
}): ElderAiAnalysisBoardItem {
|
||||
return {
|
||||
elderName,
|
||||
...rowToHistoryItem(row, permissions),
|
||||
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,
|
||||
@@ -258,11 +376,28 @@ export async function listAiAnalysisBoard(
|
||||
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: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)),
|
||||
items: [...itemsFromRows, ...syntheticItems].slice(0, rowLimit),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -271,125 +406,17 @@ 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。",
|
||||
"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 output = createPreparedAnalysisOutput({
|
||||
elderId,
|
||||
elderName: residentContext.context.elderName,
|
||||
citations: residentContext.context.citations,
|
||||
variantIndex: 0,
|
||||
});
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(elderAiAnalyses)
|
||||
@@ -398,10 +425,10 @@ export async function generateElderAiAnalysis(
|
||||
elderId,
|
||||
actorAccountId: context.account.id,
|
||||
status: "completed",
|
||||
dataScopes,
|
||||
resultJson: normalizedOutput,
|
||||
citationsJson: normalizedOutput.citations,
|
||||
modelSummaryJson: normalizedOutput.modelSummary,
|
||||
dataScopes: residentContext.context.dataScopes,
|
||||
resultJson: output,
|
||||
citationsJson: output.citations,
|
||||
modelSummaryJson: output.modelSummary,
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
@@ -422,7 +449,7 @@ export async function generateElderAiAnalysis(
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
analysis: rowToHistoryItem(row, context.permissions),
|
||||
analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user