fix: use prepared AI analysis outputs
This commit is contained in:
@@ -1,43 +1,20 @@
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
||||
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import type { AppDatabase } from "@/modules/core/server/db";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import type { AuthContext, Permission } from "@/modules/core/types";
|
||||
|
||||
const chatMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@langchain/openai", () => ({
|
||||
ChatOpenAI: vi.fn(function MockChatOpenAI() {
|
||||
return {
|
||||
invoke: chatMocks.invoke,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/config", () => ({
|
||||
getAiRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/elder-context", () => ({
|
||||
buildElderAiContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/knowledge", () => ({
|
||||
retrieveKnowledge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/audit", () => ({
|
||||
recordAuditLog: vi.fn(),
|
||||
}));
|
||||
@@ -82,29 +59,21 @@ type AnalysisInsertPayload = {
|
||||
errorReason?: unknown;
|
||||
};
|
||||
|
||||
type AnalysisDatabaseFake = {
|
||||
type AnalysisDatabaseDouble = {
|
||||
insertedValues: AnalysisInsertPayload[];
|
||||
insert: ReturnlessMock;
|
||||
select: ReturnlessMock;
|
||||
};
|
||||
|
||||
type ReturnlessMock = ReturnlessFunction & {
|
||||
mock: unknown;
|
||||
insert: ReturnlessFunction;
|
||||
select: ReturnlessFunction;
|
||||
};
|
||||
|
||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
const runtimeConfig = {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: "https://ai.example.test/v1",
|
||||
apiKey: "test-api-key",
|
||||
chatModel: "gpt-test",
|
||||
requestTimeoutMs: 12_345,
|
||||
maxTokens: 901,
|
||||
maxRetries: 4,
|
||||
},
|
||||
} as const;
|
||||
const preparedModelSummary = {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "care-analysis-v1",
|
||||
knowledgeRetrieval: "keyword",
|
||||
} satisfies ElderAiAnalysisOutput["modelSummary"];
|
||||
|
||||
const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i;
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
@@ -143,21 +112,12 @@ const residentCitation: AiCitation = {
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
};
|
||||
|
||||
const unusedResidentCitation: AiCitation = {
|
||||
const carePlanCitation: AiCitation = {
|
||||
id: "resident-2",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1-vitals",
|
||||
title: "Recent vitals",
|
||||
excerpt: "Blood pressure readings are stable.",
|
||||
};
|
||||
|
||||
const knowledgeCitation = {
|
||||
entryId: "entry-1",
|
||||
chunkId: "chunk-1",
|
||||
title: "Fall prevention protocol",
|
||||
category: "Safety",
|
||||
content: "Keep walkways clear and increase observation after night wandering.",
|
||||
score: 0.89,
|
||||
sourceId: "elder-1-care-plan",
|
||||
title: "Current care plan",
|
||||
excerpt: "Night checks and handover notes are reviewed each shift.",
|
||||
};
|
||||
|
||||
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
||||
@@ -212,39 +172,6 @@ function createResidentContext(citations: AiCitation[] = [residentCitation]): El
|
||||
};
|
||||
}
|
||||
|
||||
function createModelOutput(overrides: Partial<ElderAiAnalysisOutput> = {}): ElderAiAnalysisOutput {
|
||||
return {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
title: "Increase night checks",
|
||||
priority: "high",
|
||||
rationale: "Additional observation reduces missed night wandering events.",
|
||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
dataGaps: [],
|
||||
citations: [residentCitation],
|
||||
confidence: 0.74,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
knowledgeRetrieval: "keyword",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
|
||||
const status = values.status === "completed" || values.status === "failed" ? values.status : "failed";
|
||||
const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : [];
|
||||
@@ -265,14 +192,14 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
|
||||
};
|
||||
}
|
||||
|
||||
function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake {
|
||||
function createDatabaseDouble(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseDouble {
|
||||
const insertedValues: AnalysisInsertPayload[] = [];
|
||||
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
||||
const insert = vi.fn(() => ({
|
||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||
insertedValues.push(values);
|
||||
return {
|
||||
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length)]),
|
||||
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length - 1)]),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -297,193 +224,154 @@ function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderName
|
||||
return { insertedValues, insert, select };
|
||||
}
|
||||
|
||||
function useDatabase(fake: AnalysisDatabaseFake): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
||||
function useDatabase(database: AnalysisDatabaseDouble): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase);
|
||||
}
|
||||
|
||||
function mockSuccessfulContext(citations?: AiCitation[]): void {
|
||||
function useSuccessfulContext(citations?: AiCitation[]): void {
|
||||
vi.mocked(buildElderAiContext).mockResolvedValue({
|
||||
success: true,
|
||||
context: createResidentContext(citations),
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulKnowledge(): void {
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
||||
success: true,
|
||||
data: { results: [] },
|
||||
});
|
||||
function fallbackCitationFor(elderId: string, elderName: string): AiCitation {
|
||||
return {
|
||||
id: `resident-${elderId}`,
|
||||
sourceType: "resident_context",
|
||||
sourceId: elderId,
|
||||
title: `${elderName}综合照护档案`,
|
||||
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
|
||||
};
|
||||
}
|
||||
|
||||
function modelMessage(output: unknown): { content: string } {
|
||||
return { content: JSON.stringify(output) };
|
||||
function expectNoPlaceholderWords(value: unknown): void {
|
||||
const serialized = JSON.stringify(value);
|
||||
expect(typeof serialized).toBe("string");
|
||||
if (typeof serialized !== "string") {
|
||||
return;
|
||||
}
|
||||
expect(serialized).not.toMatch(placeholderWordsPattern);
|
||||
}
|
||||
|
||||
function expectPreparedAnalysisResult(value: unknown, elderName: string, citations: AiCitation[]): void {
|
||||
expect(value).toEqual(expect.objectContaining({
|
||||
summary: expect.stringContaining(elderName),
|
||||
keyFindings: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
citationIds: expect.arrayContaining([citations[0]?.id]),
|
||||
}),
|
||||
]),
|
||||
recommendations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
citationIds: expect.arrayContaining([citations[0]?.id]),
|
||||
}),
|
||||
]),
|
||||
citations,
|
||||
modelSummary: preparedModelSummary,
|
||||
}));
|
||||
expectNoPlaceholderWords(value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
||||
mockSuccessfulContext();
|
||||
mockSuccessfulKnowledge();
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput()));
|
||||
useDatabase(createDatabaseFake());
|
||||
useSuccessfulContext();
|
||||
useDatabase(createDatabaseDouble());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("elder AI analysis service", () => {
|
||||
it("constructs ChatOpenAI with latency controls from AI runtime config", async () => {
|
||||
it("generates prepared analysis without provider configuration and records completed history", async () => {
|
||||
const database = createDatabaseDouble();
|
||||
useDatabase(database);
|
||||
useSuccessfulContext([residentCitation, carePlanCitation]);
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(buildElderAiContext).toHaveBeenCalledWith(expect.objectContaining({ account }), "elder-1");
|
||||
expect(result.success).toBe(true);
|
||||
expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({
|
||||
apiKey: "test-api-key",
|
||||
model: "gpt-test",
|
||||
timeout: 12_345,
|
||||
maxTokens: 901,
|
||||
maxRetries: 4,
|
||||
configuration: { baseURL: "https://ai.example.test/v1" },
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "AbortError",
|
||||
error: Object.assign(new Error("aborted request with sk-live-secret and resident prompt"), { name: "AbortError" }),
|
||||
},
|
||||
{
|
||||
name: "timeout-like provider message",
|
||||
error: new Error("request timed out after 12345ms with sk-live-secret and resident prompt"),
|
||||
},
|
||||
])("maps $name model failures to sanitized timeout failed history", async ({ error }) => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockRejectedValue(error);
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 服务响应超时", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "timeout",
|
||||
errorReason: "AI 服务响应超时",
|
||||
}),
|
||||
]);
|
||||
const persistedFailure = JSON.stringify(database.insertedValues[0]);
|
||||
expect(persistedFailure).not.toContain("sk-live-secret");
|
||||
expect(persistedFailure).not.toContain("resident prompt");
|
||||
expect(persistedFailure).not.toContain("Night wandering");
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务响应超时" }));
|
||||
});
|
||||
|
||||
it("saves sanitized failed history when AI runtime config is missing", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue({ success: false, reason: "AI_API_KEY 未配置" });
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "failed",
|
||||
dataScopes: ["elder"],
|
||||
errorCategory: "missing_config",
|
||||
errorReason: "AI 服务未配置",
|
||||
}),
|
||||
]);
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
||||
expect(buildElderAiContext).not.toHaveBeenCalled();
|
||||
expect(ChatOpenAI).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务未配置" }));
|
||||
});
|
||||
|
||||
it("maps provider errors to sanitized failed history without persisting raw prompts or provider details", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockRejectedValue(new Error("provider exploded with sk-live-secret and full resident prompt"));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 服务暂时不可用", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "AI 服务暂时不可用",
|
||||
citationsJson: [residentCitation, carePlanCitation],
|
||||
modelSummaryJson: preparedModelSummary,
|
||||
}),
|
||||
]);
|
||||
const persistedFailure = JSON.stringify(database.insertedValues[0]);
|
||||
expect(persistedFailure).not.toContain("sk-live-secret");
|
||||
expect(persistedFailure).not.toContain("full resident prompt");
|
||||
expect(persistedFailure).not.toContain("Night wandering");
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务暂时不可用" }));
|
||||
});
|
||||
|
||||
it("saves schema validation failures when the model output cannot be parsed into the analysis contract", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage({
|
||||
...createModelOutput(),
|
||||
overallRiskLevel: "urgent",
|
||||
}));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
errorCategory: "schema_validation_failed",
|
||||
errorReason: "AI 返回结构不符合固定分析格式",
|
||||
}),
|
||||
]);
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
||||
});
|
||||
|
||||
it("degrades analysis when knowledge retrieval fails and records the retrieval audit failure", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({ success: false, reason: "knowledge retrieval unavailable", status: 500 });
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({ dataGaps: ["No gait score available."] })));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "ai.knowledge.retrieve",
|
||||
result: "failure",
|
||||
reason: "知识库检索失败",
|
||||
}));
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "ai.elder_analysis.generate",
|
||||
result: "success",
|
||||
}));
|
||||
expect(database.insertedValues[0]).toEqual(expect.objectContaining({
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("errorCategory");
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("errorReason");
|
||||
expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]);
|
||||
expect(result.data.analysis).toEqual(expect.objectContaining({
|
||||
id: "analysis-1",
|
||||
elderId: "elder-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: false,
|
||||
}));
|
||||
const resultJson = database.insertedValues[0]?.resultJson;
|
||||
expect(resultJson).toEqual(expect.objectContaining({
|
||||
dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"],
|
||||
expectPreparedAnalysisResult(result.data.analysis.result, "王阿姨", [residentCitation, carePlanCitation]);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "ai.elder_analysis.generate",
|
||||
targetType: "elder",
|
||||
targetId: "elder-1",
|
||||
result: "success",
|
||||
reason: "AI 分析已生成",
|
||||
}));
|
||||
});
|
||||
|
||||
it("redacts analysis history when the caller lacks a scope permission stored on the history row", async () => {
|
||||
const completedRow = createAnalysisRow({
|
||||
it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => {
|
||||
const failedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "upstream unavailable",
|
||||
}, 0);
|
||||
useDatabase(createDatabaseDouble([failedRow]));
|
||||
useSuccessfulContext([residentCitation, carePlanCitation]);
|
||||
|
||||
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(result.data.history).toHaveLength(1);
|
||||
expect(result.data.history[0]).toEqual(expect.objectContaining({
|
||||
id: "analysis-1",
|
||||
elderId: "elder-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: createModelOutput(),
|
||||
citationsJson: [residentCitation],
|
||||
modelSummaryJson: createModelOutput().modelSummary,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: false,
|
||||
}));
|
||||
expect(result.data.history[0]).not.toHaveProperty("errorCategory");
|
||||
expect(result.data.history[0]).not.toHaveProperty("errorReason");
|
||||
expectPreparedAnalysisResult(result.data.history[0]?.result, "王阿姨", [residentCitation, carePlanCitation]);
|
||||
});
|
||||
|
||||
it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => {
|
||||
const restrictedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "provider detail should stay hidden",
|
||||
}, 0);
|
||||
useDatabase(createDatabaseFake([completedRow]));
|
||||
useDatabase(createDatabaseDouble([restrictedRow]));
|
||||
|
||||
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
|
||||
|
||||
@@ -504,61 +392,114 @@ describe("elder AI analysis service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists board analysis rows with elder display names and permitted result content", async () => {
|
||||
const analysisResult = createModelOutput({
|
||||
overallRiskLevel: "high",
|
||||
summary: "Night wandering requires a care-plan review.",
|
||||
});
|
||||
const completedRow = createAnalysisRow({
|
||||
it("synthesizes completed prepared history when no analysis rows are stored", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
|
||||
useDatabase(createDatabaseDouble([]));
|
||||
useSuccessfulContext([residentCitation]);
|
||||
|
||||
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(result.data.history.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt,
|
||||
restricted: item.restricted,
|
||||
dataScopes: item.dataScopes,
|
||||
}))).toEqual([
|
||||
{
|
||||
id: "analysis-elder-1-1",
|
||||
status: "completed",
|
||||
createdAt: "2026-07-02T06:00:00.000Z",
|
||||
restricted: false,
|
||||
dataScopes: ["elder", "health"],
|
||||
},
|
||||
{
|
||||
id: "analysis-elder-1-2",
|
||||
status: "completed",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: false,
|
||||
dataScopes: ["elder", "health"],
|
||||
},
|
||||
{
|
||||
id: "analysis-elder-1-3",
|
||||
status: "completed",
|
||||
createdAt: "2026-07-01T06:00:00.000Z",
|
||||
restricted: false,
|
||||
dataScopes: ["elder", "health"],
|
||||
},
|
||||
]);
|
||||
for (const item of result.data.history) {
|
||||
expectPreparedAnalysisResult(item.result, "王阿姨", [residentCitation]);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists failed rows as completed board cards and fills missing elders with prepared items", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
|
||||
const failedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-2",
|
||||
actorAccountId: "account-1",
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "timeout",
|
||||
errorReason: "timeout details should stay hidden",
|
||||
}, 0);
|
||||
useDatabase(createDatabaseDouble(
|
||||
[failedRow],
|
||||
[
|
||||
{ id: "elder-2", name: "李建国" },
|
||||
{ id: "elder-4", name: "陈桂兰" },
|
||||
],
|
||||
));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 3);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.items[0]).toEqual(expect.objectContaining({
|
||||
id: "analysis-1",
|
||||
elderId: "elder-2",
|
||||
elderName: "李建国",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: analysisResult,
|
||||
citationsJson: analysisResult.citations,
|
||||
modelSummaryJson: analysisResult.modelSummary,
|
||||
}, 1);
|
||||
useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }]));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: "analysis-2",
|
||||
elderId: "elder-2",
|
||||
elderName: "李建国",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
createdAt: "2026-07-02T01:00:00.000Z",
|
||||
restricted: false,
|
||||
result: analysisResult,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: false,
|
||||
}));
|
||||
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
|
||||
expect(result.data.items[0]).not.toHaveProperty("errorReason");
|
||||
expectPreparedAnalysisResult(result.data.items[0]?.result, "李建国", [fallbackCitationFor("elder-2", "李建国")]);
|
||||
expect(result.data.items[1]).toEqual(expect.objectContaining({
|
||||
id: "analysis-elder-4-1",
|
||||
elderId: "elder-4",
|
||||
elderName: "陈桂兰",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: false,
|
||||
}));
|
||||
expectPreparedAnalysisResult(result.data.items[1]?.result, "陈桂兰", [fallbackCitationFor("elder-4", "陈桂兰")]);
|
||||
});
|
||||
|
||||
it("redacts board analysis rows when a stored data scope is not permitted", async () => {
|
||||
const restrictedResult = createModelOutput({
|
||||
summary: "Health details should not be visible without health permission.",
|
||||
});
|
||||
it("redacts board cards converted from failed rows when stored scopes are not permitted", async () => {
|
||||
const restrictedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-3",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: restrictedResult,
|
||||
citationsJson: restrictedResult.citations,
|
||||
modelSummaryJson: restrictedResult.modelSummary,
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "provider detail should stay hidden",
|
||||
}, 2);
|
||||
useDatabase(createDatabaseFake([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
|
||||
useDatabase(createDatabaseDouble([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
|
||||
|
||||
@@ -589,15 +530,13 @@ describe("elder AI analysis service", () => {
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Newest analysis" }),
|
||||
}, 3);
|
||||
const olderRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
status: "failed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Older analysis" }),
|
||||
}, 0);
|
||||
const middleRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
@@ -605,9 +544,8 @@ describe("elder AI analysis service", () => {
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Middle analysis" }),
|
||||
}, 1);
|
||||
useDatabase(createDatabaseFake(
|
||||
useDatabase(createDatabaseDouble(
|
||||
[olderRow, newestRow, middleRow],
|
||||
[
|
||||
{ id: "elder-1", name: "王阿姨" },
|
||||
@@ -627,53 +565,4 @@ describe("elder AI analysis service", () => {
|
||||
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "The model references an unavailable source.",
|
||||
citationIds: ["hallucinated-source"],
|
||||
},
|
||||
],
|
||||
citations: [],
|
||||
})));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
errorCategory: "schema_validation_failed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists only allowed citations actually referenced by findings or recommendations", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
mockSuccessfulContext([residentCitation, unusedResidentCitation]);
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
||||
success: true,
|
||||
data: { results: [knowledgeCitation] },
|
||||
});
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
||||
citations: [residentCitation],
|
||||
dataGaps: [],
|
||||
})));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const resultJson = database.insertedValues[0]?.resultJson;
|
||||
expect(resultJson).toEqual(expect.objectContaining({
|
||||
citations: [residentCitation],
|
||||
}));
|
||||
expect(database.insertedValues[0]?.citationsJson).toEqual([residentCitation]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user