test: harden AI analysis path
This commit is contained in:
@@ -181,12 +181,18 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
||||
</Card>
|
||||
) : latest ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最新记录</CardTitle>
|
||||
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>最新记录</CardTitle>
|
||||
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latest.status === "failed" ? "destructive" : "secondary"}>{latest.status === "failed" ? "生成失败" : "受限"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}
|
||||
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
||||
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
||||
{latest.status === "failed" ? <p>失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -66,6 +66,13 @@ function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
|
||||
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
|
||||
}
|
||||
|
||||
function formatKnowledgeFailureReason(reason: string): string {
|
||||
if (reason.includes("AI_API_KEY") || reason.includes("向量生成失败")) {
|
||||
return `保存知识需要可用的 AI 向量配置:${reason}`;
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
|
||||
const [entries, setEntries] = useState(initialEntries);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -135,7 +142,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
setIsPending(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
setMessage(formatKnowledgeFailureReason(result.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +180,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
setMessage(formatKnowledgeFailureReason(result.reason));
|
||||
return;
|
||||
}
|
||||
await refreshEntries();
|
||||
|
||||
495
modules/ai/server/analysis.test.ts
Normal file
495
modules/ai/server/analysis.test.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
||||
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, 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(),
|
||||
}));
|
||||
|
||||
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 AnalysisInsertPayload = {
|
||||
organizationId?: unknown;
|
||||
elderId?: unknown;
|
||||
actorAccountId?: unknown;
|
||||
status?: unknown;
|
||||
dataScopes?: unknown;
|
||||
resultJson?: unknown;
|
||||
citationsJson?: unknown;
|
||||
modelSummaryJson?: unknown;
|
||||
errorCategory?: unknown;
|
||||
errorReason?: unknown;
|
||||
};
|
||||
|
||||
type AnalysisDatabaseFake = {
|
||||
insertedValues: AnalysisInsertPayload[];
|
||||
insert: ReturnlessMock;
|
||||
select: ReturnlessMock;
|
||||
};
|
||||
|
||||
type ReturnlessMock = ReturnlessFunction & {
|
||||
mock: unknown;
|
||||
};
|
||||
|
||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
const runtimeConfig: AiRuntimeConfigResult = {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: "https://ai.example.test/v1",
|
||||
apiKey: "test-api-key",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
embeddingDimensions: 1536,
|
||||
},
|
||||
};
|
||||
|
||||
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 unusedResidentCitation: 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,
|
||||
};
|
||||
|
||||
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 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",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
...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") : [];
|
||||
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 createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
|
||||
const insertedValues: AnalysisInsertPayload[] = [];
|
||||
const insert = vi.fn(() => ({
|
||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||
insertedValues.push(values);
|
||||
return {
|
||||
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length)]),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(async () => selectRows),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { insertedValues, insert, select };
|
||||
}
|
||||
|
||||
function useDatabase(fake: AnalysisDatabaseFake): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
||||
}
|
||||
|
||||
function mockSuccessfulContext(citations?: AiCitation[]): void {
|
||||
vi.mocked(buildElderAiContext).mockResolvedValue({
|
||||
success: true,
|
||||
context: createResidentContext(citations),
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulKnowledge(): void {
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
||||
success: true,
|
||||
data: { results: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function modelMessage(output: unknown): { content: string } {
|
||||
return { content: JSON.stringify(output) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
||||
mockSuccessfulContext();
|
||||
mockSuccessfulKnowledge();
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput()));
|
||||
useDatabase(createDatabaseFake());
|
||||
});
|
||||
|
||||
describe("elder AI analysis service", () => {
|
||||
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 });
|
||||
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",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "AI 服务暂时不可用",
|
||||
}),
|
||||
]);
|
||||
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: "vector store 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({
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
}));
|
||||
const resultJson = database.insertedValues[0]?.resultJson;
|
||||
expect(resultJson).toEqual(expect.objectContaining({
|
||||
dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("redacts analysis history when the caller lacks a scope permission stored on the history row", async () => {
|
||||
const completedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: createModelOutput(),
|
||||
citationsJson: [residentCitation],
|
||||
modelSummaryJson: createModelOutput().modelSummary,
|
||||
}, 0);
|
||||
useDatabase(createDatabaseFake([completedRow]));
|
||||
|
||||
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("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]);
|
||||
});
|
||||
});
|
||||
@@ -85,14 +85,39 @@ function extractJsonFromText(value: string): unknown {
|
||||
return safeJsonParse(trimmed.slice(start, end + 1));
|
||||
}
|
||||
|
||||
function normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput {
|
||||
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]));
|
||||
const merged = output.citations.filter((citation) => allowed.has(citation.id));
|
||||
const seenIds = new Set(merged.map((citation) => citation.id));
|
||||
const missing = citations.filter((citation) => !seenIds.has(citation.id));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...output,
|
||||
citations: [...merged, ...missing],
|
||||
citations: citations.filter((citation) => referencedIds.has(citation.id)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,15 +308,23 @@ export async function generateElderAiAnalysis(
|
||||
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
|
||||
}
|
||||
|
||||
const normalizedOutput = normalizeCitations(
|
||||
knowledgeUnavailableReason
|
||||
? {
|
||||
...output,
|
||||
dataGaps: [...output.dataGaps, "知识库检索不可用,分析未使用内部知识库"],
|
||||
}
|
||||
: output,
|
||||
citations,
|
||||
);
|
||||
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 database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(elderAiAnalyses)
|
||||
|
||||
454
modules/ai/server/knowledge.test.ts
Normal file
454
modules/ai/server/knowledge.test.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import {
|
||||
createKnowledgeEntry,
|
||||
listKnowledgeEntries,
|
||||
retrieveKnowledge,
|
||||
updateKnowledgeEntry,
|
||||
} from "@/modules/ai/server/knowledge";
|
||||
import type { KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import type { AppDatabase } from "@/modules/core/server/db";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import type { AuthContext, Permission } from "@/modules/core/types";
|
||||
|
||||
const embeddingMocks = vi.hoisted(() => ({
|
||||
embedDocuments: vi.fn(),
|
||||
}));
|
||||
|
||||
const drizzleDsl = vi.hoisted(() => {
|
||||
type TableName = "entries" | "chunks";
|
||||
type ColumnRef = { table: TableName; key: string };
|
||||
const entries = {
|
||||
id: { table: "entries", key: "id" } as ColumnRef,
|
||||
scope: { table: "entries", key: "scope" } as ColumnRef,
|
||||
organizationId: { table: "entries", key: "organizationId" } as ColumnRef,
|
||||
status: { table: "entries", key: "status" } as ColumnRef,
|
||||
updatedAt: { table: "entries", key: "updatedAt" } as ColumnRef,
|
||||
};
|
||||
const chunks = {
|
||||
id: { table: "chunks", key: "id" } as ColumnRef,
|
||||
entryId: { table: "chunks", key: "entryId" } as ColumnRef,
|
||||
scope: { table: "chunks", key: "scope" } as ColumnRef,
|
||||
organizationId: { table: "chunks", key: "organizationId" } as ColumnRef,
|
||||
embedding: { table: "chunks", key: "embedding" } as ColumnRef,
|
||||
sourceTitle: { table: "chunks", key: "sourceTitle" } as ColumnRef,
|
||||
sourceCategory: { table: "chunks", key: "sourceCategory" } as ColumnRef,
|
||||
content: { table: "chunks", key: "content" } as ColumnRef,
|
||||
};
|
||||
return { entries, chunks };
|
||||
});
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@langchain/openai", () => ({
|
||||
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
|
||||
return {
|
||||
embedDocuments: embeddingMocks.embedDocuments,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/config", () => ({
|
||||
getAiRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/db", () => ({
|
||||
getDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/schema", () => ({
|
||||
aiKnowledgeEntries: drizzleDsl.entries,
|
||||
aiKnowledgeChunks: drizzleDsl.chunks,
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => {
|
||||
type JoinedRow = {
|
||||
entries?: Record<string, unknown>;
|
||||
chunks?: Record<string, unknown>;
|
||||
};
|
||||
type ColumnRef = { table: "entries" | "chunks"; key: string };
|
||||
type Predicate = (row: JoinedRow) => boolean;
|
||||
|
||||
function readColumn(row: JoinedRow, column: ColumnRef): unknown {
|
||||
return row[column.table]?.[column.key];
|
||||
}
|
||||
|
||||
return {
|
||||
eq: (column: ColumnRef, expected: unknown): Predicate => (row) => readColumn(row, column) === expected,
|
||||
isNull: (column: ColumnRef): Predicate => (row) => readColumn(row, column) === null || readColumn(row, column) === undefined,
|
||||
and: (...predicates: Predicate[]): Predicate => (row) => predicates.every((predicate) => predicate(row)),
|
||||
or: (...predicates: Predicate[]): Predicate => (row) => predicates.some((predicate) => predicate(row)),
|
||||
desc: (column: ColumnRef) => ({ type: "desc", column }),
|
||||
sql: () => ({ type: "distance" }),
|
||||
};
|
||||
});
|
||||
|
||||
type KnowledgeScope = "platform" | "organization";
|
||||
type KnowledgeStatus = "enabled" | "disabled";
|
||||
|
||||
type KnowledgeRow = {
|
||||
id: string;
|
||||
scope: KnowledgeScope;
|
||||
organizationId: string | null;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: KnowledgeStatus;
|
||||
createdByAccountId: string | null;
|
||||
updatedByAccountId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type KnowledgeChunkRow = {
|
||||
id: string;
|
||||
entryId: string;
|
||||
organizationId: string | null;
|
||||
scope: KnowledgeScope;
|
||||
chunkIndex: number;
|
||||
content: string;
|
||||
embedding: number[];
|
||||
sourceTitle: string;
|
||||
sourceCategory: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type JoinedRow = {
|
||||
entries?: KnowledgeRow;
|
||||
chunks?: KnowledgeChunkRow;
|
||||
};
|
||||
|
||||
type Predicate = (row: JoinedRow) => boolean;
|
||||
type SelectRow = KnowledgeRow | RetrievedChunkRow;
|
||||
|
||||
type RetrievedChunkRow = {
|
||||
entryId: string;
|
||||
chunkId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
};
|
||||
|
||||
type KnowledgeDatabaseFake = {
|
||||
entries: KnowledgeRow[];
|
||||
chunks: KnowledgeChunkRow[];
|
||||
transaction: ReturnlessFunction;
|
||||
select: ReturnlessFunction;
|
||||
};
|
||||
|
||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
const runtimeConfig: AiRuntimeConfigResult = {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: "",
|
||||
apiKey: "test-api-key",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
embeddingDimensions: 1536,
|
||||
},
|
||||
};
|
||||
|
||||
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 account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "Knowledge Admin",
|
||||
email: "knowledge@example.com",
|
||||
avatarUrl: "",
|
||||
role: "org_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const baseInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Keep walkways clear and increase night rounds after wandering events.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
||||
|
||||
function createAuthContext(input: { permissions?: Permission[]; activeOrganization?: boolean } = {}): AuthContext {
|
||||
const activeOrganization = input.activeOrganization ?? true;
|
||||
const permissions = input.permissions ?? ["knowledge:read", "knowledge:manage"];
|
||||
return {
|
||||
account,
|
||||
organization: activeOrganization ? organizationContext : undefined,
|
||||
organizations: [
|
||||
{
|
||||
id: organizationContext.id,
|
||||
name: organizationContext.name,
|
||||
slug: organizationContext.slug,
|
||||
status: organizationContext.status,
|
||||
registrationEnabled: organizationContext.registrationEnabled,
|
||||
oidcEnabled: organizationContext.oidcEnabled,
|
||||
roleLabel: "Admin",
|
||||
isActive: activeOrganization,
|
||||
},
|
||||
],
|
||||
membership: activeOrganization
|
||||
? {
|
||||
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",
|
||||
}
|
||||
: undefined,
|
||||
permissions,
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: account.id,
|
||||
activeOrganizationId: activeOrganization ? organizationContext.id : undefined,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEntry(overrides: Partial<KnowledgeRow> = {}): KnowledgeRow {
|
||||
return {
|
||||
id: "entry-org-1",
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Org fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall",
|
||||
body: "Organization-specific fall prevention guidance.",
|
||||
status: "enabled",
|
||||
createdByAccountId: "account-1",
|
||||
updatedByAccountId: "account-1",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow> = {}): KnowledgeChunkRow {
|
||||
return {
|
||||
id: `${entry.id}-chunk-1`,
|
||||
entryId: entry.id,
|
||||
organizationId: entry.organizationId,
|
||||
scope: entry.scope,
|
||||
chunkIndex: 0,
|
||||
content: entry.body,
|
||||
embedding: [0.1, 0.2, 0.3],
|
||||
sourceTitle: entry.title,
|
||||
sourceCategory: entry.category,
|
||||
createdAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class SelectBuilder implements PromiseLike<SelectRow[]> {
|
||||
private table: "entries" | "chunks" = "entries";
|
||||
private predicate: Predicate = () => true;
|
||||
|
||||
constructor(
|
||||
private readonly entries: KnowledgeRow[],
|
||||
private readonly chunks: KnowledgeChunkRow[],
|
||||
) {}
|
||||
|
||||
from(table: unknown): SelectBuilder {
|
||||
this.table = table === drizzleDsl.chunks ? "chunks" : "entries";
|
||||
return this;
|
||||
}
|
||||
|
||||
innerJoin(): SelectBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
where(predicate: Predicate): SelectBuilder {
|
||||
this.predicate = predicate;
|
||||
return this;
|
||||
}
|
||||
|
||||
orderBy(): SelectBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(count: number): Promise<SelectRow[]> {
|
||||
return Promise.resolve(this.execute().slice(0, count));
|
||||
}
|
||||
|
||||
then<TResult1 = SelectRow[], TResult2 = never>(
|
||||
onfulfilled?: ((value: SelectRow[]) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return Promise.resolve(this.execute()).then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
private execute(): SelectRow[] {
|
||||
if (this.table === "entries") {
|
||||
return this.entries
|
||||
.filter((entry) => this.predicate({ entries: entry }))
|
||||
.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
|
||||
}
|
||||
|
||||
const rows: RetrievedChunkRow[] = [];
|
||||
for (const chunk of this.chunks) {
|
||||
const entry = this.entries.find((candidate) => candidate.id === chunk.entryId);
|
||||
if (entry && this.predicate({ entries: entry, chunks: chunk })) {
|
||||
rows.push({
|
||||
entryId: chunk.entryId,
|
||||
chunkId: chunk.id,
|
||||
title: chunk.sourceTitle,
|
||||
category: chunk.sourceCategory,
|
||||
content: chunk.content,
|
||||
distance: 0.1 + rows.length * 0.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
function createDatabaseFake(input: { entries?: KnowledgeRow[]; chunks?: KnowledgeChunkRow[] } = {}): KnowledgeDatabaseFake {
|
||||
const entries = input.entries ?? [];
|
||||
const chunks = input.chunks ?? [];
|
||||
return {
|
||||
entries,
|
||||
chunks,
|
||||
transaction: vi.fn(),
|
||||
select: vi.fn(() => new SelectBuilder(entries, chunks)),
|
||||
};
|
||||
}
|
||||
|
||||
function useDatabase(fake: KnowledgeDatabaseFake): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
||||
embeddingMocks.embedDocuments.mockResolvedValue([[0.4, 0.5, 0.6]]);
|
||||
useDatabase(createDatabaseFake());
|
||||
});
|
||||
|
||||
describe("AI knowledge service", () => {
|
||||
it("lists platform and active-organization entries while excluding other organizations", async () => {
|
||||
const platformEntry = createEntry({
|
||||
id: "entry-platform",
|
||||
scope: "platform",
|
||||
organizationId: null,
|
||||
title: "Platform safety standard",
|
||||
updatedAt: new Date("2026-07-03T00:00:00.000Z"),
|
||||
});
|
||||
const orgEntry = createEntry({ id: "entry-org-1", title: "Org safety note" });
|
||||
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org note" });
|
||||
useDatabase(createDatabaseFake({ entries: [orgEntry, otherOrgEntry, platformEntry] }));
|
||||
|
||||
const entries = await listKnowledgeEntries(createAuthContext());
|
||||
|
||||
expect(entries.map((entry) => entry.id)).toEqual(["entry-platform", "entry-org-1"]);
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({ id: "entry-platform", scope: "platform", organizationId: undefined }),
|
||||
expect.objectContaining({ id: "entry-org-1", scope: "organization", organizationId: "org-1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retrieves only enabled platform and active-organization chunks", async () => {
|
||||
const platformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null, title: "Platform standard" });
|
||||
const orgEntry = createEntry({ id: "entry-org-1", title: "Org guidance" });
|
||||
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled guidance" });
|
||||
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org guidance" });
|
||||
const database = createDatabaseFake({
|
||||
entries: [platformEntry, orgEntry, disabledEntry, otherOrgEntry],
|
||||
chunks: [
|
||||
createChunk(platformEntry),
|
||||
createChunk(orgEntry),
|
||||
createChunk(disabledEntry),
|
||||
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
|
||||
],
|
||||
});
|
||||
useDatabase(database);
|
||||
|
||||
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 10 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
results: [
|
||||
expect.objectContaining({ entryId: "entry-platform", title: "Platform standard" }),
|
||||
expect.objectContaining({ entryId: "entry-org-1", title: "Org guidance" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(embeddingMocks.embedDocuments).toHaveBeenCalledWith(["night fall risk"]);
|
||||
});
|
||||
|
||||
it("requires platform management permission before mutating platform knowledge", async () => {
|
||||
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
|
||||
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));
|
||||
|
||||
const result = await updateKnowledgeEntry(
|
||||
createAuthContext({ permissions: ["knowledge:manage"] }),
|
||||
"entry-platform",
|
||||
baseInput,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an active organization before mutating organization knowledge", async () => {
|
||||
const result = await createKnowledgeEntry(
|
||||
createAuthContext({ activeOrganization: false, permissions: ["knowledge:manage"] }),
|
||||
baseInput,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects attempts to mutate another organization's knowledge", async () => {
|
||||
const result = await createKnowledgeEntry(createAuthContext(), {
|
||||
...baseInput,
|
||||
organizationId: "org-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces embedding failures without opening a write transaction", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
embeddingMocks.embedDocuments.mockRejectedValue(new Error("embedding provider unavailable"));
|
||||
|
||||
const result = await createKnowledgeEntry(createAuthContext(), baseInput);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "知识库向量生成失败", status: 500 });
|
||||
expect(database.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
168
modules/ai/types.test.ts
Normal file
168
modules/ai/types.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AiCitation, ElderAiAnalysisOutput, ElderAiFinding, ElderAiRecommendation, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import {
|
||||
parseDataScopes,
|
||||
validateElderAiAnalysisOutput,
|
||||
validateKnowledgeEntryInput,
|
||||
} from "@/modules/ai/types";
|
||||
|
||||
const validKnowledgeInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention guidance",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Residents with night wandering need bed exit checks and clear walkways.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const validFinding: ElderAiFinding = {
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
};
|
||||
|
||||
const validRecommendation: ElderAiRecommendation = {
|
||||
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"],
|
||||
};
|
||||
|
||||
const validCitation: AiCitation = {
|
||||
id: "resident-1",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1",
|
||||
title: "Resident profile",
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
};
|
||||
|
||||
const validAnalysisOutput: ElderAiAnalysisOutput = {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [validFinding],
|
||||
recommendations: [validRecommendation],
|
||||
dataGaps: ["Recent gait assessment is unavailable."],
|
||||
citations: [validCitation],
|
||||
confidence: 0.72,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
};
|
||||
|
||||
describe("AI validators", () => {
|
||||
describe("validateKnowledgeEntryInput", () => {
|
||||
it("accepts a scoped entry and trims user-authored text fields", () => {
|
||||
const result = validateKnowledgeEntryInput({
|
||||
...validKnowledgeInput,
|
||||
title: " Fall prevention guidance ",
|
||||
category: " Safety ",
|
||||
tags: " fall,night ",
|
||||
body: " Residents need clear walkways. ",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
input: {
|
||||
...validKnowledgeInput,
|
||||
title: "Fall prevention guidance",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Residents need clear walkways.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "non-object payload", value: null, reason: "请求数据格式无效" },
|
||||
{ name: "unknown scope", value: { ...validKnowledgeInput, scope: "facility" }, reason: "知识库范围无效" },
|
||||
{ name: "unknown status", value: { ...validKnowledgeInput, status: "archived" }, reason: "知识库状态无效" },
|
||||
{ name: "blank title", value: { ...validKnowledgeInput, title: " " }, reason: "知识标题不能为空" },
|
||||
{ name: "blank body", value: { ...validKnowledgeInput, body: "\n\t" }, reason: "知识内容不能为空" },
|
||||
])("rejects $name", ({ value, reason }) => {
|
||||
expect(validateKnowledgeEntryInput(value)).toEqual({ success: false, reason });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateElderAiAnalysisOutput", () => {
|
||||
it("accepts valid model output, coerces numeric confidence strings, and clamps the public score", () => {
|
||||
const result = validateElderAiAnalysisOutput({
|
||||
...validAnalysisOutput,
|
||||
confidence: "1.25",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...validAnalysisOutput,
|
||||
confidence: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "unknown risk level",
|
||||
value: { ...validAnalysisOutput, overallRiskLevel: "urgent" },
|
||||
},
|
||||
{
|
||||
name: "finding with invalid severity",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
keyFindings: [{ ...validFinding, severity: "debug" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recommendation with invalid priority",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
recommendations: [{ ...validRecommendation, priority: "soon" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed citation id array",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
keyFindings: [{ ...validFinding, citationIds: ["resident-1", 42] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid citation source",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
citations: [{ ...validCitation, sourceType: "web" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unexpected model provider",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
modelSummary: { ...validAnalysisOutput.modelSummary, provider: "anthropic" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-finite confidence",
|
||||
value: { ...validAnalysisOutput, confidence: "not-a-number" },
|
||||
},
|
||||
])("rejects $name", ({ value }) => {
|
||||
expect(validateElderAiAnalysisOutput(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataScopes", () => {
|
||||
it("keeps only recognized scope strings in caller order", () => {
|
||||
expect(parseDataScopes(["elder", "health", "unknown", 7, "knowledge", "elder"])).toEqual([
|
||||
"elder",
|
||||
"health",
|
||||
"knowledge",
|
||||
"elder",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "elder", { scopes: ["elder"] }])("returns an empty list for non-array input %#", (value) => {
|
||||
expect(parseDataScopes(value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user