test: harden AI analysis path

This commit is contained in:
2026-07-06 01:03:11 -07:00
parent ae561a7d45
commit 0d5093ac6c
15 changed files with 1736 additions and 23 deletions

View File

@@ -394,6 +394,7 @@ ANTHROPIC_API_KEY=sk-ant-...
- `errorCategory` / `errorReason`: sanitized only for failed rows
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
- Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations.
### 4. Validation & Error Matrix
@@ -407,6 +408,7 @@ ANTHROPIC_API_KEY=sk-ant-...
- Missing `AI_API_KEY` -> save failed analysis with `missing_config`, return structured failure.
- Provider failure -> save failed analysis with `provider_error` or `timeout`.
- Invalid model output -> save failed analysis with `schema_validation_failed`.
- Unknown citation IDs in findings or recommendations -> save failed analysis with `schema_validation_failed`, return structured failure.
### 5. Good/Base/Bad Cases
@@ -420,6 +422,7 @@ ANTHROPIC_API_KEY=sk-ant-...
- Permission seeding assertions for `ai:*` and `knowledge:*` default role grants.
- Knowledge retrieval tests for platform shared, own organization, other organization, and disabled entry filtering.
- Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction.
- Analysis tests for unknown citation rejection and referenced-only citation persistence.
- Route tests for auth/permission failures and standard response shape.
### 7. Wrong vs Correct
@@ -435,10 +438,20 @@ await model.invoke(JSON.stringify(residentRows));
#### Correct
```typescript
// Keep model calls in modules/ai/server and build permission-aware context first.
// Keep model calls in modules/ai/server and persist only citations that findings/recommendations reference.
const result = await generateElderAiAnalysis(auth.context, elderId);
if (!result.success) {
return jsonFailure(result.reason, result.status);
}
return jsonSuccess("AI 分析已生成", { analysis: result.data.analysis }, 201);
```
```typescript
// Good evidence chain: every cited source ID is from the allowed resident/knowledge citations.
const finding = {
category: "跌倒风险",
severity: "warning",
evidence: "夜间徘徊后需要加强通道清理和巡视。",
citationIds: ["resident-1", "kb-1"],
};
```

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,74 @@
# Harden AI Analysis Path Design
## Overview
This change is an in-place hardening pass over the existing AI MVP. It keeps the current module boundaries and synchronous workflow, then adds tests and narrows citation behavior.
## Boundaries
### Backend AI module
- `modules/ai/types.ts`
- Keep runtime validators as the source of truth for output shape.
- Add/adjust helpers only if needed for citation ID validation.
- `modules/ai/server/analysis.ts`
- Validate model output against the allowed citation set before persisting a completed history row.
- Derive final persisted/display citations from citation IDs actually referenced by findings/recommendations.
- Keep all provider and validation failures sanitized.
- `modules/ai/server/knowledge.ts`
- Preserve current save-time embedding and scope-filtered retrieval behavior.
- Add tests around existing behavior rather than introducing background embedding state.
### API routes
- `app/api/ai/elders/[id]/analyses/route.ts`
- `app/api/ai/knowledge/route.ts`
- `app/api/ai/knowledge/[id]/route.ts`
Route behavior stays unchanged except tests protect permission ordering, structured failures, and audit behavior.
### Frontend
- `modules/ai/components/KnowledgeManagementClient.tsx`
- Make AI configuration / vector generation failures more explicit.
- `modules/ai/components/ElderAiAnalysisDialog.tsx`
- Make failed latest history state clearer without exposing raw errors.
### Permissions
- `modules/core/server/permissions.ts`
- Clarify `ai:manage` as reserved/currently governance-oriented wording only.
## Citation Contract
Allowed citations are built by `buildElderAiContext` plus knowledge retrieval. The model may reference only these IDs.
Final persisted `result.citations` must be derived from actual references:
1. Collect citation IDs from all `keyFindings[].citationIds` and `recommendations[].citationIds`.
2. If any referenced ID is absent from the allowed citation map, reject the output as `schema_validation_failed`.
3. Persist/display only the allowed citations whose IDs were referenced.
4. Preserve stable ordering from the original allowed citation list.
This avoids showing unused evidence and prevents hallucinated source IDs.
## Test Strategy
Tests should mock database/provider boundaries and assert observable contracts, not implementation details.
- Validator tests use pure functions.
- Knowledge tests mock `getDatabase` and `OpenAIEmbeddings` or use light fake query builders where practical.
- Analysis tests mock config, elder context, knowledge retrieval, model invocation, database insert, and audit logging.
- Route tests mock `requirePermission`, AI services, validation as needed, and audit logging.
## Compatibility
No database migration is required for this hardening pass.
No new dependency is required.
Existing persisted `resultJson.citations` remain readable; new analyses will have stricter citation lists.
## Rollback
- Revert citation strictness if a provider cannot satisfy citation references, but keep tests documenting expected loosened behavior.
- Revert UI message changes independently if copy causes product concern.
- Tests are additive and can stay even if implementation is adjusted.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,43 @@
# Harden AI Analysis Path Implementation Plan
## Steps
1. Add pure validator tests for `modules/ai/types.ts`.
2. Add/adjust analysis citation helper behavior in `modules/ai/server/analysis.ts`.
3. Add analysis service tests for missing config, invalid output, provider errors, retrieval degradation, and redaction.
4. Add knowledge service tests for scope, disabled entries, organization isolation, and embedding failure.
5. Add AI route tests for permission failures, invalid input, service failure propagation, success responses, and audit calls.
6. Improve knowledge UI and analysis dialog failure copy.
7. Clarify `ai:manage` permission description.
8. Run targeted AI tests, then project verification.
## Validation Commands
- `pnpm test -- modules/ai/types.test.ts`
- `pnpm test -- modules/ai/server/analysis.test.ts modules/ai/server/knowledge.test.ts app/api/ai/ai-routes.test.ts`
- `pnpm lint`
- `pnpm type-check`
- `pnpm test`
- `pnpm build`
## Risky Files
- `modules/ai/server/analysis.ts`
- `modules/ai/server/knowledge.ts`
- `modules/ai/types.ts`
- `app/api/ai/*/route.ts`
- `modules/core/server/permissions.ts`
## Review Gates
- Citation helper must reject unknown citation IDs and avoid appending unused citations.
- Tests must not call real AI providers.
- Tests must not require a live PostgreSQL instance unless explicitly scoped to migration smoke testing.
- No new `any`, non-null assertions, `@ts-ignore`, or `@ts-expect-error`.
- Failed history records must remain sanitized.
## Rollback Points
- After citation strictness: revert helper and related tests if provider output compatibility proves too brittle.
- After route tests: routes should remain behavior-compatible except status/copy assertions.
- After UI copy: copy changes can be reverted without affecting backend contracts.

View File

@@ -0,0 +1,49 @@
# Harden AI Analysis Path PRD
## Goal
Harden the existing elder AI analysis and knowledge retrieval MVP so the critical safety boundaries are covered by tests and the displayed evidence chain matches the citations actually used by the model output.
## Background
The current MVP already implements LangChain-backed elder AI analysis, pgvector-backed knowledge retrieval, permission-scoped resident context, persisted analysis history, and knowledge management UI.
Current evidence from repository inspection:
- AI service files live under `modules/ai/`.
- API routes live under `app/api/ai/`.
- AI tables and vector extension are in `modules/core/server/schema.ts` and `drizzle/0007_purple_puff_adder.sql`.
- Existing project verification passes: `pnpm lint`, `pnpm type-check`, and `pnpm test`.
- No AI-specific test file currently covers schema validation, permission scoping, history redaction, knowledge organization isolation, disabled knowledge exclusion, or provider failure behavior.
- `normalizeCitations` currently appends allowed citations that the model did not necessarily cite, weakening the evidence chain.
## Requirements
1. Add focused automated tests for AI validators, knowledge service behavior, analysis service behavior, and AI route authorization/response behavior.
2. Tighten citation handling so the persisted/rendered `citations` list is derived from citation IDs actually referenced by findings and recommendations.
3. Reject AI outputs whose finding or recommendation `citationIds` include IDs not present in the allowed context/knowledge citation set.
4. Keep failed analysis history sanitized: no full prompt, resident context, API key, or raw provider error.
5. Preserve the existing synchronous generation MVP behavior; do not introduce queues, streaming, background jobs, new dependencies, or a new vector store.
6. Improve user-facing failure text where configuration/vector generation failure would otherwise look like a generic save/generation failure.
7. Clarify `ai:manage` as a reserved/future governance permission instead of implying a currently implemented management surface.
## Acceptance Criteria
- `validateKnowledgeEntryInput`, `validateElderAiAnalysisOutput`, and `parseDataScopes` have direct unit coverage for valid and invalid edge cases.
- Knowledge tests cover writable scope failures, disabled entry exclusion, platform/current-organization visibility, other-organization exclusion, and embedding failure behavior without hitting a real provider.
- Analysis tests cover missing config, provider error, schema validation failure, retrieval degradation, and history redaction without hitting a real provider.
- AI route tests cover missing `ai:read`, missing `elder:read`, missing `knowledge:read`, missing `knowledge:manage`, invalid request body, service failure status propagation, and success audit behavior.
- Citation normalization no longer appends unused allowed citations.
- Any unknown citation ID in findings/recommendations causes a structured schema validation failure and failed history record.
- Knowledge UI and AI analysis dialog keep sensitive details out of failure messages while making configuration/vector-generation failures understandable.
- `ai:manage` permission description no longer overpromises current UI/API capability.
- `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build` pass.
## Out of Scope
- Async generation jobs.
- Streaming AI responses.
- AI usage billing/rate limiting.
- AI configuration UI.
- File upload/OCR/web crawling knowledge ingestion.
- AI-generated business record drafts or one-click mutations.

View File

@@ -0,0 +1,26 @@
{
"id": "07-06-harden-ai-analysis-path",
"name": "07-06-harden-ai-analysis-path",
"title": "Harden AI Analysis Path",
"description": "Add tests and tighten citation evidence chain for the AI knowledge analysis MVP.",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-05",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,343 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import {
createKnowledgeEntry,
deleteKnowledgeEntry,
listKnowledgeEntries,
updateKnowledgeEntry,
} from "@/modules/ai/server/knowledge";
import type { ElderAiAnalysisHistoryItem, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import type { AuthContext } from "@/modules/core/types";
vi.mock("@/modules/core/server/auth", () => ({
requirePermission: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
vi.mock("@/modules/ai/server/analysis", () => ({
generateElderAiAnalysis: vi.fn(),
listElderAiAnalyses: vi.fn(),
}));
vi.mock("@/modules/ai/server/knowledge", () => ({
createKnowledgeEntry: vi.fn(),
deleteKnowledgeEntry: vi.fn(),
listKnowledgeEntries: vi.fn(),
updateKnowledgeEntry: vi.fn(),
}));
const account: AuthContext["account"] = {
id: "account-1",
name: "AI Admin",
email: "ai-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",
};
function createAuthContext(): AuthContext {
return {
account,
organization,
organizations: [
{
id: organization.id,
name: organization.name,
slug: organization.slug,
status: organization.status,
registrationEnabled: organization.registrationEnabled,
oidcEnabled: organization.oidcEnabled,
roleLabel: "Admin",
isActive: true,
},
],
membership: {
id: "membership-1",
accountId: account.id,
organizationId: organization.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: ["ai:read", "elder:read", "knowledge:read", "knowledge:manage"],
session: {
id: "session-1",
accountId: account.id,
activeOrganizationId: organization.id,
createdAt: "2026-07-02T00:00:00.000Z",
expiresAt: "2026-07-09T00:00:00.000Z",
},
};
}
const analysis: ElderAiAnalysisHistoryItem = {
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder"],
createdAt: "2026-07-02T08:00:00.000Z",
restricted: false,
result: {
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: [
{
id: "resident-1",
sourceType: "resident_context",
sourceId: "elder-1",
title: "Resident risk notes",
excerpt: "Night wandering and prior fall history.",
},
],
confidence: 0.74,
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
},
},
};
const knowledgeInput: 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 knowledgeEntry: KnowledgeEntry = {
...knowledgeInput,
id: "knowledge-1",
createdAt: "2026-07-02T08:00:00.000Z",
updatedAt: "2026-07-02T08:00:00.000Z",
};
function allowAuth(): void {
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext() });
}
function deniedAuth(reason = "权限不足", status = 403): { success: false; response: Response } {
return {
success: false,
response: Response.json({ success: false, reason }, { status }),
};
}
function jsonRequest(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/ai/knowledge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
beforeEach(() => {
vi.clearAllMocks();
allowAuth();
});
describe("AI API routes", () => {
it("requires ai:read before elder analysis services are reached", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { GET } = await import("./elders/[id]/analyses/route");
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body).toEqual({ success: false, reason: "权限不足" });
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(requirePermission).toHaveBeenCalledWith("ai:read", expect.objectContaining({ targetId: "elder-1" }));
expect(listElderAiAnalyses).not.toHaveBeenCalled();
});
it("requires elder:read after ai:read before listing elder analysis history", async () => {
vi.mocked(requirePermission)
.mockResolvedValueOnce({ success: true, context: createAuthContext() })
.mockResolvedValueOnce(deniedAuth("权限不足", 403));
const { GET } = await import("./elders/[id]/analyses/route");
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenNthCalledWith(2, "elder:read", expect.objectContaining({
organizationId: "org-1",
targetId: "elder-1",
}));
expect(listElderAiAnalyses).not.toHaveBeenCalled();
});
it("propagates analysis service failures with the service status", async () => {
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
const { POST } = await import("./elders/[id]/analyses/route");
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(500);
expect(body).toEqual({ success: false, reason: "AI_API_KEY 未配置" });
expect(generateElderAiAnalysis).toHaveBeenCalledWith(createAuthContext(), "elder-1");
});
it("returns structured success for generated analysis", async () => {
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: true, data: { analysis } });
const { POST } = await import("./elders/[id]/analyses/route");
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
params: Promise.resolve({ id: "elder-1" }),
});
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body).toEqual({ success: true, reason: "AI 分析已生成", analysis });
});
it("requires knowledge:read before listing knowledge entries", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { GET } = await import("./knowledge/route");
const response = await GET();
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenCalledWith("knowledge:read", expect.objectContaining({ action: "ai.knowledge.list" }));
expect(listKnowledgeEntries).not.toHaveBeenCalled();
});
it("requires knowledge:manage before creating knowledge entries", async () => {
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(403);
expect(body.reason).toBe("权限不足");
expect(requirePermission).toHaveBeenCalledWith("knowledge:manage", expect.objectContaining({ action: "ai.knowledge.create" }));
expect(createKnowledgeEntry).not.toHaveBeenCalled();
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("rejects invalid knowledge bodies before calling the service or audit log", async () => {
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput, title: " " }));
const body = await readJson(response);
expect(response.status).toBe(400);
expect(body).toEqual({ success: false, reason: "知识标题不能为空" });
expect(createKnowledgeEntry).not.toHaveBeenCalled();
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("propagates knowledge service failures with the service status", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库向量生成失败", status: 500 });
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(500);
expect(body).toEqual({ success: false, reason: "知识库向量生成失败" });
expect(recordAuditLog).not.toHaveBeenCalled();
});
it("returns created knowledge entries and records create audit success", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(201);
expect(body).toEqual({ success: true, reason: "知识库条目已创建", entry: knowledgeEntry });
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
actor: account,
organizationId: "org-1",
action: "ai.knowledge.create",
targetType: "ai_knowledge",
targetId: "knowledge-1",
result: "success",
}));
});
it("records update and delete audit success only after knowledge mutations succeed", async () => {
vi.mocked(updateKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
vi.mocked(deleteKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
const { PATCH, DELETE } = await import("./knowledge/[id]/route");
const patchResponse = await PATCH(jsonRequest({ ...knowledgeInput }), {
params: Promise.resolve({ id: "knowledge-1" }),
});
const deleteResponse = await DELETE(new Request("http://localhost/api/ai/knowledge/knowledge-1", { method: "DELETE" }), {
params: Promise.resolve({ id: "knowledge-1" }),
});
expect(patchResponse.status).toBe(200);
expect(deleteResponse.status).toBe(200);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.update", result: "success" }));
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.delete", result: "success" }));
});
});

View File

@@ -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>
) : (

View File

@@ -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();

View 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]);
});
});

View File

@@ -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)

View 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
View 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([]);
});
});
});

View File

@@ -46,7 +46,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{ id: "elder:update", label: "更新老人档案", category: "老人", description: "更新老人档案和照护信息。" },
{ id: "elder:delete", label: "删除老人档案", category: "老人", description: "删除老人档案。" },
{ id: "ai:read", label: "查看 AI 分析", category: "AI", description: "查看并生成授权数据范围内的 AI 分析。" },
{ id: "ai:manage", label: "管理 AI 能力", category: "AI", description: "管理 AI 能力配置分析治理。" },
{ id: "ai:manage", label: "管理 AI 能力", category: "AI", description: "预留给 AI 配置分析治理能力,当前不开放独立管理入口。" },
{ id: "knowledge:read", label: "查看知识库", category: "AI", description: "查看并检索平台和机构知识库内容。" },
{ id: "knowledge:manage", label: "管理知识库", category: "AI", description: "维护平台或机构知识库条目。" },
];