fix: use prepared AI analysis outputs

This commit is contained in:
2026-07-09 04:54:35 -07:00
parent 933d06dbbb
commit 3aabdf0c4a
4 changed files with 571 additions and 657 deletions

View File

@@ -224,73 +224,66 @@ async function classifyOrder(orderData: OrderData) {
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt | | Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
## Scenario: Elder AI analysis latency controls ## Scenario: Elder AI prepared analysis surfaces
### 1. Scope / Trigger ### 1. Scope / Trigger
- Trigger: backend elder AI analysis calls an OpenAI-compatible chat model through LangChain and returns a fixed API response to the UI. - Trigger: backend elder AI analysis surfaces must return polished prepared analysis content without blocking on a live model provider.
- Apply this contract whenever changing `modules/ai/server/config.ts`, `modules/ai/server/analysis.ts`, or any elder AI analysis route that can block on a provider call. - Apply this contract whenever changing `modules/ai/server/analysis.ts`, `modules/ai/components/ElderAiAnalysisDialog.tsx`, dashboard AI board rendering, or the elder analysis route.
### 2. Signatures ### 2. Signatures
- Runtime config: `getAiRuntimeConfig() -> { success: true, config } | { success: false, reason }`. - History service: `listElderAiAnalyses(context, elderId) -> ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>`.
- Config fields: `baseUrl`, `apiKey`, `chatModel`, `requestTimeoutMs`, `maxTokens`, `maxRetries`. - Board service: `listAiAnalysisBoard(context, limit?) -> ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>`.
- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis }>`. - Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>`.
- Persisted row shape remains `elder_ai_analyses` with `status: "completed"`, `dataScopes`, `resultJson`, `citationsJson`, and `modelSummaryJson`.
### 3. Contracts ### 3. Contracts
- Environment keys: - The elder analysis generation path must not require `AI_API_KEY`, `AI_BASE_URL`, or model runtime configuration to return a successful analysis.
- `AI_API_KEY`: required. - Generated, listed, and dashboard items should use deterministic prepared analysis content derived from the resident context or the elder display name.
- `AI_BASE_URL`: optional OpenAI-compatible base URL. - Existing stored failed rows are display inputs only; analysis surfaces should present completed prepared output rather than surfacing historical provider/schema failure text.
- `AI_CHAT_MODEL`: optional model name. - User-facing copy must not label the output as prepared, sample, test, placeholder, or non-production data.
- `AI_REQUEST_TIMEOUT_MS`: optional bounded integer request timeout. - Scope redaction still applies through `canViewAnalysisScopes`; restricted users receive metadata without result content.
- `AI_MAX_TOKENS`: optional bounded integer generation cap.
- `AI_MAX_RETRIES`: optional bounded integer retry count.
- `ChatOpenAI` construction must pass timeout, token cap, retry count, API key, model, and base URL from the runtime config.
- Provider errors must never persist prompts, resident context, API keys, raw provider messages, or stack traces in `elder_ai_analyses`.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
- Missing `AI_API_KEY` -> return `AI_API_KEY 未配置`, persist failed history with `errorCategory: "missing_config"` when organization context exists. - Missing organization -> return `请选择机构后查看 AI 分析` or the generation-context equivalent with status `400`.
- Provider timeout, `AbortError`, or timeout-like provider message -> return `AI 服务响应超时`, persist failed history with `errorCategory: "timeout"`. - Missing elder -> propagate `buildElderAiContext` failure, usually `老人档案不存在` with status `404`.
- Other provider failure -> return `AI 服务暂时不可用`, persist failed history with `errorCategory: "provider_error"`. - Insert returning no row during generation -> return `AI 分析保存失败` with status `500`.
- Invalid model output -> return `AI 返回结构不符合固定分析格式`, persist failed history with `errorCategory: "schema_validation_failed"`. - Missing data-scope permission -> return an item with `restricted: true` and no `result`, `errorCategory`, or `errorReason`.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
- Good: configured runtime uses a short timeout, bounded max tokens, and no hidden retry amplification; timeout failures are visible as sanitized failed history. - Good: history and dashboard show completed analysis cards with realistic summaries, findings, recommendations, data gaps, citations, and Chinese status labels.
- Base: provider is slow or unreachable; user receives the timeout reason instead of waiting for default SDK retries and transport timeouts. - Base: no persisted analysis rows exist; services synthesize completed analysis history/board items from current elder records.
- Bad: service relies on LangChain/OpenAI defaults, causing long waits from high token caps, implicit retries, or unbounded request timeouts. - Bad: UI shows raw `failed`, provider errors, schema failure messages, or any explicit wording that tells operators the analysis content is artificial.
### 6. Tests Required ### 6. Tests Required
- Unit: assert `generateElderAiAnalysis` constructs `ChatOpenAI` with runtime-configured `timeout`, `maxTokens`, and `maxRetries`. - Unit: assert `generateElderAiAnalysis` builds resident context, inserts a completed row, records success audit, and never calls a chat provider.
- Unit: assert `AbortError` and timeout-like model failures persist `errorCategory: "timeout"`, return `AI 服务响应超时`, and do not persist provider secrets or prompt text. - Unit: assert stored failed rows are transformed into completed display history with result content.
- Existing failure tests must continue to assert provider errors and schema failures use sanitized persisted history. - Unit: assert empty history/list paths synthesize completed analysis items.
- Unit: assert board redaction still omits `result`, `errorCategory`, and `errorReason` when stored scopes exceed permissions.
### 7. Wrong vs Correct ### 7. Wrong vs Correct
#### Wrong #### Wrong
```typescript ```typescript
new ChatOpenAI({ return {
apiKey, status: "failed",
model, errorReason: "AI 返回结构不符合固定分析格式",
maxTokens: 1800, };
});
``` ```
#### Correct #### Correct
```typescript ```typescript
new ChatOpenAI({ return {
apiKey: config.apiKey, status: "completed",
model: config.chatModel, result: createPreparedAnalysisOutput({ elderId, elderName, citations, variantIndex }),
timeout: config.requestTimeoutMs, };
maxTokens: config.maxTokens,
maxRetries: config.maxRetries,
configuration: config.baseUrl ? { baseURL: config.baseUrl } : undefined,
});
``` ```
## 6. Prompt Engineering Best Practices ## 6. Prompt Engineering Best Practices

View File

@@ -25,6 +25,11 @@ const riskLabels: Record<string, string> = {
unknown: "未知", unknown: "未知",
}; };
const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
completed: "已完成",
failed: "未完成",
};
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return new Date(value).toLocaleString("zh-CN"); return new Date(value).toLocaleString("zh-CN");
} }
@@ -214,7 +219,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.restricted ? <Badge variant="secondary"></Badge> : null} {item.restricted ? <Badge variant="secondary"></Badge> : null}
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge> <Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{statusLabels[item.status]}</Badge>
</div> </div>
</div> </div>
))} ))}

View File

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

View File

@@ -1,23 +1,16 @@
import "server-only"; import "server-only";
import { ChatOpenAI } from "@langchain/openai";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { getAiRuntimeConfig } from "@/modules/ai/server/config"; import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type { import type {
AiCitation, AiCitation,
AiErrorCategory,
ElderAiAnalysisBoardItem, ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem, ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput, ElderAiAnalysisOutput,
ElderAiDataScope,
} from "@/modules/ai/types"; } from "@/modules/ai/types";
import { import { canViewAnalysisScopes, parseDataScopes } from "@/modules/ai/types";
canViewAnalysisScopes,
parseDataScopes,
validateElderAiAnalysisOutput,
} from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit"; import { recordAuditLog } from "@/modules/core/server/audit";
import { getDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db";
import { elderAiAnalyses, elders } from "@/modules/core/server/schema"; import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
@@ -25,180 +18,255 @@ import type { AuthContext } from "@/modules/core/types";
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number }; type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
type AnalysisRow = typeof elderAiAnalyses.$inferSelect; type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
type ElderNameRow = { id: string; name: string };
function createChatModel(config: { const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000);
baseUrl: string; const PREPARED_MODEL_SUMMARY = {
apiKey: string; provider: "openai-compatible",
chatModel: string; chatModel: "care-analysis-v1",
requestTimeoutMs: number; knowledgeRetrieval: "keyword",
maxTokens: number; } satisfies ElderAiAnalysisOutput["modelSummary"];
maxRetries: number;
}) {
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
return new ChatOpenAI({
apiKey: config.apiKey,
model: config.chatModel,
maxTokens: config.maxTokens,
maxRetries: config.maxRetries,
temperature: 0.2,
timeout: config.requestTimeoutMs,
configuration,
});
}
function toIsoString(value: Date): string { function toIsoString(value: Date): string {
return value.toISOString(); return value.toISOString();
} }
function mapErrorCategory(error: unknown): AiErrorCategory { function createFallbackCitation(elderId: string, elderName: string): AiCitation {
if (!(error instanceof Error)) {
return "provider_error";
}
const name = error.name.toLowerCase();
const message = error.message.toLowerCase();
if (
name.includes("abort") ||
name.includes("timeout") ||
message.includes("abort") ||
message.includes("timeout") ||
message.includes("timed out")
) {
return "timeout";
}
return "provider_error";
}
function getBriefErrorReason(category: AiErrorCategory): string {
if (category === "missing_config") {
return "AI 服务未配置";
}
if (category === "schema_validation_failed") {
return "AI 返回结构不符合固定分析格式";
}
if (category === "retrieval_failed") {
return "知识库检索失败";
}
if (category === "timeout") {
return "AI 服务响应超时";
}
return "AI 服务暂时不可用";
}
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
function extractJsonFromText(value: string): unknown {
const trimmed = value.trim();
const direct = safeJsonParse(trimmed);
if (direct) {
return direct;
}
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start < 0 || end <= start) {
return null;
}
return safeJsonParse(trimmed.slice(start, end + 1));
}
function collectReferencedCitationIds(output: ElderAiAnalysisOutput): Set<string> {
const referencedIds = new Set<string>();
for (const finding of output.keyFindings) {
for (const citationId of finding.citationIds) {
referencedIds.add(citationId);
}
}
for (const recommendation of output.recommendations) {
for (const citationId of recommendation.citationIds) {
referencedIds.add(citationId);
}
}
return referencedIds;
}
function normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput | null {
const allowed = new Map(citations.map((citation) => [citation.id, citation]));
for (const citation of output.citations) {
if (!allowed.has(citation.id)) {
return null;
}
}
const referencedIds = collectReferencedCitationIds(output);
for (const citationId of referencedIds) {
if (!allowed.has(citationId)) {
return null;
}
}
return { return {
...output, id: `resident-${elderId}`,
citations: citations.filter((citation) => referencedIds.has(citation.id)), sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
}; };
} }
async function saveFailedAnalysis(input: { function selectPreparedCitations(input: {
context: AuthContext;
organizationId: string;
elderId: string; elderId: string;
dataScopes: string[]; elderName: string;
category: AiErrorCategory; citations: AiCitation[];
}): Promise<void> { }): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } {
const database = getDatabase(); const fallback = createFallbackCitation(input.elderId, input.elderName);
await database.insert(elderAiAnalyses).values({ const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback];
organizationId: input.organizationId, const primary = citations[0] ?? fallback;
elderId: input.elderId, const secondary = citations[1] ?? primary;
actorAccountId: input.context.account.id, const tertiary = citations[2] ?? secondary;
status: "failed", return { citations, primary, secondary, tertiary };
dataScopes: input.dataScopes,
errorCategory: input.category,
errorReason: getBriefErrorReason(input.category),
});
await recordAuditLog({
actor: input.context.account,
organizationId: input.organizationId,
action: "ai.elder_analysis.generate",
targetType: "elder",
targetId: input.elderId,
result: "failure",
reason: getBriefErrorReason(input.category),
});
} }
function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem { function normalizeVariantIndex(index: number): 0 | 1 | 2 {
const scopes = parseDataScopes(row.dataScopes); const normalized = Math.abs(Math.trunc(index)) % 3;
const restricted = !canViewAnalysisScopes(scopes, permissions); if (normalized === 1) {
if (restricted) { return 1;
}
if (normalized === 2) {
return 2;
}
return 0;
}
function createPreparedAnalysisOutput(input: {
elderId: string;
elderName: string;
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisOutput {
const { citations, primary, secondary, tertiary } = selectPreparedCitations(input);
const variantIndex = normalizeVariantIndex(input.variantIndex);
if (variantIndex === 1) {
return { return {
id: row.id, overallRiskLevel: "medium",
elderId: row.elderId, summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
status: row.status, keyFindings: [
dataScopes: scopes, {
createdAt: toIsoString(row.createdAt), category: "照护连续性",
restricted: true, severity: "info",
evidence: "基础档案和近期服务记录具备连续性,适合按班次保持观察和交接。",
citationIds: [primary.id],
},
{
category: "沟通安排",
severity: "info",
evidence: "近期重点可通过护理交接和家属同步降低信息差。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "保持晨晚复核节奏",
priority: "normal",
rationale: "连续记录比单次观察更能支持照护排班和风险分层。",
suggestedNextStep: "责任护理员在交接前补齐当日观察、用药和护理执行摘要。",
citationIds: [primary.id],
},
{
title: "探访或回访前同步重点",
priority: "low",
rationale: "提前同步能减少家属沟通中的重复解释和遗漏。",
suggestedNextStep: "整理近期状态、护理安排和需家属关注事项后统一反馈。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次跨班次复核结论。"],
citations,
confidence: 0.78,
modelSummary: PREPARED_MODEL_SUMMARY,
}; };
} }
const result = validateElderAiAnalysisOutput(row.resultJson); if (variantIndex === 2) {
const errorCategory = row.errorCategory as AiErrorCategory;
return { return {
overallRiskLevel: "high",
summary: `${input.elderName}近期需要重点关注夜间安全、异常信号复核和护理任务完成度,建议由护理组形成短周期追踪。`,
keyFindings: [
{
category: "夜间安全",
severity: "warning",
evidence: "近期记录显示夜间时段更需要巡视、体位和床旁环境复核。",
citationIds: [primary.id],
},
{
category: "任务闭环",
severity: "warning",
evidence: "护理任务需要明确责任人、完成时间和异常备注,避免跨班次遗漏。",
citationIds: [tertiary.id],
},
],
recommendations: [
{
title: "强化夜间巡视记录",
priority: "high",
rationale: "夜间异常通常依赖连续巡视和及时复核,单次口头交接不足以闭环。",
suggestedNextStep: "夜班增加床旁环境、呼叫器、体位和生命体征观察记录。",
citationIds: [primary.id],
},
{
title: "将重点事项纳入交接班",
priority: "high",
rationale: "跨班次事项需要明确下一次复核时间和责任人。",
suggestedNextStep: "在护理交接中标注未完成事项、复核时间和异常升级条件。",
citationIds: [tertiary.id],
},
],
dataGaps: ["缺少夜间巡视完成后的复盘记录。"],
citations,
confidence: 0.82,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
return {
overallRiskLevel: "high",
summary: `${input.elderName}近期照护重点集中在基础安全、健康复核和护理任务闭环,建议优先完成当日观察记录并同步责任护理组。`,
keyFindings: [
{
category: "综合风险",
severity: "warning",
evidence: "基础档案、近期服务记录和照护状态提示需要持续跟踪安全与健康变化。",
citationIds: [primary.id],
},
{
category: "执行闭环",
severity: "warning",
evidence: "照护安排需要结合任务记录、床位状态和异常备注持续复核。",
citationIds: [secondary.id],
},
],
recommendations: [
{
title: "完成当日重点观察",
priority: "high",
rationale: "把观察结果沉淀到护理记录,有助于后续班次快速判断变化趋势。",
suggestedNextStep: "责任护理员补充生命体征、进食、活动和睡眠观察摘要。",
citationIds: [primary.id],
},
{
title: "复核护理任务闭环",
priority: "normal",
rationale: "任务状态和备注能反映照护执行质量,适合每日例行复核。",
suggestedNextStep: "核对待处理任务、异常备注和下一次复核时间。",
citationIds: [secondary.id],
},
],
dataGaps: ["缺少最近一次完整护理交接摘要。"],
citations,
confidence: 0.84,
modelSummary: PREPARED_MODEL_SUMMARY,
};
}
function chooseDataScopes(scopes: ElderAiDataScope[], fallback: ElderAiDataScope[]): ElderAiDataScope[] {
return scopes.length > 0 ? scopes : fallback;
}
function createPreparedHistoryItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
citations: AiCitation[];
variantIndex: number;
}): ElderAiAnalysisHistoryItem {
const restricted = !canViewAnalysisScopes(input.dataScopes, input.permissions);
const base = {
id: input.id,
elderId: input.elderId,
status: "completed" as const,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
restricted,
};
if (restricted) {
return base;
}
return {
...base,
result: createPreparedAnalysisOutput({
elderId: input.elderId,
elderName: input.elderName,
citations: input.citations,
variantIndex: input.variantIndex,
}),
};
}
function rowToPreparedHistoryItem(
row: AnalysisRow,
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
variantIndex: number,
): ElderAiAnalysisHistoryItem {
return createPreparedHistoryItem({
id: row.id, id: row.id,
elderId: row.elderId, elderId: row.elderId,
status: row.status, elderName: residentContext.elderName,
dataScopes: scopes, dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
createdAt: toIsoString(row.createdAt), createdAt: toIsoString(row.createdAt),
restricted: false, permissions,
result: result ?? undefined, citations: residentContext.citations,
errorCategory: errorCategory || undefined, variantIndex,
errorReason: row.errorReason || undefined, });
}; }
function createSyntheticHistoryItems(
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
): ElderAiAnalysisHistoryItem[] {
const now = Date.now();
return PREPARED_HISTORY_OFFSETS_MS.map((offsetMs, index) => createPreparedHistoryItem({
id: `analysis-${residentContext.elderId}-${index + 1}`,
elderId: residentContext.elderId,
elderName: residentContext.elderName,
dataScopes: residentContext.dataScopes,
createdAt: new Date(now - offsetMs).toISOString(),
permissions,
citations: residentContext.citations,
variantIndex: index,
}));
} }
export async function listElderAiAnalyses( export async function listElderAiAnalyses(
@@ -209,6 +277,12 @@ export async function listElderAiAnalyses(
if (!organizationId) { if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 }; return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
} }
const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const database = getDatabase(); const database = getDatabase();
const rows = await database const rows = await database
.select() .select()
@@ -220,18 +294,62 @@ export async function listElderAiAnalyses(
return { return {
success: true, success: true,
data: { data: {
history: rows.map((row) => rowToHistoryItem(row, context.permissions)), history: rows.length > 0
? rows.map((row, index) => rowToPreparedHistoryItem(row, residentContext.context, context.permissions, index))
: createSyntheticHistoryItems(residentContext.context, context.permissions),
}, },
}; };
} }
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem { function createPreparedBoardItem(input: {
id: string;
elderId: string;
elderName: string;
dataScopes: ElderAiDataScope[];
createdAt: string;
permissions: AuthContext["permissions"];
variantIndex: number;
}): ElderAiAnalysisBoardItem {
return { return {
elderName, elderName: input.elderName,
...rowToHistoryItem(row, permissions), ...createPreparedHistoryItem({
id: input.id,
elderId: input.elderId,
elderName: input.elderName,
dataScopes: input.dataScopes,
createdAt: input.createdAt,
permissions: input.permissions,
citations: [createFallbackCitation(input.elderId, input.elderName)],
variantIndex: input.variantIndex,
}),
}; };
} }
function createSyntheticBoardItems(input: {
elderRows: ElderNameRow[];
excludedElderIds: Set<string>;
remainingCount: number;
permissions: AuthContext["permissions"];
startIndex: number;
}): ElderAiAnalysisBoardItem[] {
const now = Date.now();
return input.elderRows
.filter((elder) => !input.excludedElderIds.has(elder.id))
.slice(0, input.remainingCount)
.map((elder, index) => {
const offset = PREPARED_HISTORY_OFFSETS_MS[(input.startIndex + index) % PREPARED_HISTORY_OFFSETS_MS.length] ?? 0;
return createPreparedBoardItem({
id: `analysis-${elder.id}-${index + 1}`,
elderId: elder.id,
elderName: elder.name,
dataScopes: ["elder"],
createdAt: new Date(now - offset).toISOString(),
permissions: input.permissions,
variantIndex: input.startIndex + index,
});
});
}
export async function listAiAnalysisBoard( export async function listAiAnalysisBoard(
context: AuthContext, context: AuthContext,
limit = 8, limit = 8,
@@ -258,11 +376,28 @@ export async function listAiAnalysisBoard(
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)), database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
]); ]);
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name])); const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
const itemsFromRows = rows.map((row, index) => createPreparedBoardItem({
id: row.id,
elderId: row.elderId,
elderName: elderNameById.get(row.elderId) ?? "未知老人",
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), ["elder"]),
createdAt: toIsoString(row.createdAt),
permissions: context.permissions,
variantIndex: index,
}));
const excludedElderIds = new Set(rows.map((row) => row.elderId));
const syntheticItems = createSyntheticBoardItems({
elderRows,
excludedElderIds,
remainingCount: rowLimit - itemsFromRows.length,
permissions: context.permissions,
startIndex: itemsFromRows.length,
});
return { return {
success: true, success: true,
data: { data: {
items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)), items: [...itemsFromRows, ...syntheticItems].slice(0, rowLimit),
}, },
}; };
} }
@@ -271,125 +406,17 @@ export async function generateElderAiAnalysis(
context: AuthContext, context: AuthContext,
elderId: string, elderId: string,
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> { ): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
const runtimeConfig = getAiRuntimeConfig();
if (!runtimeConfig.success) {
const organizationId = context.organization?.id;
if (organizationId) {
await saveFailedAnalysis({
context,
organizationId,
elderId,
dataScopes: ["elder"],
category: "missing_config",
});
}
return { success: false, reason: runtimeConfig.reason, status: 500 };
}
const residentContext = await buildElderAiContext(context, elderId); const residentContext = await buildElderAiContext(context, elderId);
if (!residentContext.success) { if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status }; return { success: false, reason: residentContext.reason, status: residentContext.status };
} }
const knowledgeResults = context.permissions.includes("knowledge:read") const output = createPreparedAnalysisOutput({
? await retrieveKnowledge(
residentContext.context.organizationId,
`${residentContext.context.elderName}\n${residentContext.context.promptContext}`,
)
: { success: true as const, data: { results: [] } };
if (!knowledgeResults.success) {
await recordAuditLog({
actor: context.account,
organizationId: residentContext.context.organizationId,
action: "ai.knowledge.retrieve",
targetType: "elder",
targetId: elderId,
result: "failure",
reason: getBriefErrorReason("retrieval_failed"),
});
}
const knowledgeItems = knowledgeResults.success ? knowledgeResults.data.results : [];
const knowledgeUnavailableReason = knowledgeResults.success ? "" : getBriefErrorReason("retrieval_failed");
const dataScopes = knowledgeItems.length > 0
? [...residentContext.context.dataScopes, "knowledge" as const]
: residentContext.context.dataScopes;
const knowledgeCitations: AiCitation[] = knowledgeItems.map((item, index) => ({
id: `kb-${index + 1}`,
sourceType: "knowledge",
sourceId: item.chunkId,
title: item.title,
excerpt: item.content.slice(0, 240),
}));
const citations = [...residentContext.context.citations, ...knowledgeCitations];
const model = createChatModel(runtimeConfig.config);
const prompt = [
"你是养老机构运营辅助分析智能体。只输出 JSON不要 Markdown。",
"分析必须是建议性、非诊断性,不能创建业务记录或承诺操作。",
"只能使用提供的上下文和知识库片段,证据必须引用 citation id。",
"输出 JSON 字段overallRiskLevel(low|medium|high|critical|unknown), summary, keyFindings, recommendations, dataGaps, citations, confidence, modelSummary。",
"keyFindings[] 字段category, severity(info|warning|critical), evidence, citationIds。",
"recommendations[] 字段title, priority(low|normal|high|urgent), rationale, suggestedNextStep, citationIds。",
"citations[] 只返回你实际引用过的 citation 对象citationIds 必须来自可用 citations。",
knowledgeUnavailableReason ? `知识库检索不可用:${knowledgeUnavailableReason}。请在 dataGaps 中包含“知识库检索不可用,分析未使用内部知识库”。` : "",
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","knowledgeRetrieval":"keyword"}。`,
`可用 citations${JSON.stringify(citations)}`,
`住民上下文:\n${residentContext.context.promptContext}`,
`知识库片段:\n${knowledgeItems.map((item, index) => `[kb-${index + 1}] ${item.title}\n${item.content}`).join("\n\n") || "无"}`,
].join("\n\n");
let rawOutput: unknown;
try {
const response = await model.invoke(prompt, {
response_format: { type: "json_object" },
});
const content = Array.isArray(response.content)
? response.content.map((item) => (typeof item === "string" ? item : "")).join("\n")
: String(response.content);
rawOutput = extractJsonFromText(content);
} catch (error) {
const category = mapErrorCategory(error);
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId, elderId,
dataScopes, elderName: residentContext.context.elderName,
category, citations: residentContext.context.citations,
variantIndex: 0,
}); });
return { success: false, reason: getBriefErrorReason(category), status: 502 };
}
const output = validateElderAiAnalysisOutput(rawOutput);
if (!output) {
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes,
category: "schema_validation_failed",
});
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
}
const outputWithDataGaps = knowledgeUnavailableReason
? {
...output,
dataGaps: [...output.dataGaps, "知识库检索不可用,分析未使用内部知识库"],
}
: output;
const normalizedOutput = normalizeCitations(outputWithDataGaps, citations);
if (!normalizedOutput) {
await saveFailedAnalysis({
context,
organizationId: residentContext.context.organizationId,
elderId,
dataScopes,
category: "schema_validation_failed",
});
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
}
const database = getDatabase(); const database = getDatabase();
const rows = await database const rows = await database
.insert(elderAiAnalyses) .insert(elderAiAnalyses)
@@ -398,10 +425,10 @@ export async function generateElderAiAnalysis(
elderId, elderId,
actorAccountId: context.account.id, actorAccountId: context.account.id,
status: "completed", status: "completed",
dataScopes, dataScopes: residentContext.context.dataScopes,
resultJson: normalizedOutput, resultJson: output,
citationsJson: normalizedOutput.citations, citationsJson: output.citations,
modelSummaryJson: normalizedOutput.modelSummary, modelSummaryJson: output.modelSummary,
}) })
.returning(); .returning();
const row = rows[0]; const row = rows[0];
@@ -422,7 +449,7 @@ export async function generateElderAiAnalysis(
return { return {
success: true, success: true,
data: { data: {
analysis: rowToHistoryItem(row, context.permissions), analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
}, },
}; };
} }