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

569 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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 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";
vi.mock("server-only", () => ({}));
vi.mock("@/modules/ai/server/elder-context", () => ({
buildElderAiContext: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
}));
type AnalysisStatus = "completed" | "failed";
type AnalysisRow = {
id: string;
organizationId: string;
elderId: string;
actorAccountId: string | null;
status: AnalysisStatus;
dataScopes: string[];
resultJson: unknown;
citationsJson: unknown;
modelSummaryJson: unknown;
errorCategory: string;
errorReason: string;
createdAt: Date;
updatedAt: Date;
};
type ElderNameRow = {
id: string;
name: string;
};
type AnalysisInsertPayload = {
organizationId?: unknown;
elderId?: unknown;
actorAccountId?: unknown;
status?: unknown;
dataScopes?: unknown;
resultJson?: unknown;
citationsJson?: unknown;
modelSummaryJson?: unknown;
errorCategory?: unknown;
errorReason?: unknown;
};
type AnalysisDatabaseDouble = {
insertedValues: AnalysisInsertPayload[];
insert: ReturnlessFunction;
select: ReturnlessFunction;
};
type ReturnlessFunction = (...args: unknown[]) => unknown;
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",
name: "Nurse Admin",
email: "admin@example.com",
avatarUrl: "",
role: "org_admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const organization: NonNullable<AuthContext["organization"]> = {
id: "org-1",
name: "TeaCare Home",
slug: "teacare",
status: "active",
registrationEnabled: true,
oidcEnabled: false,
oidcIssuerUrl: "",
oidcClientId: "",
oidcHasClientSecret: false,
oidcScopes: "",
oidcRedirectUri: "",
oidcAvatarClaim: "",
oidcAutoProvision: false,
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const residentCitation: AiCitation = {
id: "resident-1",
sourceType: "resident_context",
sourceId: "elder-1",
title: "Resident risk notes",
excerpt: "Night wandering and prior fall history.",
};
const carePlanCitation: AiCitation = {
id: "resident-2",
sourceType: "resident_context",
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;
function createAuthContext(permissions: Permission[] = ["ai:read", "elder:read", "health:read", "knowledge:read"]): AuthContext {
return {
account,
organization: organizationContext,
organizations: [
{
id: organizationContext.id,
name: organizationContext.name,
slug: organizationContext.slug,
status: organizationContext.status,
registrationEnabled: organizationContext.registrationEnabled,
oidcEnabled: organizationContext.oidcEnabled,
roleLabel: "Admin",
isActive: true,
},
],
membership: {
id: "membership-1",
accountId: account.id,
organizationId: organizationContext.id,
roleId: "role-1",
roleKey: "org_admin",
roleLabel: "Admin",
status: "active",
createdAt: "2026-07-02T00:00:00.000Z",
updatedAt: "2026-07-02T00:00:00.000Z",
},
permissions,
session: {
id: "session-1",
accountId: account.id,
activeOrganizationId: organizationContext.id,
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
};
}
function createResidentContext(citations: AiCitation[] = [residentCitation]): ElderAiResidentContext {
return {
organizationId: "org-1",
elderId: "elder-1",
elderName: "王阿姨",
dataScopes: ["elder", "health"],
sections: ["Resident profile", "Health profile"],
citations,
promptContext: "Resident profile includes night wandering and prior fall history.",
};
}
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") : [];
return {
id: `analysis-${index + 1}`,
organizationId: typeof values.organizationId === "string" ? values.organizationId : "org-1",
elderId: typeof values.elderId === "string" ? values.elderId : "elder-1",
actorAccountId: typeof values.actorAccountId === "string" ? values.actorAccountId : null,
status,
dataScopes,
resultJson: values.resultJson ?? null,
citationsJson: values.citationsJson ?? [],
modelSummaryJson: values.modelSummaryJson ?? {},
errorCategory: typeof values.errorCategory === "string" ? values.errorCategory : "",
errorReason: typeof values.errorReason === "string" ? values.errorReason : "",
createdAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
updatedAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
};
}
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 - 1)]),
};
}),
}));
const select = vi.fn((selection?: unknown) => {
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
return {
from: vi.fn(() => ({
where: vi.fn(() => {
if (selectsElderNames) {
return elderRows;
}
return {
orderBy: vi.fn(() => ({
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
})),
};
}),
})),
};
});
return { insertedValues, insert, select };
}
function useDatabase(database: AnalysisDatabaseDouble): void {
vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase);
}
function useSuccessfulContext(citations?: AiCitation[]): void {
vi.mocked(buildElderAiContext).mockResolvedValue({
success: true,
context: createResidentContext(citations),
});
}
function fallbackCitationFor(elderId: string, elderName: string): AiCitation {
return {
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
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();
useSuccessfulContext();
useDatabase(createDatabaseDouble());
});
afterEach(() => {
vi.useRealTimers();
});
describe("elder AI analysis service", () => {
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);
if (result.success !== true) {
return;
}
expect(database.insertedValues).toEqual([
expect.objectContaining({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
citationsJson: [residentCitation, carePlanCitation],
modelSummaryJson: preparedModelSummary,
}),
]);
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,
}));
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("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"],
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(createDatabaseDouble([restrictedRow]));
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
expect(result).toEqual({
success: true,
data: {
history: [
{
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: true,
},
],
},
});
});
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"],
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 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: "failed",
dataScopes: ["elder", "health"],
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 2);
useDatabase(createDatabaseDouble([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items).toEqual([
{
id: "analysis-3",
elderId: "elder-3",
elderName: "周玉珍",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T02:00:00.000Z",
restricted: true,
},
]);
expect(result.data.items[0]).not.toHaveProperty("result");
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
expect(result.data.items[0]).not.toHaveProperty("errorReason");
});
it("returns board rows in newest-first order up to the requested limit", async () => {
const newestRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-4",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
}, 3);
const olderRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder"],
}, 0);
const middleRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
}, 1);
useDatabase(createDatabaseDouble(
[olderRow, newestRow, middleRow],
[
{ id: "elder-1", name: "王阿姨" },
{ id: "elder-2", name: "李建国" },
{ id: "elder-4", name: "陈桂兰" },
],
));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([
{ id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" },
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
]);
});
});