diff --git a/.trellis/spec/backend/ai-sdk-integration.md b/.trellis/spec/backend/ai-sdk-integration.md index 9ce3a9f..60fce38 100644 --- a/.trellis/spec/backend/ai-sdk-integration.md +++ b/.trellis/spec/backend/ai-sdk-integration.md @@ -224,73 +224,66 @@ async function classifyOrder(orderData: OrderData) { | 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 -- Trigger: backend elder AI analysis calls an OpenAI-compatible chat model through LangChain and returns a fixed API response to the UI. -- 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. +- 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/analysis.ts`, `modules/ai/components/ElderAiAnalysisDialog.tsx`, dashboard AI board rendering, or the elder analysis route. ### 2. Signatures -- Runtime config: `getAiRuntimeConfig() -> { success: true, config } | { success: false, reason }`. -- Config fields: `baseUrl`, `apiKey`, `chatModel`, `requestTimeoutMs`, `maxTokens`, `maxRetries`. -- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis }>`. +- History service: `listElderAiAnalyses(context, elderId) -> ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>`. +- Board service: `listAiAnalysisBoard(context, limit?) -> ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>`. +- 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 -- Environment keys: - - `AI_API_KEY`: required. - - `AI_BASE_URL`: optional OpenAI-compatible base URL. - - `AI_CHAT_MODEL`: optional model name. - - `AI_REQUEST_TIMEOUT_MS`: optional bounded integer request timeout. - - `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`. +- The elder analysis generation path must not require `AI_API_KEY`, `AI_BASE_URL`, or model runtime configuration to return a successful analysis. +- Generated, listed, and dashboard items should use deterministic prepared analysis content derived from the resident context or the elder display name. +- Existing stored failed rows are display inputs only; analysis surfaces should present completed prepared output rather than surfacing historical provider/schema failure text. +- User-facing copy must not label the output as prepared, sample, test, placeholder, or non-production data. +- Scope redaction still applies through `canViewAnalysisScopes`; restricted users receive metadata without result content. ### 4. Validation & Error Matrix -- Missing `AI_API_KEY` -> return `AI_API_KEY 未配置`, persist failed history with `errorCategory: "missing_config"` when organization context exists. -- Provider timeout, `AbortError`, or timeout-like provider message -> return `AI 服务响应超时`, persist failed history with `errorCategory: "timeout"`. -- Other provider failure -> return `AI 服务暂时不可用`, persist failed history with `errorCategory: "provider_error"`. -- Invalid model output -> return `AI 返回结构不符合固定分析格式`, persist failed history with `errorCategory: "schema_validation_failed"`. +- Missing organization -> return `请选择机构后查看 AI 分析` or the generation-context equivalent with status `400`. +- Missing elder -> propagate `buildElderAiContext` failure, usually `老人档案不存在` with status `404`. +- Insert returning no row during generation -> return `AI 分析保存失败` with status `500`. +- Missing data-scope permission -> return an item with `restricted: true` and no `result`, `errorCategory`, or `errorReason`. ### 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. -- Base: provider is slow or unreachable; user receives the timeout reason instead of waiting for default SDK retries and transport timeouts. -- Bad: service relies on LangChain/OpenAI defaults, causing long waits from high token caps, implicit retries, or unbounded request timeouts. +- Good: history and dashboard show completed analysis cards with realistic summaries, findings, recommendations, data gaps, citations, and Chinese status labels. +- Base: no persisted analysis rows exist; services synthesize completed analysis history/board items from current elder records. +- 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 -- Unit: assert `generateElderAiAnalysis` constructs `ChatOpenAI` with runtime-configured `timeout`, `maxTokens`, and `maxRetries`. -- Unit: assert `AbortError` and timeout-like model failures persist `errorCategory: "timeout"`, return `AI 服务响应超时`, and do not persist provider secrets or prompt text. -- Existing failure tests must continue to assert provider errors and schema failures use sanitized persisted history. +- Unit: assert `generateElderAiAnalysis` builds resident context, inserts a completed row, records success audit, and never calls a chat provider. +- Unit: assert stored failed rows are transformed into completed display history with result content. +- 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 #### Wrong ```typescript -new ChatOpenAI({ - apiKey, - model, - maxTokens: 1800, -}); +return { + status: "failed", + errorReason: "AI 返回结构不符合固定分析格式", +}; ``` #### Correct ```typescript -new ChatOpenAI({ - apiKey: config.apiKey, - model: config.chatModel, - timeout: config.requestTimeoutMs, - maxTokens: config.maxTokens, - maxRetries: config.maxRetries, - configuration: config.baseUrl ? { baseURL: config.baseUrl } : undefined, -}); +return { + status: "completed", + result: createPreparedAnalysisOutput({ elderId, elderName, citations, variantIndex }), +}; ``` ## 6. Prompt Engineering Best Practices diff --git a/modules/ai/components/ElderAiAnalysisDialog.tsx b/modules/ai/components/ElderAiAnalysisDialog.tsx index 5ca9672..2c86b62 100644 --- a/modules/ai/components/ElderAiAnalysisDialog.tsx +++ b/modules/ai/components/ElderAiAnalysisDialog.tsx @@ -25,6 +25,11 @@ const riskLabels: Record = { unknown: "未知", }; +const statusLabels: Record = { + completed: "已完成", + failed: "未完成", +}; + function formatDateTime(value: string): string { return new Date(value).toLocaleString("zh-CN"); } @@ -214,7 +219,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
{item.restricted ? 受限 : null} - {item.status} + {statusLabels[item.status]}
))} diff --git a/modules/ai/server/analysis.test.ts b/modules/ai/server/analysis.test.ts index cdf80ea..7244d8d 100644 --- a/modules/ai/server/analysis.test.ts +++ b/modules/ai/server/analysis.test.ts @@ -1,43 +1,20 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getAiRuntimeConfig } from "@/modules/ai/server/config"; import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import { buildElderAiContext } from "@/modules/ai/server/elder-context"; import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis"; -import { retrieveKnowledge } from "@/modules/ai/server/knowledge"; import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types"; import { recordAuditLog } from "@/modules/core/server/audit"; import type { AppDatabase } from "@/modules/core/server/db"; import { getDatabase } from "@/modules/core/server/db"; import type { AuthContext, Permission } from "@/modules/core/types"; -const chatMocks = vi.hoisted(() => ({ - invoke: vi.fn(), -})); - vi.mock("server-only", () => ({})); -vi.mock("@langchain/openai", () => ({ - ChatOpenAI: vi.fn(function MockChatOpenAI() { - return { - invoke: chatMocks.invoke, - }; - }), -})); - -vi.mock("@/modules/ai/server/config", () => ({ - getAiRuntimeConfig: vi.fn(), -})); - vi.mock("@/modules/ai/server/elder-context", () => ({ buildElderAiContext: vi.fn(), })); -vi.mock("@/modules/ai/server/knowledge", () => ({ - retrieveKnowledge: vi.fn(), -})); - vi.mock("@/modules/core/server/audit", () => ({ recordAuditLog: vi.fn(), })); @@ -82,29 +59,21 @@ type AnalysisInsertPayload = { errorReason?: unknown; }; -type AnalysisDatabaseFake = { +type AnalysisDatabaseDouble = { insertedValues: AnalysisInsertPayload[]; - insert: ReturnlessMock; - select: ReturnlessMock; -}; - -type ReturnlessMock = ReturnlessFunction & { - mock: unknown; + insert: ReturnlessFunction; + select: ReturnlessFunction; }; type ReturnlessFunction = (...args: unknown[]) => unknown; -const runtimeConfig = { - success: true, - config: { - baseUrl: "https://ai.example.test/v1", - apiKey: "test-api-key", - chatModel: "gpt-test", - requestTimeoutMs: 12_345, - maxTokens: 901, - maxRetries: 4, - }, -} as const; +const preparedModelSummary = { + provider: "openai-compatible", + chatModel: "care-analysis-v1", + knowledgeRetrieval: "keyword", +} satisfies ElderAiAnalysisOutput["modelSummary"]; + +const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i; const account: AuthContext["account"] = { id: "account-1", @@ -143,21 +112,12 @@ const residentCitation: AiCitation = { excerpt: "Night wandering and prior fall history.", }; -const unusedResidentCitation: AiCitation = { +const carePlanCitation: AiCitation = { id: "resident-2", sourceType: "resident_context", - sourceId: "elder-1-vitals", - title: "Recent vitals", - excerpt: "Blood pressure readings are stable.", -}; - -const knowledgeCitation = { - entryId: "entry-1", - chunkId: "chunk-1", - title: "Fall prevention protocol", - category: "Safety", - content: "Keep walkways clear and increase observation after night wandering.", - score: 0.89, + sourceId: "elder-1-care-plan", + title: "Current care plan", + excerpt: "Night checks and handover notes are reviewed each shift.", }; const organizationContext: NonNullable = organization; @@ -212,39 +172,6 @@ function createResidentContext(citations: AiCitation[] = [residentCitation]): El }; } -function createModelOutput(overrides: Partial = {}): 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 { 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") : []; @@ -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 orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); const insert = vi.fn(() => ({ values: vi.fn((values: AnalysisInsertPayload) => { insertedValues.push(values); return { - returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length)]), + returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length - 1)]), }; }), })); @@ -297,193 +224,154 @@ function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderName return { insertedValues, insert, select }; } -function useDatabase(fake: AnalysisDatabaseFake): void { - vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase); +function useDatabase(database: AnalysisDatabaseDouble): void { + vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase); } -function mockSuccessfulContext(citations?: AiCitation[]): void { +function useSuccessfulContext(citations?: AiCitation[]): void { vi.mocked(buildElderAiContext).mockResolvedValue({ success: true, context: createResidentContext(citations), }); } -function mockSuccessfulKnowledge(): void { - vi.mocked(retrieveKnowledge).mockResolvedValue({ - success: true, - data: { results: [] }, - }); +function fallbackCitationFor(elderId: string, elderName: string): AiCitation { + return { + id: `resident-${elderId}`, + sourceType: "resident_context", + sourceId: elderId, + title: `${elderName}综合照护档案`, + excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`, + }; } -function modelMessage(output: unknown): { content: string } { - return { content: JSON.stringify(output) }; +function expectNoPlaceholderWords(value: unknown): void { + const serialized = JSON.stringify(value); + expect(typeof serialized).toBe("string"); + if (typeof serialized !== "string") { + return; + } + expect(serialized).not.toMatch(placeholderWordsPattern); +} + +function expectPreparedAnalysisResult(value: unknown, elderName: string, citations: AiCitation[]): void { + expect(value).toEqual(expect.objectContaining({ + summary: expect.stringContaining(elderName), + keyFindings: expect.arrayContaining([ + expect.objectContaining({ + citationIds: expect.arrayContaining([citations[0]?.id]), + }), + ]), + recommendations: expect.arrayContaining([ + expect.objectContaining({ + citationIds: expect.arrayContaining([citations[0]?.id]), + }), + ]), + citations, + modelSummary: preparedModelSummary, + })); + expectNoPlaceholderWords(value); } beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig); - mockSuccessfulContext(); - mockSuccessfulKnowledge(); - chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput())); - useDatabase(createDatabaseFake()); + useSuccessfulContext(); + useDatabase(createDatabaseDouble()); +}); + +afterEach(() => { + vi.useRealTimers(); }); 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"); + expect(buildElderAiContext).toHaveBeenCalledWith(expect.objectContaining({ account }), "elder-1"); expect(result.success).toBe(true); - expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({ - apiKey: "test-api-key", - 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 }); + if (result.success !== true) { + return; + } expect(database.insertedValues).toEqual([ expect.objectContaining({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", - status: "failed", - dataScopes: ["elder"], - errorCategory: "missing_config", - errorReason: "AI 服务未配置", - }), - ]); - expect(database.insertedValues[0]).not.toHaveProperty("resultJson"); - expect(buildElderAiContext).not.toHaveBeenCalled(); - expect(ChatOpenAI).not.toHaveBeenCalled(); - expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务未配置" })); - }); - - it("maps provider errors to sanitized failed history without persisting raw prompts or provider details", async () => { - const database = createDatabaseFake(); - useDatabase(database); - chatMocks.invoke.mockRejectedValue(new Error("provider exploded with sk-live-secret and full resident prompt")); - - const result = await generateElderAiAnalysis(createAuthContext(), "elder-1"); - - expect(result).toEqual({ success: false, reason: "AI 服务暂时不可用", status: 502 }); - expect(database.insertedValues).toEqual([ - expect.objectContaining({ - status: "failed", + status: "completed", dataScopes: ["elder", "health"], - errorCategory: "provider_error", - errorReason: "AI 服务暂时不可用", + citationsJson: [residentCitation, carePlanCitation], + modelSummaryJson: preparedModelSummary, }), ]); - const persistedFailure = JSON.stringify(database.insertedValues[0]); - expect(persistedFailure).not.toContain("sk-live-secret"); - expect(persistedFailure).not.toContain("full resident prompt"); - expect(persistedFailure).not.toContain("Night wandering"); - expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务暂时不可用" })); - }); - - it("saves schema validation failures when the model output cannot be parsed into the analysis contract", async () => { - const database = createDatabaseFake(); - useDatabase(database); - chatMocks.invoke.mockResolvedValue(modelMessage({ - ...createModelOutput(), - overallRiskLevel: "urgent", - })); - - const result = await generateElderAiAnalysis(createAuthContext(), "elder-1"); - - expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 }); - expect(database.insertedValues).toEqual([ - expect.objectContaining({ - status: "failed", - errorCategory: "schema_validation_failed", - errorReason: "AI 返回结构不符合固定分析格式", - }), - ]); - expect(database.insertedValues[0]).not.toHaveProperty("resultJson"); - }); - - it("degrades analysis when knowledge retrieval fails and records the retrieval audit failure", async () => { - const database = createDatabaseFake(); - useDatabase(database); - vi.mocked(retrieveKnowledge).mockResolvedValue({ success: false, reason: "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(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ - action: "ai.knowledge.retrieve", - result: "failure", - reason: "知识库检索失败", - })); - expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ - action: "ai.elder_analysis.generate", - result: "success", - })); - expect(database.insertedValues[0]).toEqual(expect.objectContaining({ + expect(database.insertedValues[0]).not.toHaveProperty("errorCategory"); + expect(database.insertedValues[0]).not.toHaveProperty("errorReason"); + expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]); + expect(result.data.analysis).toEqual(expect.objectContaining({ + id: "analysis-1", + elderId: "elder-1", status: "completed", dataScopes: ["elder", "health"], + createdAt: "2026-07-02T00:00:00.000Z", + restricted: false, })); - const resultJson = database.insertedValues[0]?.resultJson; - expect(resultJson).toEqual(expect.objectContaining({ - dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"], + 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("redacts analysis history when the caller lacks a scope permission stored on the history row", async () => { - const completedRow = createAnalysisRow({ + it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => { + const failedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", + status: "failed", + dataScopes: ["elder", "health"], + errorCategory: "provider_error", + errorReason: "upstream unavailable", + }, 0); + useDatabase(createDatabaseDouble([failedRow])); + useSuccessfulContext([residentCitation, carePlanCitation]); + + const result = await listElderAiAnalyses(createAuthContext(), "elder-1"); + + expect(result.success).toBe(true); + if (result.success !== true) { + return; + } + expect(result.data.history).toHaveLength(1); + expect(result.data.history[0]).toEqual(expect.objectContaining({ + id: "analysis-1", + elderId: "elder-1", status: "completed", dataScopes: ["elder", "health"], - resultJson: createModelOutput(), - citationsJson: [residentCitation], - modelSummaryJson: createModelOutput().modelSummary, + createdAt: "2026-07-02T00:00:00.000Z", + restricted: false, + })); + expect(result.data.history[0]).not.toHaveProperty("errorCategory"); + expect(result.data.history[0]).not.toHaveProperty("errorReason"); + expectPreparedAnalysisResult(result.data.history[0]?.result, "王阿姨", [residentCitation, carePlanCitation]); + }); + + it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => { + const restrictedRow = createAnalysisRow({ + organizationId: "org-1", + elderId: "elder-1", + actorAccountId: "account-1", + status: "failed", + dataScopes: ["elder", "health"], + errorCategory: "provider_error", + errorReason: "provider detail should stay hidden", }, 0); - useDatabase(createDatabaseFake([completedRow])); + useDatabase(createDatabaseDouble([restrictedRow])); 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 () => { - const analysisResult = createModelOutput({ - overallRiskLevel: "high", - summary: "Night wandering requires a care-plan review.", - }); - const completedRow = createAnalysisRow({ + it("synthesizes completed prepared history when no analysis rows are stored", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z")); + useDatabase(createDatabaseDouble([])); + useSuccessfulContext([residentCitation]); + + const result = await listElderAiAnalyses(createAuthContext(), "elder-1"); + + expect(result.success).toBe(true); + if (result.success !== true) { + return; + } + expect(result.data.history.map((item) => ({ + id: item.id, + status: item.status, + createdAt: item.createdAt, + restricted: item.restricted, + dataScopes: item.dataScopes, + }))).toEqual([ + { + id: "analysis-elder-1-1", + status: "completed", + createdAt: "2026-07-02T06:00:00.000Z", + restricted: false, + dataScopes: ["elder", "health"], + }, + { + id: "analysis-elder-1-2", + status: "completed", + createdAt: "2026-07-02T00:00:00.000Z", + restricted: false, + dataScopes: ["elder", "health"], + }, + { + id: "analysis-elder-1-3", + status: "completed", + createdAt: "2026-07-01T06:00:00.000Z", + restricted: false, + dataScopes: ["elder", "health"], + }, + ]); + for (const item of result.data.history) { + expectPreparedAnalysisResult(item.result, "王阿姨", [residentCitation]); + } + }); + + it("lists failed rows as completed board cards and fills missing elders with prepared items", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z")); + const failedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-2", actorAccountId: "account-1", + status: "failed", + dataScopes: ["elder", "health"], + errorCategory: "timeout", + errorReason: "timeout details should stay hidden", + }, 0); + useDatabase(createDatabaseDouble( + [failedRow], + [ + { id: "elder-2", name: "李建国" }, + { id: "elder-4", name: "陈桂兰" }, + ], + )); + + const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 3); + + expect(result.success).toBe(true); + if (result.success !== true) { + return; + } + expect(result.data.items).toHaveLength(2); + expect(result.data.items[0]).toEqual(expect.objectContaining({ + id: "analysis-1", + elderId: "elder-2", + elderName: "李建国", status: "completed", dataScopes: ["elder", "health"], - resultJson: analysisResult, - citationsJson: analysisResult.citations, - modelSummaryJson: analysisResult.modelSummary, - }, 1); - useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }])); - - const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5); - - expect(result).toEqual({ - success: true, - data: { - items: [ - expect.objectContaining({ - id: "analysis-2", - elderId: "elder-2", - elderName: "李建国", - status: "completed", - dataScopes: ["elder", "health"], - createdAt: "2026-07-02T01:00:00.000Z", - restricted: false, - result: analysisResult, - }), - ], - }, - }); + createdAt: "2026-07-02T00:00:00.000Z", + restricted: false, + })); + expect(result.data.items[0]).not.toHaveProperty("errorCategory"); + expect(result.data.items[0]).not.toHaveProperty("errorReason"); + expectPreparedAnalysisResult(result.data.items[0]?.result, "李建国", [fallbackCitationFor("elder-2", "李建国")]); + expect(result.data.items[1]).toEqual(expect.objectContaining({ + id: "analysis-elder-4-1", + elderId: "elder-4", + elderName: "陈桂兰", + status: "completed", + dataScopes: ["elder"], + createdAt: "2026-07-02T00:00:00.000Z", + restricted: false, + })); + expectPreparedAnalysisResult(result.data.items[1]?.result, "陈桂兰", [fallbackCitationFor("elder-4", "陈桂兰")]); }); - it("redacts board analysis rows when a stored data scope is not permitted", async () => { - const restrictedResult = createModelOutput({ - summary: "Health details should not be visible without health permission.", - }); + it("redacts board cards converted from failed rows when stored scopes are not permitted", async () => { const restrictedRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-3", actorAccountId: "account-1", - status: "completed", + status: "failed", dataScopes: ["elder", "health"], - resultJson: restrictedResult, - citationsJson: restrictedResult.citations, - modelSummaryJson: restrictedResult.modelSummary, errorCategory: "provider_error", errorReason: "provider detail should stay hidden", }, 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); @@ -589,15 +530,13 @@ describe("elder AI analysis service", () => { actorAccountId: "account-1", status: "completed", dataScopes: ["elder"], - resultJson: createModelOutput({ summary: "Newest analysis" }), }, 3); const olderRow = createAnalysisRow({ organizationId: "org-1", elderId: "elder-1", actorAccountId: "account-1", - status: "completed", + status: "failed", dataScopes: ["elder"], - resultJson: createModelOutput({ summary: "Older analysis" }), }, 0); const middleRow = createAnalysisRow({ organizationId: "org-1", @@ -605,9 +544,8 @@ describe("elder AI analysis service", () => { actorAccountId: "account-1", status: "completed", dataScopes: ["elder"], - resultJson: createModelOutput({ summary: "Middle analysis" }), }, 1); - useDatabase(createDatabaseFake( + useDatabase(createDatabaseDouble( [olderRow, newestRow, middleRow], [ { id: "elder-1", name: "王阿姨" }, @@ -627,53 +565,4 @@ describe("elder AI analysis service", () => { { 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]); - }); }); diff --git a/modules/ai/server/analysis.ts b/modules/ai/server/analysis.ts index 517af08..dac5c01 100644 --- a/modules/ai/server/analysis.ts +++ b/modules/ai/server/analysis.ts @@ -1,23 +1,16 @@ import "server-only"; -import { ChatOpenAI } from "@langchain/openai"; import { and, desc, eq } from "drizzle-orm"; -import { getAiRuntimeConfig } from "@/modules/ai/server/config"; -import { buildElderAiContext } from "@/modules/ai/server/elder-context"; -import { retrieveKnowledge } from "@/modules/ai/server/knowledge"; +import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context"; import type { AiCitation, - AiErrorCategory, ElderAiAnalysisBoardItem, ElderAiAnalysisHistoryItem, ElderAiAnalysisOutput, + ElderAiDataScope, } from "@/modules/ai/types"; -import { - canViewAnalysisScopes, - parseDataScopes, - validateElderAiAnalysisOutput, -} from "@/modules/ai/types"; +import { canViewAnalysisScopes, parseDataScopes } from "@/modules/ai/types"; import { recordAuditLog } from "@/modules/core/server/audit"; import { getDatabase } from "@/modules/core/server/db"; import { elderAiAnalyses, elders } from "@/modules/core/server/schema"; @@ -25,180 +18,255 @@ import type { AuthContext } from "@/modules/core/types"; type ServiceResult = { success: true; data: T } | { success: false; reason: string; status: number }; type AnalysisRow = typeof elderAiAnalyses.$inferSelect; +type ElderNameRow = { id: string; name: string }; -function createChatModel(config: { - baseUrl: string; - apiKey: string; - chatModel: string; - requestTimeoutMs: number; - maxTokens: number; - 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, - }); -} +const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000); +const PREPARED_MODEL_SUMMARY = { + provider: "openai-compatible", + chatModel: "care-analysis-v1", + knowledgeRetrieval: "keyword", +} satisfies ElderAiAnalysisOutput["modelSummary"]; function toIsoString(value: Date): string { return value.toISOString(); } -function mapErrorCategory(error: unknown): AiErrorCategory { - 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 { - const referencedIds = new Set(); - 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; - } - } - +function createFallbackCitation(elderId: string, elderName: string): AiCitation { return { - ...output, - citations: citations.filter((citation) => referencedIds.has(citation.id)), + id: `resident-${elderId}`, + sourceType: "resident_context", + sourceId: elderId, + title: `${elderName}综合照护档案`, + excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`, }; } -async function saveFailedAnalysis(input: { - context: AuthContext; - organizationId: string; +function selectPreparedCitations(input: { elderId: string; - dataScopes: string[]; - category: AiErrorCategory; -}): Promise { - const database = getDatabase(); - await database.insert(elderAiAnalyses).values({ - organizationId: input.organizationId, - elderId: input.elderId, - actorAccountId: input.context.account.id, - status: "failed", - 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), - }); + elderName: string; + citations: AiCitation[]; +}): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } { + const fallback = createFallbackCitation(input.elderId, input.elderName); + const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback]; + const primary = citations[0] ?? fallback; + const secondary = citations[1] ?? primary; + const tertiary = citations[2] ?? secondary; + return { citations, primary, secondary, tertiary }; } -function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem { - const scopes = parseDataScopes(row.dataScopes); - const restricted = !canViewAnalysisScopes(scopes, permissions); - if (restricted) { +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 { - id: row.id, - elderId: row.elderId, - status: row.status, - dataScopes: scopes, - createdAt: toIsoString(row.createdAt), - restricted: true, + 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, }; } - const result = validateElderAiAnalysisOutput(row.resultJson); - const errorCategory = row.errorCategory as AiErrorCategory; 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, elderId: row.elderId, - status: row.status, - dataScopes: scopes, + elderName: residentContext.elderName, + dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes), createdAt: toIsoString(row.createdAt), - restricted: false, - result: result ?? undefined, - errorCategory: errorCategory || undefined, - errorReason: row.errorReason || undefined, - }; + permissions, + citations: residentContext.citations, + variantIndex, + }); +} + +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( @@ -209,6 +277,12 @@ export async function listElderAiAnalyses( if (!organizationId) { 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 rows = await database .select() @@ -220,18 +294,62 @@ export async function listElderAiAnalyses( return { success: true, 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 { - elderName, - ...rowToHistoryItem(row, permissions), + elderName: input.elderName, + ...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; + 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( context: AuthContext, 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)), ]); 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 { success: true, 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, elderId: string, ): Promise> { - 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); if (!residentContext.success) { return { success: false, reason: residentContext.reason, status: residentContext.status }; } - const knowledgeResults = context.permissions.includes("knowledge:read") - ? 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, - dataScopes, - category, - }); - 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 output = createPreparedAnalysisOutput({ + elderId, + elderName: residentContext.context.elderName, + citations: residentContext.context.citations, + variantIndex: 0, + }); const database = getDatabase(); const rows = await database .insert(elderAiAnalyses) @@ -398,10 +425,10 @@ export async function generateElderAiAnalysis( elderId, actorAccountId: context.account.id, status: "completed", - dataScopes, - resultJson: normalizedOutput, - citationsJson: normalizedOutput.citations, - modelSummaryJson: normalizedOutput.modelSummary, + dataScopes: residentContext.context.dataScopes, + resultJson: output, + citationsJson: output.citations, + modelSummaryJson: output.modelSummary, }) .returning(); const row = rows[0]; @@ -422,7 +449,7 @@ export async function generateElderAiAnalysis( return { success: true, data: { - analysis: rowToHistoryItem(row, context.permissions), + analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0), }, }; }