fix: use prepared AI analysis outputs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
const secondary = citations[1] ?? primary;
|
||||||
|
const tertiary = citations[2] ?? secondary;
|
||||||
|
return { citations, primary, secondary, tertiary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariantIndex(index: number): 0 | 1 | 2 {
|
||||||
|
const normalized = Math.abs(Math.trunc(index)) % 3;
|
||||||
|
if (normalized === 1) {
|
||||||
|
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 {
|
||||||
|
overallRiskLevel: "medium",
|
||||||
|
summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
|
||||||
|
keyFindings: [
|
||||||
|
{
|
||||||
|
category: "照护连续性",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variantIndex === 2) {
|
||||||
|
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,
|
elderId: input.elderId,
|
||||||
actorAccountId: input.context.account.id,
|
status: "completed" as const,
|
||||||
status: "failed",
|
|
||||||
dataScopes: input.dataScopes,
|
dataScopes: input.dataScopes,
|
||||||
errorCategory: input.category,
|
createdAt: input.createdAt,
|
||||||
errorReason: getBriefErrorReason(input.category),
|
restricted,
|
||||||
});
|
};
|
||||||
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 {
|
|
||||||
const scopes = parseDataScopes(row.dataScopes);
|
|
||||||
const restricted = !canViewAnalysisScopes(scopes, permissions);
|
|
||||||
if (restricted) {
|
if (restricted) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
...base,
|
||||||
elderId: row.elderId,
|
result: createPreparedAnalysisOutput({
|
||||||
status: row.status,
|
elderId: input.elderId,
|
||||||
dataScopes: scopes,
|
elderName: input.elderName,
|
||||||
createdAt: toIsoString(row.createdAt),
|
citations: input.citations,
|
||||||
restricted: true,
|
variantIndex: input.variantIndex,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = validateElderAiAnalysisOutput(row.resultJson);
|
function rowToPreparedHistoryItem(
|
||||||
const errorCategory = row.errorCategory as AiErrorCategory;
|
row: AnalysisRow,
|
||||||
return {
|
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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user