Compare commits
7 Commits
6aa72d3b43
...
f74b7f3ca0
| Author | SHA1 | Date | |
|---|---|---|---|
| f74b7f3ca0 | |||
| 0d5093ac6c | |||
| ae561a7d45 | |||
| 6ed7508983 | |||
| b586756226 | |||
| c3cf1d49cc | |||
| e204974b57 |
@@ -349,3 +349,110 @@ GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## Scenario: Elder AI Analysis MVP With LangChain And PostgreSQL JSONB Embeddings
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: AI features that inspect resident, care, health, family, admission, alert, incident, or knowledge data.
|
||||
- Current project contract: the elder analysis MVP uses LangChain on the server with an OpenAI-compatible provider, not the Vercel AI SDK runtime path.
|
||||
- Keep provider calls under `modules/ai/server/*`; feature modules and API routes must not instantiate model clients directly.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- API:
|
||||
- `GET /api/ai/elders/[id]/analyses`
|
||||
- `POST /api/ai/elders/[id]/analyses`
|
||||
- `GET /api/ai/knowledge`
|
||||
- `POST /api/ai/knowledge`
|
||||
- `PATCH /api/ai/knowledge/[id]`
|
||||
- `DELETE /api/ai/knowledge/[id]`
|
||||
- DB:
|
||||
- `ai_knowledge_entries`
|
||||
- `ai_knowledge_chunks` with `jsonb` `embedding` arrays
|
||||
- `elder_ai_analyses`
|
||||
- Runtime env:
|
||||
- `AI_API_KEY` required for generation and embedding.
|
||||
- `AI_BASE_URL` optional OpenAI-compatible endpoint.
|
||||
- `AI_CHAT_MODEL` optional, defaults in `modules/ai/server/config.ts`.
|
||||
- `AI_EMBEDDING_MODEL` optional, defaults in `modules/ai/server/config.ts`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- All API responses use the project API shape: `{ success: boolean, reason: string, ...payload }`.
|
||||
- Knowledge entry input fields:
|
||||
- `scope`: `"platform" | "organization"`
|
||||
- `title`: non-empty string
|
||||
- `category`: string
|
||||
- `tags`: string
|
||||
- `body`: non-empty string
|
||||
- `status`: `"enabled" | "disabled"`
|
||||
- Elder analysis history stores:
|
||||
- `status`: `"completed" | "failed"`
|
||||
- `dataScopes`: JSON array of included families
|
||||
- `resultJson`: only for completed rows
|
||||
- `errorCategory` / `errorReason`: sanitized only for failed rows
|
||||
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
|
||||
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
|
||||
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then computes cosine similarity in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension.
|
||||
- Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing `ai:read` -> `403 权限不足`.
|
||||
- Missing `elder:read` -> `403 权限不足`.
|
||||
- Missing active organization for elder analysis -> `400`.
|
||||
- Target elder outside active organization -> `404`.
|
||||
- Missing `knowledge:read` -> knowledge scope is omitted from analysis context.
|
||||
- Missing `knowledge:manage` -> knowledge mutation routes return `403`.
|
||||
- Platform knowledge mutation without `platform:manage` -> `403`.
|
||||
- Missing `AI_API_KEY` -> save failed analysis with `missing_config`, return structured failure.
|
||||
- Provider failure -> save failed analysis with `provider_error` or `timeout`.
|
||||
- Invalid model output -> save failed analysis with `schema_validation_failed`.
|
||||
- Unknown citation IDs in findings or recommendations -> save failed analysis with `schema_validation_failed`, return structured failure.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: org manager generates elder analysis; context includes only data families allowed by the manager's current permissions; history stores matching `dataScopes`.
|
||||
- Base: caregiver generates analysis; caregiver gets `ai:read` and `knowledge:read` by default, but cannot mutate knowledge.
|
||||
- Bad: viewer has `elder:read` but no `ai:read`; the AI row action must not be exposed and API must return `403`.
|
||||
- Bad: organization user attempts to retrieve another organization's private knowledge; the retrieval query must not include those chunks.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Permission seeding assertions for `ai:*` and `knowledge:*` default role grants.
|
||||
- Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, and cosine-score ordering from JSONB embeddings.
|
||||
- Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction.
|
||||
- Analysis tests for unknown citation rejection and referenced-only citation persistence.
|
||||
- Route tests for auth/permission failures and standard response shape.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```typescript
|
||||
// Do not call a model from a feature route and pass raw resident rows directly.
|
||||
const model = new ChatOpenAI({ apiKey: process.env.AI_API_KEY });
|
||||
await model.invoke(JSON.stringify(residentRows));
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```typescript
|
||||
// Keep model calls in modules/ai/server and persist only citations that findings/recommendations reference.
|
||||
const result = await generateElderAiAnalysis(auth.context, elderId);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
return jsonSuccess("AI 分析已生成", { analysis: result.data.analysis }, 201);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Good evidence chain: every cited source ID is from the allowed resident/knowledge citations.
|
||||
const finding = {
|
||||
category: "跌倒风险",
|
||||
severity: "warning",
|
||||
evidence: "夜间徘徊后需要加强通道清理和巡视。",
|
||||
citationIds: ["resident-1", "kb-1"],
|
||||
};
|
||||
```
|
||||
|
||||
@@ -244,6 +244,79 @@ if (type === "tool-output-available") {
|
||||
}
|
||||
```
|
||||
|
||||
## Scenario: Elder AI Analysis Panel MVP
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: UI surfaces that render elder AI analysis, knowledge retrieval results, or AI history.
|
||||
- Current project contract: the MVP elder workflow is a fixed structured analysis panel, not chat and not streaming.
|
||||
- Do not use `@ai-sdk/react` hooks for this MVP panel; use the existing project API result shape through fetch until a generated client exists for these routes.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Elder row action:
|
||||
- Visible only when current permissions include both `ai:read` and `elder:read`.
|
||||
- Opens `ElderAiAnalysisDialog` with `width="wide"`.
|
||||
- Client APIs:
|
||||
- `GET /api/ai/elders/[id]/analyses` -> `{ success, reason, history }`
|
||||
- `POST /api/ai/elders/[id]/analyses` -> `{ success, reason, analysis }`
|
||||
- `GET /api/ai/knowledge` -> `{ success, reason, entries }`
|
||||
- Knowledge mutations -> `{ success, reason, entry }`
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Render completed analysis from fixed fields:
|
||||
- `overallRiskLevel`
|
||||
- `summary`
|
||||
- `keyFindings[]`
|
||||
- `recommendations[]`
|
||||
- `dataGaps[]`
|
||||
- `citations[]`
|
||||
- `confidence`
|
||||
- `modelSummary`
|
||||
- Render `restricted: true` history items as placeholders only; do not assume `result` or citations exist.
|
||||
- Render failed history from sanitized `errorCategory` / `errorReason`; never display raw provider errors.
|
||||
- The MVP UI must not provide one-click business mutations or confirmable drafts from recommendations.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing `ai:read` or `elder:read` -> hide row action.
|
||||
- API failure -> show `reason` in the dialog or settings page message area.
|
||||
- `restricted: true` history -> show an access-restricted placeholder.
|
||||
- `status === "failed"` -> show failed badge and sanitized reason.
|
||||
- Empty history -> show empty state.
|
||||
- Generation pending -> disable generate button and show loading label.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: authorized manager opens dialog, generates analysis, sees latest completed result, citations, data gaps, and history list.
|
||||
- Base: generation fails because provider is not configured; user sees a structured failure message and failed history remains visible.
|
||||
- Bad: viewer without `ai:read` sees an `AI 分析` row action.
|
||||
- Bad: frontend renders recommendations as buttons that create care tasks, alert handling notes, or health review records.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Component or route smoke coverage for hidden AI action when permissions are missing.
|
||||
- History rendering coverage for completed, failed, restricted, and empty states.
|
||||
- Knowledge settings coverage for read-only users and `knowledge:manage` users.
|
||||
- Build check must include both unscoped `/app/settings/knowledge` and scoped `/app/[organizationSlug]/settings/knowledge`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```tsx
|
||||
// Do not expose an AI action based only on elder read permission.
|
||||
{permissions.includes("elder:read") ? <button>AI 分析</button> : null}
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```tsx
|
||||
const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
|
||||
{canUseAi ? <Button onClick={() => setAiTarget(elder)}>AI 分析</Button> : null}
|
||||
```
|
||||
|
||||
## 7. Best Practices Summary
|
||||
|
||||
| Rule | Description |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
74
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/design.md
Normal file
74
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/design.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Harden AI Analysis Path Design
|
||||
|
||||
## Overview
|
||||
|
||||
This change is an in-place hardening pass over the existing AI MVP. It keeps the current module boundaries and synchronous workflow, then adds tests and narrows citation behavior.
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Backend AI module
|
||||
|
||||
- `modules/ai/types.ts`
|
||||
- Keep runtime validators as the source of truth for output shape.
|
||||
- Add/adjust helpers only if needed for citation ID validation.
|
||||
- `modules/ai/server/analysis.ts`
|
||||
- Validate model output against the allowed citation set before persisting a completed history row.
|
||||
- Derive final persisted/display citations from citation IDs actually referenced by findings/recommendations.
|
||||
- Keep all provider and validation failures sanitized.
|
||||
- `modules/ai/server/knowledge.ts`
|
||||
- Preserve current save-time embedding and scope-filtered retrieval behavior.
|
||||
- Add tests around existing behavior rather than introducing background embedding state.
|
||||
|
||||
### API routes
|
||||
|
||||
- `app/api/ai/elders/[id]/analyses/route.ts`
|
||||
- `app/api/ai/knowledge/route.ts`
|
||||
- `app/api/ai/knowledge/[id]/route.ts`
|
||||
|
||||
Route behavior stays unchanged except tests protect permission ordering, structured failures, and audit behavior.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `modules/ai/components/KnowledgeManagementClient.tsx`
|
||||
- Make AI configuration / vector generation failures more explicit.
|
||||
- `modules/ai/components/ElderAiAnalysisDialog.tsx`
|
||||
- Make failed latest history state clearer without exposing raw errors.
|
||||
|
||||
### Permissions
|
||||
|
||||
- `modules/core/server/permissions.ts`
|
||||
- Clarify `ai:manage` as reserved/currently governance-oriented wording only.
|
||||
|
||||
## Citation Contract
|
||||
|
||||
Allowed citations are built by `buildElderAiContext` plus knowledge retrieval. The model may reference only these IDs.
|
||||
|
||||
Final persisted `result.citations` must be derived from actual references:
|
||||
|
||||
1. Collect citation IDs from all `keyFindings[].citationIds` and `recommendations[].citationIds`.
|
||||
2. If any referenced ID is absent from the allowed citation map, reject the output as `schema_validation_failed`.
|
||||
3. Persist/display only the allowed citations whose IDs were referenced.
|
||||
4. Preserve stable ordering from the original allowed citation list.
|
||||
|
||||
This avoids showing unused evidence and prevents hallucinated source IDs.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
Tests should mock database/provider boundaries and assert observable contracts, not implementation details.
|
||||
|
||||
- Validator tests use pure functions.
|
||||
- Knowledge tests mock `getDatabase` and `OpenAIEmbeddings` or use light fake query builders where practical.
|
||||
- Analysis tests mock config, elder context, knowledge retrieval, model invocation, database insert, and audit logging.
|
||||
- Route tests mock `requirePermission`, AI services, validation as needed, and audit logging.
|
||||
|
||||
## Compatibility
|
||||
|
||||
No new dependency is required.
|
||||
Deployment hardening additionally changes the still-unapplied `0007` AI migration from pgvector to JSONB embeddings, so production PostgreSQL 18 Alpine can run the migration without a `vector` extension.
|
||||
Existing persisted `resultJson.citations` remain readable; new analyses will have stricter citation lists.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Revert citation strictness if a provider cannot satisfy citation references, but keep tests documenting expected loosened behavior.
|
||||
- Revert UI message changes independently if copy causes product concern.
|
||||
- Tests are additive and can stay even if implementation is adjusted.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Harden AI Analysis Path Implementation Plan
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add pure validator tests for `modules/ai/types.ts`.
|
||||
2. Add/adjust analysis citation helper behavior in `modules/ai/server/analysis.ts`.
|
||||
3. Add analysis service tests for missing config, invalid output, provider errors, retrieval degradation, and redaction.
|
||||
4. Add knowledge service tests for scope, disabled entries, organization isolation, and embedding failure.
|
||||
5. Add AI route tests for permission failures, invalid input, service failure propagation, success responses, and audit calls.
|
||||
6. Improve knowledge UI and analysis dialog failure copy.
|
||||
7. Clarify `ai:manage` permission description.
|
||||
8. Align AI knowledge embedding storage to JSONB for production PostgreSQL without pgvector.
|
||||
9. Run targeted AI tests, then project verification.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
- `pnpm test -- modules/ai/types.test.ts`
|
||||
- `pnpm test -- modules/ai/server/analysis.test.ts modules/ai/server/knowledge.test.ts app/api/ai/ai-routes.test.ts`
|
||||
- `pnpm lint`
|
||||
- `pnpm type-check`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
|
||||
## Risky Files
|
||||
|
||||
- `modules/ai/server/analysis.ts`
|
||||
- `modules/ai/server/knowledge.ts`
|
||||
- `modules/ai/types.ts`
|
||||
- `app/api/ai/*/route.ts`
|
||||
- `modules/core/server/permissions.ts`
|
||||
|
||||
## Review Gates
|
||||
|
||||
- Citation helper must reject unknown citation IDs and avoid appending unused citations.
|
||||
- Tests must not call real AI providers.
|
||||
- Tests must not require a live PostgreSQL instance unless explicitly scoped to migration smoke testing.
|
||||
- No new `any`, non-null assertions, `@ts-ignore`, or `@ts-expect-error`.
|
||||
- Failed history records must remain sanitized.
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- After citation strictness: revert helper and related tests if provider output compatibility proves too brittle.
|
||||
- After route tests: routes should remain behavior-compatible except status/copy assertions.
|
||||
- After UI copy: copy changes can be reverted without affecting backend contracts.
|
||||
49
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/prd.md
Normal file
49
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/prd.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Harden AI Analysis Path PRD
|
||||
|
||||
## Goal
|
||||
|
||||
Harden the existing elder AI analysis and knowledge retrieval MVP so the critical safety boundaries are covered by tests and the displayed evidence chain matches the citations actually used by the model output.
|
||||
|
||||
## Background
|
||||
|
||||
The current MVP now implements LangChain-backed elder AI analysis, PostgreSQL JSONB-backed knowledge retrieval, permission-scoped resident context, persisted analysis history, and knowledge management UI.
|
||||
|
||||
Current evidence from repository inspection:
|
||||
|
||||
- AI service files live under `modules/ai/`.
|
||||
- API routes live under `app/api/ai/`.
|
||||
- AI tables and JSONB embedding storage are in `modules/core/server/schema.ts` and `drizzle/0007_purple_puff_adder.sql`; the production target does not require PostgreSQL `vector` extension support.
|
||||
- Existing project verification passes: `pnpm lint`, `pnpm type-check`, and `pnpm test`.
|
||||
- No AI-specific test file currently covers schema validation, permission scoping, history redaction, knowledge organization isolation, disabled knowledge exclusion, or provider failure behavior.
|
||||
- `normalizeCitations` currently appends allowed citations that the model did not necessarily cite, weakening the evidence chain.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Add focused automated tests for AI validators, knowledge service behavior, analysis service behavior, and AI route authorization/response behavior.
|
||||
2. Tighten citation handling so the persisted/rendered `citations` list is derived from citation IDs actually referenced by findings and recommendations.
|
||||
3. Reject AI outputs whose finding or recommendation `citationIds` include IDs not present in the allowed context/knowledge citation set.
|
||||
4. Keep failed analysis history sanitized: no full prompt, resident context, API key, or raw provider error.
|
||||
5. Preserve the existing synchronous generation MVP behavior; do not introduce queues, streaming, background jobs, new dependencies, or a new vector store.
|
||||
6. Improve user-facing failure text where configuration/vector generation failure would otherwise look like a generic save/generation failure.
|
||||
7. Clarify `ai:manage` as a reserved/future governance permission instead of implying a currently implemented management surface.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `validateKnowledgeEntryInput`, `validateElderAiAnalysisOutput`, and `parseDataScopes` have direct unit coverage for valid and invalid edge cases.
|
||||
- Knowledge tests cover writable scope failures, disabled entry exclusion, platform/current-organization visibility, other-organization exclusion, and embedding failure behavior without hitting a real provider.
|
||||
- Analysis tests cover missing config, provider error, schema validation failure, retrieval degradation, and history redaction without hitting a real provider.
|
||||
- AI route tests cover missing `ai:read`, missing `elder:read`, missing `knowledge:read`, missing `knowledge:manage`, invalid request body, service failure status propagation, and success audit behavior.
|
||||
- Citation normalization no longer appends unused allowed citations.
|
||||
- Any unknown citation ID in findings/recommendations causes a structured schema validation failure and failed history record.
|
||||
- Knowledge UI and AI analysis dialog keep sensitive details out of failure messages while making configuration/vector-generation failures understandable.
|
||||
- `ai:manage` permission description no longer overpromises current UI/API capability.
|
||||
- `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build` pass.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Async generation jobs.
|
||||
- Streaming AI responses.
|
||||
- AI usage billing/rate limiting.
|
||||
- AI configuration UI.
|
||||
- File upload/OCR/web crawling knowledge ingestion.
|
||||
- AI-generated business record drafts or one-click mutations.
|
||||
26
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/task.json
Normal file
26
.trellis/tasks/07-05-07-06-harden-ai-analysis-path/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "07-06-harden-ai-analysis-path",
|
||||
"name": "07-06-harden-ai-analysis-path",
|
||||
"title": "Harden AI Analysis Path",
|
||||
"description": "Add tests and tighten citation evidence chain for the AI knowledge analysis MVP.",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "TalexDreamSoul",
|
||||
"assignee": "TalexDreamSoul",
|
||||
"createdAt": "2026-07-05",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,395 @@
|
||||
# AI Agent Knowledge Analysis Design
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the MVP as a server-side LangChain AI module with PostgreSQL/pgvector-backed knowledge retrieval and a structured elder AI analysis panel in the existing elder list workflow.
|
||||
|
||||
The MVP is not a chat assistant. It is a deterministic product workflow:
|
||||
|
||||
1. Authorized user opens an elder's AI analysis dialog from the elder list.
|
||||
2. Server loads only the resident data families the user can read.
|
||||
3. Server retrieves enabled platform shared knowledge and enabled active-organization knowledge.
|
||||
4. LangChain generates a fixed structured analysis.
|
||||
5. Server saves completed or failed history, writes audit logs, and returns structured JSON.
|
||||
6. UI renders latest analysis, history, citations, recommendations, and restricted placeholders where permissions do not allow detail access.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
### New Module
|
||||
|
||||
Create `modules/ai/`:
|
||||
|
||||
- `modules/ai/types.ts`
|
||||
- Zod-compatible validation helpers or TypeScript runtime validators for knowledge entries, analysis output, history status, risk levels, citation shapes, and data scopes.
|
||||
- `modules/ai/server/config.ts`
|
||||
- Reads `AI_BASE_URL`, `AI_API_KEY`, `AI_CHAT_MODEL`, `AI_EMBEDDING_MODEL`, and optional timeout/limit settings.
|
||||
- `modules/ai/server/knowledge.ts`
|
||||
- CRUD for knowledge entries.
|
||||
- Chunking and embedding on save.
|
||||
- Scope-filtered retrieval from pgvector.
|
||||
- `modules/ai/server/elder-context.ts`
|
||||
- Builds full resident graph with permission-aware optional data families.
|
||||
- `modules/ai/server/analysis.ts`
|
||||
- Runs LangChain analysis flow.
|
||||
- Validates structured output.
|
||||
- Saves completed/failed history.
|
||||
- `modules/ai/components/KnowledgeManagementClient.tsx`
|
||||
- Settings UI for manual knowledge entries.
|
||||
- `modules/ai/components/ElderAiAnalysisDialog.tsx`
|
||||
- Elder row dialog panel for generation, latest analysis, history, and citations.
|
||||
|
||||
### Existing Integration Points
|
||||
|
||||
- `modules/core/server/schema.ts`
|
||||
- Add AI enums/tables.
|
||||
- `modules/core/types.ts`
|
||||
- Add permissions and exported AI-related shared types as needed.
|
||||
- `modules/core/server/permissions.ts`
|
||||
- Seed AI/knowledge permission definitions and default role grants.
|
||||
- `app/api/ai/...`
|
||||
- New API routes for knowledge and elder analysis.
|
||||
- `app/(app)/app/settings/knowledge/page.tsx`
|
||||
- New settings route.
|
||||
- `app/(app)/app/[organizationSlug]/settings/knowledge/page.tsx`
|
||||
- Scoped route re-export.
|
||||
- `modules/shared/lib/navigation.ts`
|
||||
- Add settings navigation item visible with `knowledge:read`.
|
||||
- `modules/elders/components/EldersClient.tsx`
|
||||
- Add per-row AI analysis action and dialog.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Add runtime dependencies:
|
||||
|
||||
- `@langchain/openai`
|
||||
- `@langchain/core`
|
||||
- `langchain`
|
||||
|
||||
Add pgvector support through Drizzle/Postgres:
|
||||
|
||||
- Use PostgreSQL `vector` extension.
|
||||
- Prefer a pgvector-capable Postgres image for local development instead of plain `postgres:17-alpine`.
|
||||
- If Drizzle vector column support is insufficient for the exact version, use custom SQL migration for vector extension/index while keeping typed table metadata in schema.
|
||||
|
||||
Vercel AI SDK remains out of the MVP runtime path.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Permissions
|
||||
|
||||
Add permission IDs:
|
||||
|
||||
- `ai:read`
|
||||
- `ai:manage`
|
||||
- `knowledge:read`
|
||||
- `knowledge:manage`
|
||||
|
||||
Default grants:
|
||||
|
||||
- `platform_admin`: all four.
|
||||
- `org_admin`: all four.
|
||||
- `manager`: all four.
|
||||
- `caregiver`: `ai:read`, `knowledge:read`.
|
||||
- `viewer`, `family`, `resident`: none.
|
||||
|
||||
### Knowledge Entries
|
||||
|
||||
Table: `ai_knowledge_entries`
|
||||
|
||||
Fields:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `scope enum('platform', 'organization')`
|
||||
- `organizationId uuid null references organizations(id) on delete cascade`
|
||||
- `title text not null`
|
||||
- `category text not null default ''`
|
||||
- `tags text not null default ''`
|
||||
- `body text not null`
|
||||
- `status enum('enabled', 'disabled') not null default 'enabled'`
|
||||
- `createdByAccountId uuid null references accounts(id)`
|
||||
- `updatedByAccountId uuid null references accounts(id)`
|
||||
- `createdAt timestamptz not null default now()`
|
||||
- `updatedAt timestamptz not null default now()`
|
||||
|
||||
Rules:
|
||||
|
||||
- `scope='platform'` requires `organizationId IS NULL`.
|
||||
- `scope='organization'` requires `organizationId` to match the active organization.
|
||||
- Platform entries require platform-level authorization to manage.
|
||||
- Organization entries require active organization plus `knowledge:manage`.
|
||||
|
||||
### Knowledge Chunks
|
||||
|
||||
Table: `ai_knowledge_chunks`
|
||||
|
||||
Fields:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `entryId uuid not null references ai_knowledge_entries(id) on delete cascade`
|
||||
- `organizationId uuid null`
|
||||
- `scope enum('platform', 'organization')`
|
||||
- `chunkIndex integer not null`
|
||||
- `content text not null`
|
||||
- `embedding vector(<embedding_dimensions>) not null`
|
||||
- `sourceTitle text not null`
|
||||
- `sourceCategory text not null default ''`
|
||||
- `createdAt timestamptz not null default now()`
|
||||
|
||||
Indexes:
|
||||
|
||||
- B-tree on `(scope, organization_id)`.
|
||||
- Vector index for embedding similarity, using pgvector operator/index supported by deployment.
|
||||
|
||||
Embedding dimensions are tied to `AI_EMBEDDING_MODEL`; document the chosen default in env configuration.
|
||||
|
||||
### Elder AI Analyses
|
||||
|
||||
Table: `elder_ai_analyses`
|
||||
|
||||
Fields:
|
||||
|
||||
- `id uuid primary key`
|
||||
- `organizationId uuid not null references organizations(id) on delete cascade`
|
||||
- `elderId uuid not null references elders(id) on delete cascade`
|
||||
- `actorAccountId uuid null references accounts(id)`
|
||||
- `status enum('completed', 'failed') not null`
|
||||
- `dataScopes text not null`
|
||||
- `resultJson jsonb null`
|
||||
- `citationsJson jsonb not null default '[]'::jsonb`
|
||||
- `modelSummaryJson jsonb not null default '{}'::jsonb`
|
||||
- `errorCategory text not null default ''`
|
||||
- `errorReason text not null default ''`
|
||||
- `createdAt timestamptz not null default now()`
|
||||
|
||||
Rules:
|
||||
|
||||
- Completed rows require a valid `resultJson`.
|
||||
- Failed rows must not store prompt, full resident context, or raw provider errors.
|
||||
- `dataScopes` stores a stable comma-separated list or JSON array; use JSONB if Drizzle ergonomics are acceptable.
|
||||
|
||||
## Authorization Model
|
||||
|
||||
### Generate Analysis
|
||||
|
||||
Required:
|
||||
|
||||
- Authenticated session.
|
||||
- Active organization.
|
||||
- `ai:read`.
|
||||
- `elder:read`.
|
||||
- Target elder belongs to active organization.
|
||||
|
||||
Optional data families:
|
||||
|
||||
- `health` only if `health:read`.
|
||||
- `care` only if `care:read`.
|
||||
- `family` only if `family:read`.
|
||||
- `admission` only if `admission:read`.
|
||||
- `facility` only if `facility:read`.
|
||||
- `alert` only if `alert:read`.
|
||||
- `incident` only if `incident:read`.
|
||||
- `knowledge` only if `knowledge:read`.
|
||||
|
||||
`dataScopes` records exactly which families were included.
|
||||
|
||||
### View Analysis History
|
||||
|
||||
History list can show safe metadata for rows in the active organization and elder when the user has `ai:read` and `elder:read`.
|
||||
|
||||
History detail requires all permissions implied by `dataScopes`. If missing any scope, return or render a restricted placeholder and do not include `resultJson`/citations content.
|
||||
|
||||
### Knowledge Retrieval
|
||||
|
||||
Retrieval requires `knowledge:read`.
|
||||
|
||||
Eligible entries:
|
||||
|
||||
- `scope='platform'`, `status='enabled'`.
|
||||
- `scope='organization'`, `organizationId = activeOrganizationId`, `status='enabled'`.
|
||||
|
||||
No organization-private chunks from other organizations may be retrievable.
|
||||
|
||||
## LangChain Flow
|
||||
|
||||
### Provider
|
||||
|
||||
Use OpenAI-compatible configuration:
|
||||
|
||||
- `AI_BASE_URL`
|
||||
- `AI_API_KEY`
|
||||
- `AI_CHAT_MODEL`
|
||||
- `AI_EMBEDDING_MODEL`
|
||||
|
||||
`config.ts` validates required settings before generation/embedding and returns structured failures for missing configuration.
|
||||
|
||||
### Retrieval
|
||||
|
||||
1. Convert resident query/context summary into embedding.
|
||||
2. Query `ai_knowledge_chunks` with pgvector similarity.
|
||||
3. Filter by allowed scopes before vector ranking.
|
||||
4. Return top K chunks with source metadata:
|
||||
- source type: `knowledge`
|
||||
- entry id
|
||||
- chunk id
|
||||
- title
|
||||
- category
|
||||
- scope
|
||||
|
||||
### Resident Context Assembly
|
||||
|
||||
Build one typed object:
|
||||
|
||||
- Elder basics.
|
||||
- Current bed/admission context.
|
||||
- Admission history.
|
||||
- Health profile.
|
||||
- Recent vitals.
|
||||
- Chronic conditions.
|
||||
- Health anomaly reviews.
|
||||
- Care tasks.
|
||||
- Alert triggers/rules where relevant.
|
||||
- System incidents matched by elder/bed where relevant.
|
||||
- Family contacts/visits/feedback.
|
||||
- Retrieved knowledge snippets.
|
||||
|
||||
Use batch queries and `Promise.all` where independent. No `await` in loops.
|
||||
|
||||
### Structured Output
|
||||
|
||||
Output schema:
|
||||
|
||||
- `overallRiskLevel`: `low | medium | high | critical | unknown`
|
||||
- `summary`: string
|
||||
- `keyFindings`: array of:
|
||||
- `category`: string
|
||||
- `severity`: `info | warning | critical`
|
||||
- `evidence`: string
|
||||
- `citationIds`: string[]
|
||||
- `recommendations`: array of:
|
||||
- `priority`: `low | normal | high | urgent`
|
||||
- `action`: string
|
||||
- `reason`: string
|
||||
- `citationIds`: string[]
|
||||
- `dataGaps`: string[]
|
||||
- `citations`: array of:
|
||||
- `id`: string
|
||||
- `sourceType`: `record | knowledge`
|
||||
- `sourceId`: string
|
||||
- `title`: string
|
||||
- `excerpt`: string
|
||||
- `confidence`: number from 0 to 1
|
||||
- `modelSummary`: object with provider base URL host, chat model, embedding model, and generation timestamp, excluding API key.
|
||||
|
||||
Use LangChain structured output where possible. Validate again server-side before saving.
|
||||
|
||||
## API Design
|
||||
|
||||
### Elder Analysis
|
||||
|
||||
- `GET /api/ai/elders/[id]/analyses`
|
||||
- Returns latest allowed analysis metadata/detail and history list.
|
||||
- Redacts detail for records whose `dataScopes` exceed current permissions.
|
||||
- `POST /api/ai/elders/[id]/analyses`
|
||||
- Synchronously generates a new analysis.
|
||||
- Returns `{ success: true, reason, analysis }` on completion.
|
||||
- On failure, saves failed history and audit log, then returns `{ success: false, reason }`.
|
||||
|
||||
### Knowledge
|
||||
|
||||
- `GET /api/ai/knowledge`
|
||||
- Requires `knowledge:read`.
|
||||
- Lists platform and active organization entries visible to caller.
|
||||
- `POST /api/ai/knowledge`
|
||||
- Requires `knowledge:manage`.
|
||||
- Creates entry, chunks, embeds, writes audit log.
|
||||
- `PATCH /api/ai/knowledge/[id]`
|
||||
- Requires `knowledge:manage`.
|
||||
- Updates entry and rebuilds chunks/embeddings.
|
||||
- `DELETE /api/ai/knowledge/[id]`
|
||||
- Requires `knowledge:manage`.
|
||||
- Deletes entry and cascading chunks.
|
||||
|
||||
All routes return the existing API response shape with `Cache-Control: no-store`.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Elder List
|
||||
|
||||
In `EldersClient`:
|
||||
|
||||
- Add AI analysis button per row for users with `ai:read`.
|
||||
- Use lucide icon such as `Sparkles`.
|
||||
- Open `Dialog` with `width="wide"`.
|
||||
- Dialog sections:
|
||||
- Resident summary.
|
||||
- Generate button with loading state.
|
||||
- Latest analysis summary.
|
||||
- Findings and recommendations.
|
||||
- Data gaps.
|
||||
- Citations.
|
||||
- History list with restricted placeholders.
|
||||
|
||||
No chat input and no one-click business creation.
|
||||
|
||||
### Knowledge Settings Page
|
||||
|
||||
Route: `/settings/knowledge`
|
||||
|
||||
Features:
|
||||
|
||||
- List entries by scope/status/category/search.
|
||||
- Create/edit dialog with title, body, category, tags, scope, enabled/disabled.
|
||||
- Disable mutations when missing `knowledge:manage`.
|
||||
- Show embedding/rebuild status through response messages; no background jobs.
|
||||
|
||||
## Audit And Logging
|
||||
|
||||
Audit actions:
|
||||
|
||||
- `ai.elderAnalysis.generate`
|
||||
- `ai.elderAnalysis.generateFailed`
|
||||
- `ai.knowledge.create`
|
||||
- `ai.knowledge.update`
|
||||
- `ai.knowledge.delete`
|
||||
- `ai.knowledge.disable`
|
||||
- `ai.knowledge.enable`
|
||||
|
||||
Audit records include actor, organization, target type/id, result, and sanitized reason.
|
||||
|
||||
Do not store:
|
||||
|
||||
- Full prompts.
|
||||
- Full resident context snapshots.
|
||||
- Raw provider errors.
|
||||
- API keys.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
Generation failures are classified:
|
||||
|
||||
- `missing_configuration`
|
||||
- `provider_error`
|
||||
- `timeout`
|
||||
- `schema_validation_failed`
|
||||
- `retrieval_failed`
|
||||
- `unknown`
|
||||
|
||||
On failure:
|
||||
|
||||
1. Save failed analysis history with sanitized category/reason.
|
||||
2. Write audit log.
|
||||
3. Return structured API failure.
|
||||
4. UI shows a concise error state and keeps prior completed history visible.
|
||||
|
||||
## Rollout And Compatibility
|
||||
|
||||
- Requires database migration for pgvector extension, AI enums, knowledge tables, chunk table, analysis table, and permissions.
|
||||
- Local `compose.yaml` should use a pgvector-capable PostgreSQL image or document manual extension installation.
|
||||
- Production deployment must verify `CREATE EXTENSION vector` support before migration.
|
||||
- If AI env vars are missing, knowledge CRUD can still work except embedding-dependent save should fail gracefully; analysis generation should return a structured configuration failure.
|
||||
|
||||
## Open Trade-Offs
|
||||
|
||||
- Embedding dimensions depend on the selected embedding model. The implementation should define a default and document migration impact if changed.
|
||||
- Synchronous generation is acceptable for MVP but can be replaced later by async jobs using the same history table status field.
|
||||
- Platform shared knowledge increases utility but requires careful scope filters in every retrieval query.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,305 @@
|
||||
# AI Agent Knowledge Analysis Implementation Plan
|
||||
|
||||
## Preconditions
|
||||
|
||||
- User reviews and approves `prd.md`, `design.md`, and `implement.md`.
|
||||
- Task status is moved from `planning` to `in_progress` with `task.py start`.
|
||||
- Before editing code in Phase 2, load `trellis-before-dev` and relevant specs:
|
||||
- `.trellis/spec/shared/code-quality.md`
|
||||
- `.trellis/spec/shared/typescript.md`
|
||||
- `.trellis/spec/shared/dependencies.md`
|
||||
- `.trellis/spec/backend/directory-structure.md`
|
||||
- `.trellis/spec/backend/database.md`
|
||||
- `.trellis/spec/backend/authentication.md`
|
||||
- `.trellis/spec/backend/type-safety.md`
|
||||
- `.trellis/spec/backend/quality.md`
|
||||
- `.trellis/spec/frontend/components.md`
|
||||
- `.trellis/spec/frontend/type-safety.md`
|
||||
- `.trellis/spec/frontend/quality.md`
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### 1. Dependencies And Environment
|
||||
|
||||
- Add LangChain runtime packages:
|
||||
- `langchain`
|
||||
- `@langchain/core`
|
||||
- `@langchain/openai`
|
||||
- Decide and document default embedding dimension for `AI_EMBEDDING_MODEL`.
|
||||
- Add env handling in `modules/ai/server/config.ts`:
|
||||
- `AI_BASE_URL`
|
||||
- `AI_API_KEY`
|
||||
- `AI_CHAT_MODEL`
|
||||
- `AI_EMBEDDING_MODEL`
|
||||
- Update local PostgreSQL image or setup notes so pgvector is available.
|
||||
|
||||
Validation:
|
||||
|
||||
- `pnpm install`
|
||||
- `pnpm type-check`
|
||||
|
||||
Rollback:
|
||||
|
||||
- Remove added dependencies and AI env usage if the AI module is backed out.
|
||||
|
||||
### 2. Database Schema And Migration
|
||||
|
||||
- Update `modules/core/server/schema.ts`:
|
||||
- AI enums for knowledge scope/status and analysis status.
|
||||
- `aiKnowledgeEntries`.
|
||||
- `aiKnowledgeChunks`.
|
||||
- `elderAiAnalyses`.
|
||||
- Add pgvector extension migration support.
|
||||
- Generate Drizzle migration with `pnpm db:generate`.
|
||||
- Manually inspect generated SQL for:
|
||||
- `CREATE EXTENSION IF NOT EXISTS vector`.
|
||||
- Correct foreign keys.
|
||||
- Correct indexes.
|
||||
- Correct vector column type/dimension.
|
||||
|
||||
Validation:
|
||||
|
||||
- `pnpm db:generate`
|
||||
- `pnpm type-check`
|
||||
- Apply migration locally when DB is available: `pnpm db:migrate`.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Drop AI tables and extension-dependent indexes in a rollback migration if needed.
|
||||
- Do not drop `vector` extension automatically if other future objects may use it.
|
||||
|
||||
### 3. Permissions And Role Seeding
|
||||
|
||||
- Update `modules/core/types.ts`:
|
||||
- Add `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
|
||||
- Update `modules/core/server/permissions.ts`:
|
||||
- Add permission definitions.
|
||||
- Update default role grants:
|
||||
- `platform_admin`: all four.
|
||||
- `org_admin`: all four.
|
||||
- `manager`: all four.
|
||||
- `caregiver`: `ai:read`, `knowledge:read`.
|
||||
- `viewer`, `family`, `resident`: none.
|
||||
- Ensure `ensureSystemDefaults()` upserts permissions and role grants without deleting custom grants.
|
||||
|
||||
Validation:
|
||||
|
||||
- `pnpm type-check`
|
||||
- Targeted tests or assertions for seeded permission definitions and role grants.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Remove permissions from default grants only through an intentional migration/seed change; avoid deleting custom user role state.
|
||||
|
||||
### 4. AI Domain Types
|
||||
|
||||
- Create `modules/ai/types.ts`.
|
||||
- Define:
|
||||
- Knowledge entry input and status/scope values.
|
||||
- Analysis status values.
|
||||
- Data scope values.
|
||||
- Risk/severity/priority values.
|
||||
- Structured analysis output type and runtime validator.
|
||||
- Citation type.
|
||||
- Sanitized error category type.
|
||||
- Avoid `any`; use explicit `unknown` validation helpers when parsing API bodies or AI output.
|
||||
|
||||
Validation:
|
||||
|
||||
- Unit tests for validators if they contain non-trivial parsing.
|
||||
- `pnpm type-check`
|
||||
|
||||
Rollback:
|
||||
|
||||
- Types are isolated; remove module if feature is backed out.
|
||||
|
||||
### 5. Knowledge Service
|
||||
|
||||
- Create `modules/ai/server/knowledge.ts`.
|
||||
- Implement:
|
||||
- List visible entries by permission and active organization.
|
||||
- Create/update/delete entries with scope validation.
|
||||
- Server-side chunking.
|
||||
- Embedding generation through LangChain OpenAI-compatible embeddings.
|
||||
- Chunk rebuild on create/update.
|
||||
- Scope-filtered retrieval for platform shared + active organization entries.
|
||||
- Write audit logs for create/update/delete/enable/disable.
|
||||
- Ensure no organization-private chunks from other organizations can be retrieved.
|
||||
|
||||
Validation:
|
||||
|
||||
- Tests for scope filtering:
|
||||
- Platform shared visible to organization user.
|
||||
- Own organization visible.
|
||||
- Other organization not visible.
|
||||
- Disabled entries not retrieved.
|
||||
- Tests for mutation permission failures.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Disable `/settings/knowledge` navigation and API routes if embedding is unavailable.
|
||||
- Existing tables can remain unused.
|
||||
|
||||
### 6. Elder Context Service
|
||||
|
||||
- Create `modules/ai/server/elder-context.ts`.
|
||||
- Implement permission-aware resident graph assembly:
|
||||
- Always include elder basics after `elder:read`.
|
||||
- Include health only with `health:read`.
|
||||
- Include care only with `care:read`.
|
||||
- Include family only with `family:read`.
|
||||
- Include admission/facility only with matching read permissions.
|
||||
- Include alert/incident only with matching read permissions.
|
||||
- Include knowledge only with `knowledge:read`.
|
||||
- Return `dataScopes` exactly matching included families.
|
||||
- Use batch queries and `Promise.all` for independent loads.
|
||||
|
||||
Validation:
|
||||
|
||||
- Tests for data family inclusion/exclusion by permissions.
|
||||
- Tests that target elder must belong to active organization.
|
||||
|
||||
Rollback:
|
||||
|
||||
- This service is isolated under `modules/ai`; remove API use if feature is disabled.
|
||||
|
||||
### 7. Analysis Service
|
||||
|
||||
- Create `modules/ai/server/analysis.ts`.
|
||||
- Implement:
|
||||
- Generate structured analysis synchronously with LangChain.
|
||||
- Validate AI output against fixed schema.
|
||||
- Save `completed` history with result/citations/model summary/data scopes.
|
||||
- On failure, save `failed` history with sanitized error category/reason.
|
||||
- Write success/failure audit logs.
|
||||
- Redact history details when current permissions do not satisfy stored `dataScopes`.
|
||||
- Do not store full prompts, full context snapshots, API keys, or raw provider errors.
|
||||
|
||||
Validation:
|
||||
|
||||
- Tests for:
|
||||
- Missing config -> structured failure + failed history.
|
||||
- Schema validation failure -> failed history.
|
||||
- History detail redaction when permissions are insufficient.
|
||||
- Completed history shape.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Keep history rows as audit evidence; hide UI/API if provider issues block rollout.
|
||||
|
||||
### 8. API Routes
|
||||
|
||||
- Add elder analysis routes:
|
||||
- `app/api/ai/elders/[id]/analyses/route.ts`
|
||||
- `GET`: latest/history with permission-aware redaction.
|
||||
- `POST`: synchronous generation.
|
||||
- Add knowledge routes:
|
||||
- `app/api/ai/knowledge/route.ts`
|
||||
- `GET`, `POST`.
|
||||
- `app/api/ai/knowledge/[id]/route.ts`
|
||||
- `PATCH`, `DELETE`.
|
||||
- Use existing `requirePermission`, `jsonSuccess`, `jsonFailure`, `readJsonBody`, and `recordAuditLog` patterns.
|
||||
- Preserve `Cache-Control: no-store` through existing helpers.
|
||||
|
||||
Validation:
|
||||
|
||||
- Route tests for auth/permission failures and structured responses.
|
||||
- `pnpm type-check`
|
||||
|
||||
Rollback:
|
||||
|
||||
- Remove routes or return feature-disabled failures while keeping data.
|
||||
|
||||
### 9. Knowledge Settings UI
|
||||
|
||||
- Create:
|
||||
- `app/(app)/app/settings/knowledge/page.tsx`
|
||||
- `app/(app)/app/[organizationSlug]/settings/knowledge/page.tsx`
|
||||
- `modules/ai/components/KnowledgeManagementClient.tsx`
|
||||
- Update:
|
||||
- `modules/shared/lib/navigation.ts`
|
||||
- `modules/shared/components/AppSidebarNav.tsx` icon map if a new icon key is needed.
|
||||
- UI supports:
|
||||
- List/filter/search.
|
||||
- Create/edit dialog.
|
||||
- Enable/disable/delete.
|
||||
- Read-only view if missing `knowledge:manage`.
|
||||
- Follow existing settings/client component patterns.
|
||||
|
||||
Validation:
|
||||
|
||||
- Manual browser check.
|
||||
- Type-check/lint.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Remove nav entry and route.
|
||||
|
||||
### 10. Elder Analysis UI
|
||||
|
||||
- Update `modules/elders/components/EldersClient.tsx`.
|
||||
- Add `AI 分析` row action for authorized users.
|
||||
- Create `modules/ai/components/ElderAiAnalysisDialog.tsx`.
|
||||
- Dialog displays:
|
||||
- Elder summary.
|
||||
- Generate button/loading state.
|
||||
- Latest completed analysis.
|
||||
- Failed state if latest attempt failed.
|
||||
- Findings, recommendations, data gaps.
|
||||
- Citations.
|
||||
- History list with restricted placeholders.
|
||||
- No chat input.
|
||||
- No one-click business mutation.
|
||||
|
||||
Validation:
|
||||
|
||||
- Manual browser check for desktop/mobile widths.
|
||||
- Confirm button text fits and table layout remains stable.
|
||||
- Verify unauthorized users do not see the action.
|
||||
|
||||
Rollback:
|
||||
|
||||
- Remove row action; API/history remains dormant.
|
||||
|
||||
### 11. Quality Gate
|
||||
|
||||
Run:
|
||||
|
||||
- `pnpm lint`
|
||||
- `pnpm type-check`
|
||||
- `pnpm test` if route/domain tests were added.
|
||||
- `pnpm build` before final completion if time and environment allow.
|
||||
|
||||
Manual checks:
|
||||
|
||||
- Knowledge CRUD as authorized admin/manager.
|
||||
- Knowledge read-only as caregiver.
|
||||
- Elder analysis generation with configured AI provider.
|
||||
- Missing AI config failure.
|
||||
- History redaction across roles.
|
||||
- Other-organization knowledge cannot appear in retrieval.
|
||||
|
||||
## Risky Files
|
||||
|
||||
- `modules/core/server/schema.ts`
|
||||
- `modules/core/types.ts`
|
||||
- `modules/core/server/permissions.ts`
|
||||
- `compose.yaml`
|
||||
- `drizzle/*`
|
||||
- `modules/elders/components/EldersClient.tsx`
|
||||
- `modules/shared/lib/navigation.ts`
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- After dependency install: revert package changes if LangChain package compatibility fails.
|
||||
- After schema migration generation: inspect SQL before applying.
|
||||
- After permissions update: verify default role grants before touching UI.
|
||||
- After API routes: keep UI disabled until permission and scope tests pass.
|
||||
- After UI: remove nav/row entry if backend generation is not production-ready.
|
||||
|
||||
## Follow-Up Checks Before Start
|
||||
|
||||
- Confirm pgvector deployment support on the target PostgreSQL server.
|
||||
- Pick and document embedding dimensions for the configured default embedding model.
|
||||
- Confirm production AI provider and env var names.
|
||||
- Decide whether to add seed knowledge entries for smoke testing.
|
||||
@@ -0,0 +1,158 @@
|
||||
# AI agent knowledge analysis
|
||||
|
||||
## Goal
|
||||
|
||||
Add an AI capability layer for TeaTea Pension that can use agent-style orchestration, retrieve organization-scoped knowledge, and provide AI analysis in relevant care-operation workflows.
|
||||
|
||||
The MVP workflow is elder profile AI analysis: given one resident, the system should gather authorized, organization-scoped resident context, retrieve relevant knowledge, and produce a cited, structured operational analysis for staff review.
|
||||
|
||||
The MVP interaction is a fixed "generate AI analysis" panel, not a conversational assistant. The panel should produce structured output such as risk summary, key evidence, recommended actions, source citations, and confidence.
|
||||
|
||||
The MVP entry point should be an "AI 分析" action in each elder table row. Clicking it opens a `wide` dialog panel that shows elder context, a generate action, the latest analysis, analysis history, and citations.
|
||||
|
||||
The MVP resident context should use the full available resident graph: elder basics, current bed/admission context, admission history, health profile, recent vitals, chronic conditions, health anomaly reviews, care tasks, alerts/incidents, family feedback, and relevant knowledge-base snippets.
|
||||
|
||||
MVP knowledge-base content should be manually maintained as structured knowledge entries with title, body, category/tags, scope, organization scope when applicable, and enabled status. On save, the server should chunk and embed the content into pgvector-backed storage. File upload, OCR, web crawling, and external-system sync are out of scope for MVP ingestion.
|
||||
|
||||
MVP retrieval should support platform shared knowledge plus organization-private knowledge. Ordinary organization users can retrieve enabled platform shared entries and enabled entries for their active organization. Platform knowledge is maintained by platform-level authorized users; organization knowledge is maintained by organization-level authorized users.
|
||||
|
||||
MVP must include a `/settings/knowledge` management page under the existing settings area. The sidebar entry should live in the "管理系统" group, be visible to `knowledge:read`, and allow create/edit/enable/disable/delete only with `knowledge:manage`.
|
||||
|
||||
MVP elder analyses should be persisted as history records with organization, elder, actor, structured result, source citations, model configuration summary, status, and creation time. Persisting history supports auditability, comparison over time, and token-cost control.
|
||||
|
||||
MVP AI analysis output must be a fixed structured schema rendered by the frontend rather than free-form text. The schema should include `overallRiskLevel`, `summary`, `keyFindings[]`, `recommendations[]`, `dataGaps[]`, `citations[]`, `confidence`, and `modelSummary`. Findings should include category, severity, evidence, and citation references. Recommendations should remain advisory and must not create business drafts.
|
||||
|
||||
MVP analysis generation should run synchronously in the request. The UI should show a loading state while generation is in progress. Analysis history should support at least `completed` and `failed` statuses so failures are visible and auditable without requiring an async job system.
|
||||
|
||||
On generation failure, the system should save a `failed` analysis history record and write an audit log. Failed history records must store only sanitized error category and brief reason, such as `provider_error`, `timeout`, or `schema_validation_failed`; they must not store full prompts, resident context, sensitive data, or raw provider errors.
|
||||
|
||||
Each analysis history record must store `dataScopes` describing the data families used to generate it, such as `elder`, `health`, `care`, `family`, `admission`, `alert`, `incident`, and `knowledge`. Viewing a history detail requires `ai:read`, `elder:read`, active organization access, and all read permissions implied by that record's `dataScopes`; otherwise the UI should show a restricted placeholder rather than the analysis content.
|
||||
|
||||
The broader planning objective still covers:
|
||||
|
||||
- LangChain + agent module.
|
||||
- Knowledge-base retrieval.
|
||||
- AI analysis from multiple places in the product.
|
||||
|
||||
The feature should improve operational decision support without weakening tenant isolation, role permissions, auditability, or existing deterministic workflows.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- The application is a single-repo Next.js 15 / React 19 / TypeScript app with API routes, Drizzle ORM, PostgreSQL, and TailwindCSS. See `package.json`.
|
||||
- Runtime dependencies currently do not include `ai`, `@ai-sdk/*`, `langchain`, vector database clients, or embedding providers. See `package.json`.
|
||||
- Local infrastructure currently runs a single `postgres:17-alpine` service, and Drizzle migrations are generated from `modules/core/server/schema.ts`. There is no existing pgvector extension or external vector database configuration. See `compose.yaml` and `drizzle.config.ts`.
|
||||
- Project specs already contain Vercel AI SDK guidance for backend and frontend AI features in `.trellis/spec/backend/ai-sdk-integration.md` and `.trellis/spec/frontend/ai-sdk-integration.md`.
|
||||
- The actual backend code uses `modules/core/server/*` for database, auth, audit, permissions, and schema; it is not using the package layout shown in some generic specs.
|
||||
- Authentication uses a custom `teatea_session` cookie and Drizzle-backed `sessions`, `accounts`, `organizations`, `memberships`, `roles`, and permissions. See `modules/core/server/auth.ts` and `.trellis/spec/backend/authentication.md`.
|
||||
- Most operational tables are tenant-scoped by `organizationId`, and resident-specific data often includes `elderId`. See `modules/core/server/schema.ts`.
|
||||
- Current permission families include care, health, device, notice, alert, family, facility, admission, elder, incident, audit, role, account, organization, and platform permissions. See `modules/core/types.ts` and `modules/core/server/permissions.ts`.
|
||||
- The dashboard already aggregates elders, beds, admissions, care tasks, health reviews, and emergency incidents with permission checks. See `app/(app)/app/dashboard/page.tsx`.
|
||||
- Health currently has deterministic anomaly detection for vital records and creates health anomaly reviews from thresholds. See `modules/health/server/operations.ts`.
|
||||
- Alerting already has rule and trigger tables that can represent warnings and suggested handling. See `modules/alerts/server/operations.ts` and `modules/core/server/schema.ts`.
|
||||
- Existing elder/bed operational context can already attach permission-filtered care task, health review/vital, and incident lines to elders. See `modules/operations/server/context.ts`.
|
||||
- Health profile, vital, chronic condition, anomaly review, family contact, visit, feedback, and admission data exist but would need an AI-specific resident context assembler to gather one resident's full analysis input. See `modules/health/server/operations.ts`, `modules/family/server/operations.ts`, and `modules/core/server/operations.ts`.
|
||||
- API responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`. See `modules/core/server/api.ts`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The AI module must preserve organization isolation: every retrieval, prompt context, generated analysis, and generated action draft must be scoped to the authenticated active organization unless the caller has a platform-level reason to cross scopes.
|
||||
- The AI module must preserve existing role permissions: analysis endpoints must require the same read permissions as the underlying data they inspect, and any write/action-producing workflow must require the matching manage permission.
|
||||
- MVP authorization must add dedicated AI and knowledge permissions while still applying underlying domain data permissions.
|
||||
- Dedicated MVP permission points should include `ai:read`, `ai:manage`, `knowledge:read`, and `knowledge:manage`.
|
||||
- Generating elder AI analysis must require `ai:read`, `elder:read`, an active organization, and only include optional resident-context data when the caller has the matching read permission such as `health:read`, `care:read`, `alert:read`, `family:read`, `admission:read`, `facility:read`, or `incident:read`.
|
||||
- Analysis history details must be filtered by stored `dataScopes` so users cannot view generated content derived from data families they cannot currently read.
|
||||
- Maintaining knowledge entries must require `knowledge:manage`; using retrieved knowledge in analysis must require `knowledge:read`.
|
||||
- Default role seeding should grant:
|
||||
- `platform_admin`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
|
||||
- `org_admin`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
|
||||
- `manager`: `ai:read`, `ai:manage`, `knowledge:read`, `knowledge:manage`.
|
||||
- `caregiver`: `ai:read`, `knowledge:read`.
|
||||
- `viewer`, `family`, and `resident`: no AI or knowledge permissions by default.
|
||||
- The first implementation should add a shared server-side AI boundary rather than scattering provider calls across feature modules.
|
||||
- The AI boundary must allow future provider/model changes without touching each product surface.
|
||||
- MVP AI orchestration must use LangChain as the primary server-side runtime for model calls, retrieval chains, agent/tool orchestration, and structured analysis output.
|
||||
- Vercel AI SDK should not be part of the MVP runtime path; keep it as a future option for conversational or streaming UI work.
|
||||
- MVP model access must use an OpenAI-compatible configurable provider interface with environment variables for base URL, API key, chat model, and embedding model.
|
||||
- The MVP UI must be a structured analysis panel attached to the elder profile/list workflow rather than a free-form chat assistant.
|
||||
- The MVP elder UI entry point must be an "AI 分析" action in each elder table row that opens a `wide` dialog panel.
|
||||
- MVP knowledge management UI must be available at `/settings/knowledge` with read access gated by `knowledge:read` and mutations gated by `knowledge:manage`.
|
||||
- The MVP analysis input must use the full available resident graph when the caller has matching read permissions.
|
||||
- MVP LangChain agent/tools must be read-only. They may load resident context, retrieve knowledge, and read analysis history, but must not provide mutation tools.
|
||||
- Knowledge retrieval must return source metadata that the UI can display or store with the analysis, so AI outputs are traceable to underlying records or documents.
|
||||
- MVP knowledge retrieval must store chunks and embeddings in PostgreSQL with pgvector, reusing the existing Drizzle/Postgres persistence boundary.
|
||||
- MVP knowledge ingestion must support manually maintained knowledge entries and server-side chunking/embedding on save.
|
||||
- MVP knowledge retrieval must include enabled platform shared knowledge plus enabled active-organization private knowledge, filtered by caller permissions.
|
||||
- AI analysis must be advisory by default. MVP output is limited to summaries, risk notes, recommendations, data gaps, and citations; future action-draft workflows require separate requirements and explicit user confirmation.
|
||||
- MVP elder AI analysis must only display structured recommendations. It must not create confirmable business drafts or one-click mutations for care tasks, alert triggers, health review notes, or other operational records.
|
||||
- MVP elder AI analyses must be saved as history records after generation.
|
||||
- MVP generation should run synchronously and save completed or failed history status.
|
||||
- Failed generation attempts must persist sanitized failed history and write audit logs.
|
||||
- Generated outputs must use structured schemas where the product needs machine-readable data, such as severity, confidence, citations, suggested next steps, or draft entity payloads.
|
||||
- MVP elder analysis output must use a fixed JSON-compatible schema with risk level, summary, findings, recommendations, data gaps, citations, confidence, and model summary.
|
||||
- AI calls must have observable failure handling: user-facing failures should be structured, and server logs/audit records should identify operation type, actor, organization, target, and result.
|
||||
|
||||
## Candidate Product Surfaces
|
||||
|
||||
- MVP: Elder profile analysis: summarize one resident's health notes, vitals, care tasks, alerts, admissions, and family feedback.
|
||||
- Dashboard executive summary: summarize current risk across care, health, beds, admissions, and incidents.
|
||||
- Health anomaly explanation: explain why a review matters and suggest follow-up questions or actions.
|
||||
- Alert and incident triage: summarize context, recommend severity/status, and draft handling notes.
|
||||
- Care operations assistant: answer operational questions from scoped data and draft care-task notes.
|
||||
- Knowledge-base Q&A: retrieve internal policies, care protocols, notices, facility SOPs, and relevant system records.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not bypass deterministic rules already present in health anomaly detection and alert handling; AI should augment those flows first.
|
||||
- Do not introduce unscoped vector search that can leak data across organizations.
|
||||
- Do not introduce a separate vector database/service for the MVP.
|
||||
- Do not let AI-specific permissions bypass the underlying domain permissions for resident, health, care, family, alert, admission, facility, device, or incident data.
|
||||
- Do not show analysis history content to users who lack read permission for any stored `dataScopes` used by the analysis.
|
||||
- Do not expose organization-private knowledge to other organizations through platform shared retrieval.
|
||||
- Do not include file upload, document parsing, OCR, crawling, or external knowledge sync in the MVP.
|
||||
- Do not make knowledge maintenance API-only; MVP needs a user-facing settings page.
|
||||
- Do not store full prompts or sensitive resident data in third-party logs unless explicitly configured and reviewed.
|
||||
- Do not store full prompts, resident context, sensitive data, or raw provider errors in failed history records.
|
||||
- Do not hard-code a single model vendor into feature modules.
|
||||
- Do not treat generated AI analysis history as an authoritative clinical record; it is advisory operational support.
|
||||
- Do not add "one-click create" or confirmable draft business mutations from MVP AI analysis output.
|
||||
- Do not define mutation-capable LangChain tools in the MVP, even if they are not exposed in the UI.
|
||||
- Do not require frontend components to know provider-specific APIs.
|
||||
- Do not start implementation until `design.md` and `implement.md` exist and the user has approved the final planning artifacts.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] PRD identifies elder profile AI analysis as the MVP user workflow and fixed structured analysis panel as the MVP interaction.
|
||||
- [ ] Elder list rows expose an AI analysis action for authorized users and open a wide analysis dialog.
|
||||
- [ ] PRD defines the full resident graph as MVP data sources and identifies out-of-scope AI surfaces.
|
||||
- [ ] Technical design defines the AI server module boundary, provider/orchestration choice, RAG storage strategy, authorization model, source-citation contract, audit/logging contract, and fallback behavior.
|
||||
- [ ] MVP implementation uses LangChain as the server-side AI orchestration layer and does not introduce Vercel AI SDK runtime code.
|
||||
- [ ] MVP model configuration supports OpenAI-compatible `AI_BASE_URL`, `AI_API_KEY`, `AI_CHAT_MODEL`, and `AI_EMBEDDING_MODEL` style settings.
|
||||
- [ ] Implementation plan defines ordered steps, validation commands, risky files, migration needs, and rollback points.
|
||||
- [ ] Any implemented AI analysis endpoint checks authentication, active organization, and required permissions before loading data or retrieving knowledge.
|
||||
- [ ] MVP adds and seeds `ai:read`, `ai:manage`, `knowledge:read`, and `knowledge:manage` permission definitions and assigns them to appropriate default operational/admin roles.
|
||||
- [ ] Default role permission seeding matches the agreed AI/knowledge grants for platform admins, org admins, managers, caregivers, viewers, family users, and residents.
|
||||
- [ ] Elder analysis context only includes each optional data family when the caller has the matching domain read permission.
|
||||
- [ ] Analysis history records store `dataScopes`, and history detail visibility is denied or redacted when the current user lacks any required scope permission.
|
||||
- [ ] Any implemented retrieval mechanism stores and filters knowledge by organization scope and returns source metadata with each retrieval result.
|
||||
- [ ] MVP retrieval uses PostgreSQL + pgvector with Drizzle migrations and organization-scoped query filters.
|
||||
- [ ] MVP knowledge entries can be manually maintained and embedded into searchable chunks with source metadata.
|
||||
- [ ] `/settings/knowledge` exists for authorized users and supports list, filter, create, edit, enable/disable, and delete workflows.
|
||||
- [ ] Knowledge retrieval includes only enabled platform shared knowledge and enabled knowledge for the caller's active organization.
|
||||
- [ ] MVP AI analysis output contains recommendations only and does not create or persist business drafts/actions beyond the analysis history record itself.
|
||||
- [ ] MVP LangChain tools are read-only and cannot mutate care, health, alert, family, admission, facility, incident, or knowledge data.
|
||||
- [ ] MVP AI analysis responses and persisted history use the fixed structured output schema.
|
||||
- [ ] Generated elder analyses are persisted with actor, elder, organization, structured result, citation metadata, model summary, status, and timestamps.
|
||||
- [ ] Synchronous generation shows a loading state and returns structured API success/failure responses.
|
||||
- [ ] Failed generation attempts persist sanitized failed history records and audit logs without full prompts or raw sensitive context.
|
||||
- [ ] Quality verification includes `pnpm lint`, `pnpm type-check`, and targeted tests for permission scoping and structured failure behavior.
|
||||
|
||||
## Likely Out of Scope For MVP
|
||||
|
||||
- Fully autonomous agents that mutate production records without a user confirmation step.
|
||||
- Cross-organization analytics for ordinary organization users.
|
||||
- Replacing existing deterministic anomaly rules with AI-only decisions.
|
||||
- Conversational AI/chat UX for MVP elder analysis.
|
||||
- Real-time streaming chat across the whole product.
|
||||
- Async generation jobs, background workers, polling, and retry queues.
|
||||
- Building a full document-management system before the minimum knowledge-ingestion path is defined.
|
||||
- PDF/Word upload, OCR, web crawling, and external-system knowledge synchronization.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "ai-agent-knowledge-analysis",
|
||||
"name": "ai-agent-knowledge-analysis",
|
||||
"title": "AI agent knowledge analysis",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "TalexDreamSoul",
|
||||
"assignee": "TalexDreamSoul",
|
||||
"createdAt": "2026-07-04",
|
||||
"completedAt": "2026-07-05",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 12
|
||||
- **Last Active**: 2026-07-03
|
||||
- **Total Sessions**: 13
|
||||
- **Last Active**: 2026-07-05
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~416 | Active |
|
||||
| `journal-1.md` | ~449 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
|
||||
| 12 | 2026-07-03 | Invitation limits and theme toggle | `fb15d4d`, `9fd2088` | `main` |
|
||||
| 11 | 2026-07-03 | Build collaboration modules | `4ba2a11` | `main` |
|
||||
| 10 | 2026-07-03 | Local mock data | `36eae0b` | `main` |
|
||||
|
||||
@@ -414,3 +414,36 @@ Added workspace theme switching and configurable invitation validity/use limits
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 13: AI knowledge analysis MVP
|
||||
|
||||
**Date**: 2026-07-05
|
||||
**Task**: AI knowledge analysis MVP
|
||||
**Branch**: `main`
|
||||
|
||||
### Summary
|
||||
|
||||
Implemented LangChain-backed elder AI analysis with pgvector knowledge retrieval, permissions, APIs, UI entry points, migrations, and specs. Validated lint, type-check, tests, build, and local pgvector migration smoke checks.
|
||||
|
||||
### Main Changes
|
||||
|
||||
(Add details)
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `e204974` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import KnowledgePage from "../../../settings/knowledge/page";
|
||||
|
||||
export default async function ScopedKnowledgePage(): Promise<React.ReactElement> {
|
||||
return KnowledgePage();
|
||||
}
|
||||
21
app/(app)/app/settings/knowledge/page.tsx
Normal file
21
app/(app)/app/settings/knowledge/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { KnowledgeManagementClient } from "@/modules/ai/components/KnowledgeManagementClient";
|
||||
import { listKnowledgeEntries } from "@/modules/ai/server/knowledge";
|
||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||
import { hasPermission } from "@/modules/core/server/permissions";
|
||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||
|
||||
export default async function KnowledgePage(): Promise<React.ReactElement> {
|
||||
const context = await getCurrentAuthContext();
|
||||
if (!context) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!hasPermission(context.permissions, "knowledge:read")) {
|
||||
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||
}
|
||||
|
||||
const entries = await listKnowledgeEntries(context);
|
||||
return <KnowledgeManagementClient initialEntries={entries} permissions={context.permissions} />;
|
||||
}
|
||||
343
app/api/ai/ai-routes.test.ts
Normal file
343
app/api/ai/ai-routes.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import {
|
||||
createKnowledgeEntry,
|
||||
deleteKnowledgeEntry,
|
||||
listKnowledgeEntries,
|
||||
updateKnowledgeEntry,
|
||||
} from "@/modules/ai/server/knowledge";
|
||||
import type { ElderAiAnalysisHistoryItem, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
vi.mock("@/modules/core/server/auth", () => ({
|
||||
requirePermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/audit", () => ({
|
||||
recordAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/analysis", () => ({
|
||||
generateElderAiAnalysis: vi.fn(),
|
||||
listElderAiAnalyses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/knowledge", () => ({
|
||||
createKnowledgeEntry: vi.fn(),
|
||||
deleteKnowledgeEntry: vi.fn(),
|
||||
listKnowledgeEntries: vi.fn(),
|
||||
updateKnowledgeEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "AI Admin",
|
||||
email: "ai-admin@example.com",
|
||||
avatarUrl: "",
|
||||
role: "org_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const organization: NonNullable<AuthContext["organization"]> = {
|
||||
id: "org-1",
|
||||
name: "TeaCare Home",
|
||||
slug: "teacare",
|
||||
status: "active",
|
||||
registrationEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcHasClientSecret: false,
|
||||
oidcScopes: "",
|
||||
oidcRedirectUri: "",
|
||||
oidcAvatarClaim: "",
|
||||
oidcAutoProvision: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createAuthContext(): AuthContext {
|
||||
return {
|
||||
account,
|
||||
organization,
|
||||
organizations: [
|
||||
{
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
status: organization.status,
|
||||
registrationEnabled: organization.registrationEnabled,
|
||||
oidcEnabled: organization.oidcEnabled,
|
||||
roleLabel: "Admin",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
membership: {
|
||||
id: "membership-1",
|
||||
accountId: account.id,
|
||||
organizationId: organization.id,
|
||||
roleId: "role-1",
|
||||
roleKey: "org_admin",
|
||||
roleLabel: "Admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
},
|
||||
permissions: ["ai:read", "elder:read", "knowledge:read", "knowledge:manage"],
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: account.id,
|
||||
activeOrganizationId: organization.id,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const analysis: ElderAiAnalysisHistoryItem = {
|
||||
id: "analysis-1",
|
||||
elderId: "elder-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
createdAt: "2026-07-02T08:00:00.000Z",
|
||||
restricted: false,
|
||||
result: {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
title: "Increase night checks",
|
||||
priority: "high",
|
||||
rationale: "Additional observation reduces missed night wandering events.",
|
||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
dataGaps: [],
|
||||
citations: [
|
||||
{
|
||||
id: "resident-1",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1",
|
||||
title: "Resident risk notes",
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
},
|
||||
],
|
||||
confidence: 0.74,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const knowledgeInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Keep walkways clear and increase night rounds after wandering events.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const knowledgeEntry: KnowledgeEntry = {
|
||||
...knowledgeInput,
|
||||
id: "knowledge-1",
|
||||
createdAt: "2026-07-02T08:00:00.000Z",
|
||||
updatedAt: "2026-07-02T08:00:00.000Z",
|
||||
};
|
||||
|
||||
function allowAuth(): void {
|
||||
vi.mocked(requirePermission).mockResolvedValue({ success: true, context: createAuthContext() });
|
||||
}
|
||||
|
||||
function deniedAuth(reason = "权限不足", status = 403): { success: false; response: Response } {
|
||||
return {
|
||||
success: false,
|
||||
response: Response.json({ success: false, reason }, { status }),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/ai/knowledge", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
allowAuth();
|
||||
});
|
||||
|
||||
describe("AI API routes", () => {
|
||||
it("requires ai:read before elder analysis services are reached", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { GET } = await import("./elders/[id]/analyses/route");
|
||||
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body).toEqual({ success: false, reason: "权限不足" });
|
||||
expect(requirePermission).toHaveBeenCalledTimes(1);
|
||||
expect(requirePermission).toHaveBeenCalledWith("ai:read", expect.objectContaining({ targetId: "elder-1" }));
|
||||
expect(listElderAiAnalyses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires elder:read after ai:read before listing elder analysis history", async () => {
|
||||
vi.mocked(requirePermission)
|
||||
.mockResolvedValueOnce({ success: true, context: createAuthContext() })
|
||||
.mockResolvedValueOnce(deniedAuth("权限不足", 403));
|
||||
|
||||
const { GET } = await import("./elders/[id]/analyses/route");
|
||||
const response = await GET(new Request("http://localhost/api/ai/elders/elder-1/analyses"), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenNthCalledWith(2, "elder:read", expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
targetId: "elder-1",
|
||||
}));
|
||||
expect(listElderAiAnalyses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates analysis service failures with the service status", async () => {
|
||||
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
|
||||
|
||||
const { POST } = await import("./elders/[id]/analyses/route");
|
||||
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toEqual({ success: false, reason: "AI_API_KEY 未配置" });
|
||||
expect(generateElderAiAnalysis).toHaveBeenCalledWith(createAuthContext(), "elder-1");
|
||||
});
|
||||
|
||||
it("returns structured success for generated analysis", async () => {
|
||||
vi.mocked(generateElderAiAnalysis).mockResolvedValue({ success: true, data: { analysis } });
|
||||
|
||||
const { POST } = await import("./elders/[id]/analyses/route");
|
||||
const response = await POST(new Request("http://localhost/api/ai/elders/elder-1/analyses", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "elder-1" }),
|
||||
});
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body).toEqual({ success: true, reason: "AI 分析已生成", analysis });
|
||||
});
|
||||
|
||||
it("requires knowledge:read before listing knowledge entries", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { GET } = await import("./knowledge/route");
|
||||
const response = await GET();
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenCalledWith("knowledge:read", expect.objectContaining({ action: "ai.knowledge.list" }));
|
||||
expect(listKnowledgeEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires knowledge:manage before creating knowledge entries", async () => {
|
||||
vi.mocked(requirePermission).mockResolvedValue(deniedAuth());
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(body.reason).toBe("权限不足");
|
||||
expect(requirePermission).toHaveBeenCalledWith("knowledge:manage", expect.objectContaining({ action: "ai.knowledge.create" }));
|
||||
expect(createKnowledgeEntry).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid knowledge bodies before calling the service or audit log", async () => {
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput, title: " " }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body).toEqual({ success: false, reason: "知识标题不能为空" });
|
||||
expect(createKnowledgeEntry).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates knowledge service failures with the service status", async () => {
|
||||
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库向量生成失败", status: 500 });
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toEqual({ success: false, reason: "知识库向量生成失败" });
|
||||
expect(recordAuditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns created knowledge entries and records create audit success", async () => {
|
||||
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
|
||||
const { POST } = await import("./knowledge/route");
|
||||
const response = await POST(jsonRequest({ ...knowledgeInput }));
|
||||
const body = await readJson(response);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body).toEqual({ success: true, reason: "知识库条目已创建", entry: knowledgeEntry });
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actor: account,
|
||||
organizationId: "org-1",
|
||||
action: "ai.knowledge.create",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: "knowledge-1",
|
||||
result: "success",
|
||||
}));
|
||||
});
|
||||
|
||||
it("records update and delete audit success only after knowledge mutations succeed", async () => {
|
||||
vi.mocked(updateKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
vi.mocked(deleteKnowledgeEntry).mockResolvedValue({ success: true, data: { entry: knowledgeEntry } });
|
||||
|
||||
const { PATCH, DELETE } = await import("./knowledge/[id]/route");
|
||||
const patchResponse = await PATCH(jsonRequest({ ...knowledgeInput }), {
|
||||
params: Promise.resolve({ id: "knowledge-1" }),
|
||||
});
|
||||
const deleteResponse = await DELETE(new Request("http://localhost/api/ai/knowledge/knowledge-1", { method: "DELETE" }), {
|
||||
params: Promise.resolve({ id: "knowledge-1" }),
|
||||
});
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.update", result: "success" }));
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ action: "ai.knowledge.delete", result: "success" }));
|
||||
});
|
||||
});
|
||||
62
app/api/ai/elders/[id]/analyses/route.ts
Normal file
62
app/api/ai/elders/[id]/analyses/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import { jsonFailure, jsonSuccess } from "@/modules/core/server/api";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function requireAiAnalysisAccess(id: string) {
|
||||
const aiAuth = await requirePermission("ai:read", {
|
||||
action: "ai.elder_analysis.access",
|
||||
targetType: "elder",
|
||||
targetId: id,
|
||||
});
|
||||
if (!aiAuth.success) {
|
||||
return aiAuth;
|
||||
}
|
||||
|
||||
const elderAuth = await requirePermission("elder:read", {
|
||||
action: "ai.elder_analysis.access",
|
||||
targetType: "elder",
|
||||
targetId: id,
|
||||
organizationId: aiAuth.context.organization?.id,
|
||||
});
|
||||
if (!elderAuth.success) {
|
||||
return elderAuth;
|
||||
}
|
||||
|
||||
return aiAuth;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requireAiAnalysisAccess(id);
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const result = await listElderAiAnalyses(auth.context, id);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
|
||||
return jsonSuccess("AI 分析历史已加载", { history: result.data.history });
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requireAiAnalysisAccess(id);
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const result = await generateElderAiAnalysis(auth.context, id);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
|
||||
return jsonSuccess("AI 分析已生成", { analysis: result.data.analysis }, 201);
|
||||
}
|
||||
74
app/api/ai/knowledge/[id]/route.ts
Normal file
74
app/api/ai/knowledge/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { deleteKnowledgeEntry, updateKnowledgeEntry } from "@/modules/ai/server/knowledge";
|
||||
import { validateKnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("knowledge:manage", {
|
||||
action: "ai.knowledge.update",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: id,
|
||||
});
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const input = validateKnowledgeEntryInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const result = await updateKnowledgeEntry(auth.context, id, input.input);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
|
||||
action: "ai.knowledge.update",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: result.data.entry.id,
|
||||
result: "success",
|
||||
reason: `更新知识库条目:${result.data.entry.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("知识库条目已更新", { entry: result.data.entry });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
const auth = await requirePermission("knowledge:manage", {
|
||||
action: "ai.knowledge.delete",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: id,
|
||||
});
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const result = await deleteKnowledgeEntry(auth.context, id);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
|
||||
action: "ai.knowledge.delete",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: result.data.entry.id,
|
||||
result: "success",
|
||||
reason: `删除知识库条目:${result.data.entry.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("知识库条目已删除", { entry: result.data.entry });
|
||||
}
|
||||
50
app/api/ai/knowledge/route.ts
Normal file
50
app/api/ai/knowledge/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createKnowledgeEntry, listKnowledgeEntries } from "@/modules/ai/server/knowledge";
|
||||
import { validateKnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { requirePermission } from "@/modules/core/server/auth";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const auth = await requirePermission("knowledge:read", {
|
||||
action: "ai.knowledge.list",
|
||||
targetType: "ai_knowledge",
|
||||
});
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const entries = await listKnowledgeEntries(auth.context);
|
||||
return jsonSuccess("知识库已加载", { entries });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const auth = await requirePermission("knowledge:manage", {
|
||||
action: "ai.knowledge.create",
|
||||
targetType: "ai_knowledge",
|
||||
});
|
||||
if (!auth.success) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const input = validateKnowledgeEntryInput(await readJsonBody(request));
|
||||
if (!input.success) {
|
||||
return jsonFailure(input.reason);
|
||||
}
|
||||
|
||||
const result = await createKnowledgeEntry(auth.context, input.input);
|
||||
if (!result.success) {
|
||||
return jsonFailure(result.reason, result.status);
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: auth.context.account,
|
||||
organizationId: result.data.entry.organizationId ?? auth.context.organization?.id,
|
||||
action: "ai.knowledge.create",
|
||||
targetType: "ai_knowledge",
|
||||
targetId: result.data.entry.id,
|
||||
result: "success",
|
||||
reason: `创建知识库条目:${result.data.entry.title}`,
|
||||
});
|
||||
|
||||
return jsonSuccess("知识库条目已创建", { entry: result.data.entry }, 201);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: teatea-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
60
drizzle/0007_purple_puff_adder.sql
Normal file
60
drizzle/0007_purple_puff_adder.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
CREATE TYPE "public"."ai_knowledge_scope" AS ENUM('platform', 'organization');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ai_knowledge_status" AS ENUM('enabled', 'disabled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."elder_ai_analysis_status" AS ENUM('completed', 'failed');--> statement-breakpoint
|
||||
CREATE TABLE "ai_knowledge_chunks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"entry_id" uuid NOT NULL,
|
||||
"organization_id" uuid,
|
||||
"scope" "ai_knowledge_scope" NOT NULL,
|
||||
"chunk_index" integer NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"embedding" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"source_title" text NOT NULL,
|
||||
"source_category" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ai_knowledge_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"scope" "ai_knowledge_scope" NOT NULL,
|
||||
"organization_id" uuid,
|
||||
"title" text NOT NULL,
|
||||
"category" text DEFAULT '' NOT NULL,
|
||||
"tags" text DEFAULT '' NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"status" "ai_knowledge_status" DEFAULT 'enabled' NOT NULL,
|
||||
"created_by_account_id" uuid,
|
||||
"updated_by_account_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "elder_ai_analyses" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"elder_id" uuid NOT NULL,
|
||||
"actor_account_id" uuid,
|
||||
"status" "elder_ai_analysis_status" NOT NULL,
|
||||
"data_scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"result_json" jsonb,
|
||||
"citations_json" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"model_summary_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"error_category" text DEFAULT '' NOT NULL,
|
||||
"error_reason" text DEFAULT '' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ai_knowledge_chunks" ADD CONSTRAINT "ai_knowledge_chunks_entry_id_ai_knowledge_entries_id_fk" FOREIGN KEY ("entry_id") REFERENCES "public"."ai_knowledge_entries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_knowledge_chunks" ADD CONSTRAINT "ai_knowledge_chunks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_created_by_account_id_accounts_id_fk" FOREIGN KEY ("created_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ai_knowledge_entries" ADD CONSTRAINT "ai_knowledge_entries_updated_by_account_id_accounts_id_fk" FOREIGN KEY ("updated_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_elder_id_elders_id_fk" FOREIGN KEY ("elder_id") REFERENCES "public"."elders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "elder_ai_analyses" ADD CONSTRAINT "elder_ai_analyses_actor_account_id_accounts_id_fk" FOREIGN KEY ("actor_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ai_knowledge_chunks_entry_lookup" ON "ai_knowledge_chunks" USING btree ("entry_id");--> statement-breakpoint
|
||||
CREATE INDEX "ai_knowledge_chunks_scope_lookup" ON "ai_knowledge_chunks" USING btree ("scope","organization_id");--> statement-breakpoint
|
||||
CREATE INDEX "ai_knowledge_entries_scope_lookup" ON "ai_knowledge_entries" USING btree ("scope","status");--> statement-breakpoint
|
||||
CREATE INDEX "ai_knowledge_entries_organization_lookup" ON "ai_knowledge_entries" USING btree ("organization_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "elder_ai_analyses_elder_lookup" ON "elder_ai_analyses" USING btree ("organization_id","elder_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "elder_ai_analyses_status_lookup" ON "elder_ai_analyses" USING btree ("organization_id","status");
|
||||
5034
drizzle/meta/0007_snapshot.json
Normal file
5034
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1783077936270,
|
||||
"tag": "0006_petite_night_nurse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1783185764378,
|
||||
"tag": "0007_purple_puff_adder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
232
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
232
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Brain, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Elder } from "@/modules/elders/types";
|
||||
|
||||
type ElderAiAnalysisDialogProps = {
|
||||
elder: Elder | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
critical: "紧急",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
|
||||
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
async function loadHistory(target: Elder): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
|
||||
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
setHistory(result.history);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && elder) {
|
||||
void loadHistory(elder);
|
||||
}
|
||||
}, [elder, open]);
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
if (!elder) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
|
||||
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
|
||||
setIsGenerating(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
await loadHistory(elder);
|
||||
return;
|
||||
}
|
||||
setHistory((current) => [result.analysis, ...current]);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
const latest = history[0];
|
||||
const latestResult = latest && !latest.restricted ? latest.result : undefined;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title="AI 分析"
|
||||
width="wide"
|
||||
>
|
||||
{elder ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{elder.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{elder.age} 岁 · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
|
||||
<RefreshCw className="size-4" aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{isGenerating ? "生成中" : "生成分析"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{latestResult ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>最新分析</CardTitle>
|
||||
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
|
||||
风险:{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<p className="text-sm leading-6">{latestResult.summary}</p>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">关键发现</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.keyFindings.map((finding, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{finding.category}</span>
|
||||
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">建议动作</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.recommendations.map((recommendation, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{recommendation.title}</span>
|
||||
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{latestResult.dataGaps.length > 0 ? (
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">数据缺口</h3>
|
||||
<ul className="grid gap-1 text-sm text-muted-foreground">
|
||||
{latestResult.dataGaps.map((gap) => (
|
||||
<li key={gap}>{gap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">引用来源</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.citations.map((citation) => (
|
||||
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
|
||||
<p className="font-medium">
|
||||
[{citation.id}] {citation.title}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : latest ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>最新记录</CardTitle>
|
||||
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latest.status === "failed" ? "destructive" : "secondary"}>{latest.status === "failed" ? "生成失败" : "受限"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
||||
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
||||
{latest.status === "failed" ? <p>失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">历史记录</h3>
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
||||
<div>
|
||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
||||
<p className="text-xs text-muted-foreground">范围:{item.dataScopes.join(", ") || "-"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
||||
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
<Brain className="mr-2 size-4" aria-hidden="true" />
|
||||
未选择老人档案
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
360
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
360
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Edit3, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import type { AiKnowledgeScope, AiKnowledgeStatus, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
|
||||
type KnowledgeManagementClientProps = {
|
||||
initialEntries: KnowledgeEntry[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
type KnowledgeFormState = {
|
||||
scope: AiKnowledgeScope;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: AiKnowledgeStatus;
|
||||
};
|
||||
|
||||
const emptyForm: KnowledgeFormState = {
|
||||
scope: "organization",
|
||||
title: "",
|
||||
category: "",
|
||||
tags: "",
|
||||
body: "",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "organization", label: "机构私有" },
|
||||
{ value: "platform", label: "平台共享" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
];
|
||||
|
||||
function entryToForm(entry: KnowledgeEntry): KnowledgeFormState {
|
||||
return {
|
||||
scope: entry.scope,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
tags: entry.tags,
|
||||
body: entry.body,
|
||||
status: entry.status,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
|
||||
}
|
||||
|
||||
function formatKnowledgeFailureReason(reason: string): string {
|
||||
if (reason.includes("AI_API_KEY") || reason.includes("向量生成失败")) {
|
||||
return `保存知识需要可用的 AI 向量配置:${reason}`;
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
|
||||
const [entries, setEntries] = useState(initialEntries);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntry | null>(null);
|
||||
const [form, setForm] = useState<KnowledgeFormState>(emptyForm);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const canManage = permissions.includes("knowledge:manage");
|
||||
const filteredEntries = useMemo(() => entries.filter((entry) => matchesEntry(entry, query)), [entries, query]);
|
||||
const isEditing = editingId !== null;
|
||||
|
||||
function updateField<K extends keyof KnowledgeFormState>(key: K, value: KnowledgeFormState[K]): void {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function beginCreate(): void {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setMessage("");
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
|
||||
function beginEdit(entry: KnowledgeEntry): void {
|
||||
setEditingId(entry.id);
|
||||
setForm(entryToForm(entry));
|
||||
setMessage("");
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function refreshEntries(): Promise<void> {
|
||||
const response = await fetch("/api/ai/knowledge");
|
||||
const result = (await response.json()) as ApiResult<{ entries: KnowledgeEntry[] }>;
|
||||
if (result.success) {
|
||||
setEntries(result.entries);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权维护知识库");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const payload: KnowledgeEntryInput = {
|
||||
...form,
|
||||
};
|
||||
const response = await fetch(isEditing ? `/api/ai/knowledge/${editingId}` : "/api/ai/knowledge", {
|
||||
method: isEditing ? "PATCH" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
setIsPending(false);
|
||||
if (!result.success) {
|
||||
setMessage(formatKnowledgeFailureReason(result.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshEntries();
|
||||
closeDialog();
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!deleteTarget || !canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/knowledge/${deleteTarget.id}`, { method: "DELETE" });
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
setIsPending(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshEntries();
|
||||
setDeleteTarget(null);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
async function toggleStatus(entry: KnowledgeEntry): Promise<void> {
|
||||
const nextStatus: AiKnowledgeStatus = entry.status === "enabled" ? "disabled" : "enabled";
|
||||
const response = await fetch(`/api/ai/knowledge/${entry.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...entry, status: nextStatus }),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
if (!result.success) {
|
||||
setMessage(formatKnowledgeFailureReason(result.reason));
|
||||
return;
|
||||
}
|
||||
await refreshEntries();
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">维护平台共享和机构私有知识,保存后会重建检索向量。</p>
|
||||
</div>
|
||||
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
新增知识
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<CardTitle>知识条目</CardTitle>
|
||||
<CardDescription>共 {filteredEntries.length} 条匹配记录。</CardDescription>
|
||||
</div>
|
||||
<label className="relative block w-full md:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
||||
</label>
|
||||
</div>
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table className="w-full min-w-[860px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<Table.Head className="px-4 py-3 font-medium">标题</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">范围</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">分类</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">标签</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{filteredEntries.map((entry) => (
|
||||
<Table.Row key={entry.id}>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<p className="font-medium">{entry.title}</p>
|
||||
<p className="max-w-md truncate text-xs text-muted-foreground">{entry.body}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">{entry.scope === "platform" ? "平台共享" : "机构私有"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.category || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.tags || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={entry.status === "enabled" ? "success" : "secondary"}>
|
||||
{entry.status === "enabled" ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button disabled={!canManage} onClick={() => void toggleStatus(entry)} size="sm" type="button" variant="outline">
|
||||
{entry.status === "enabled" ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button disabled={!canManage} onClick={() => beginEdit(entry)} size="sm" type="button" variant="outline">
|
||||
<Edit3 className="size-4" aria-hidden="true" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button disabled={!canManage} onClick={() => setDeleteTarget(entry)} size="sm" type="button" variant="destructive">
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{filteredEntries.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无知识库条目
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
description="保存时会写入数据库、重建分块和 embedding。"
|
||||
onClose={closeDialog}
|
||||
open={isDialogOpen}
|
||||
title={isEditing ? "编辑知识" : "新增知识"}
|
||||
width="lg"
|
||||
>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5 text-sm font-medium">
|
||||
范围
|
||||
<Select
|
||||
aria-label="知识范围"
|
||||
onValueChange={(value) => updateField("scope", value as AiKnowledgeScope)}
|
||||
options={scopeOptions}
|
||||
value={form.scope}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 text-sm font-medium">
|
||||
状态
|
||||
<Select
|
||||
aria-label="知识状态"
|
||||
onValueChange={(value) => updateField("status", value as AiKnowledgeStatus)}
|
||||
options={statusOptions}
|
||||
value={form.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
标题
|
||||
<Input onChange={(event) => updateField("title", event.target.value)} required value={form.title} />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
分类
|
||||
<Input onChange={(event) => updateField("category", event.target.value)} value={form.category} />
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
标签
|
||||
<Input onChange={(event) => updateField("tags", event.target.value)} placeholder="逗号分隔" value={form.tags} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
内容
|
||||
<Textarea className="min-h-56" onChange={(event) => updateField("body", event.target.value)} required value={form.body} />
|
||||
</label>
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={closeDialog} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending || !canManage} type="submit">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
保存知识
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
description={deleteTarget ? `确认删除「${deleteTarget.title}」?对应分块也会删除。` : undefined}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
open={deleteTarget !== null}
|
||||
title="删除确认"
|
||||
width="sm"
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending || !canManage} onClick={() => void remove()} type="button" variant="destructive">
|
||||
删除知识
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
495
modules/ai/server/analysis.test.ts
Normal file
495
modules/ai/server/analysis.test.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import type { AppDatabase } from "@/modules/core/server/db";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import type { AuthContext, Permission } from "@/modules/core/types";
|
||||
|
||||
const chatMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@langchain/openai", () => ({
|
||||
ChatOpenAI: vi.fn(function MockChatOpenAI() {
|
||||
return {
|
||||
invoke: chatMocks.invoke,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/config", () => ({
|
||||
getAiRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/elder-context", () => ({
|
||||
buildElderAiContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/knowledge", () => ({
|
||||
retrieveKnowledge: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/audit", () => ({
|
||||
recordAuditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/db", () => ({
|
||||
getDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
type AnalysisStatus = "completed" | "failed";
|
||||
|
||||
type AnalysisRow = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
actorAccountId: string | null;
|
||||
status: AnalysisStatus;
|
||||
dataScopes: string[];
|
||||
resultJson: unknown;
|
||||
citationsJson: unknown;
|
||||
modelSummaryJson: unknown;
|
||||
errorCategory: string;
|
||||
errorReason: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type AnalysisInsertPayload = {
|
||||
organizationId?: unknown;
|
||||
elderId?: unknown;
|
||||
actorAccountId?: unknown;
|
||||
status?: unknown;
|
||||
dataScopes?: unknown;
|
||||
resultJson?: unknown;
|
||||
citationsJson?: unknown;
|
||||
modelSummaryJson?: unknown;
|
||||
errorCategory?: unknown;
|
||||
errorReason?: unknown;
|
||||
};
|
||||
|
||||
type AnalysisDatabaseFake = {
|
||||
insertedValues: AnalysisInsertPayload[];
|
||||
insert: ReturnlessMock;
|
||||
select: ReturnlessMock;
|
||||
};
|
||||
|
||||
type ReturnlessMock = ReturnlessFunction & {
|
||||
mock: unknown;
|
||||
};
|
||||
|
||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
const runtimeConfig: AiRuntimeConfigResult = {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: "https://ai.example.test/v1",
|
||||
apiKey: "test-api-key",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
embeddingDimensions: 1536,
|
||||
},
|
||||
};
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "Nurse Admin",
|
||||
email: "admin@example.com",
|
||||
avatarUrl: "",
|
||||
role: "org_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const organization: NonNullable<AuthContext["organization"]> = {
|
||||
id: "org-1",
|
||||
name: "TeaCare Home",
|
||||
slug: "teacare",
|
||||
status: "active",
|
||||
registrationEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcHasClientSecret: false,
|
||||
oidcScopes: "",
|
||||
oidcRedirectUri: "",
|
||||
oidcAvatarClaim: "",
|
||||
oidcAutoProvision: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const residentCitation: AiCitation = {
|
||||
id: "resident-1",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1",
|
||||
title: "Resident risk notes",
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
};
|
||||
|
||||
const unusedResidentCitation: AiCitation = {
|
||||
id: "resident-2",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1-vitals",
|
||||
title: "Recent vitals",
|
||||
excerpt: "Blood pressure readings are stable.",
|
||||
};
|
||||
|
||||
const knowledgeCitation = {
|
||||
entryId: "entry-1",
|
||||
chunkId: "chunk-1",
|
||||
title: "Fall prevention protocol",
|
||||
category: "Safety",
|
||||
content: "Keep walkways clear and increase observation after night wandering.",
|
||||
score: 0.89,
|
||||
};
|
||||
|
||||
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
||||
|
||||
function createAuthContext(permissions: Permission[] = ["ai:read", "elder:read", "health:read", "knowledge:read"]): AuthContext {
|
||||
return {
|
||||
account,
|
||||
organization: organizationContext,
|
||||
organizations: [
|
||||
{
|
||||
id: organizationContext.id,
|
||||
name: organizationContext.name,
|
||||
slug: organizationContext.slug,
|
||||
status: organizationContext.status,
|
||||
registrationEnabled: organizationContext.registrationEnabled,
|
||||
oidcEnabled: organizationContext.oidcEnabled,
|
||||
roleLabel: "Admin",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
membership: {
|
||||
id: "membership-1",
|
||||
accountId: account.id,
|
||||
organizationId: organizationContext.id,
|
||||
roleId: "role-1",
|
||||
roleKey: "org_admin",
|
||||
roleLabel: "Admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
},
|
||||
permissions,
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: account.id,
|
||||
activeOrganizationId: organizationContext.id,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResidentContext(citations: AiCitation[] = [residentCitation]): ElderAiResidentContext {
|
||||
return {
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
elderName: "王阿姨",
|
||||
dataScopes: ["elder", "health"],
|
||||
sections: ["Resident profile", "Health profile"],
|
||||
citations,
|
||||
promptContext: "Resident profile includes night wandering and prior fall history.",
|
||||
};
|
||||
}
|
||||
|
||||
function createModelOutput(overrides: Partial<ElderAiAnalysisOutput> = {}): ElderAiAnalysisOutput {
|
||||
return {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
title: "Increase night checks",
|
||||
priority: "high",
|
||||
rationale: "Additional observation reduces missed night wandering events.",
|
||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
||||
citationIds: ["resident-1"],
|
||||
},
|
||||
],
|
||||
dataGaps: [],
|
||||
citations: [residentCitation],
|
||||
confidence: 0.74,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
|
||||
const status = values.status === "completed" || values.status === "failed" ? values.status : "failed";
|
||||
const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : [];
|
||||
return {
|
||||
id: `analysis-${index + 1}`,
|
||||
organizationId: typeof values.organizationId === "string" ? values.organizationId : "org-1",
|
||||
elderId: typeof values.elderId === "string" ? values.elderId : "elder-1",
|
||||
actorAccountId: typeof values.actorAccountId === "string" ? values.actorAccountId : null,
|
||||
status,
|
||||
dataScopes,
|
||||
resultJson: values.resultJson ?? null,
|
||||
citationsJson: values.citationsJson ?? [],
|
||||
modelSummaryJson: values.modelSummaryJson ?? {},
|
||||
errorCategory: typeof values.errorCategory === "string" ? values.errorCategory : "",
|
||||
errorReason: typeof values.errorReason === "string" ? values.errorReason : "",
|
||||
createdAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
|
||||
updatedAt: new Date(`2026-07-02T0${index}:00:00.000Z`),
|
||||
};
|
||||
}
|
||||
|
||||
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
|
||||
const insertedValues: AnalysisInsertPayload[] = [];
|
||||
const insert = vi.fn(() => ({
|
||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||
insertedValues.push(values);
|
||||
return {
|
||||
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length)]),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(async () => selectRows),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { insertedValues, insert, select };
|
||||
}
|
||||
|
||||
function useDatabase(fake: AnalysisDatabaseFake): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
||||
}
|
||||
|
||||
function mockSuccessfulContext(citations?: AiCitation[]): void {
|
||||
vi.mocked(buildElderAiContext).mockResolvedValue({
|
||||
success: true,
|
||||
context: createResidentContext(citations),
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulKnowledge(): void {
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
||||
success: true,
|
||||
data: { results: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function modelMessage(output: unknown): { content: string } {
|
||||
return { content: JSON.stringify(output) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
||||
mockSuccessfulContext();
|
||||
mockSuccessfulKnowledge();
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput()));
|
||||
useDatabase(createDatabaseFake());
|
||||
});
|
||||
|
||||
describe("elder AI analysis service", () => {
|
||||
it("saves sanitized failed history when AI runtime config is missing", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue({ success: false, reason: "AI_API_KEY 未配置" });
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "failed",
|
||||
dataScopes: ["elder"],
|
||||
errorCategory: "missing_config",
|
||||
errorReason: "AI 服务未配置",
|
||||
}),
|
||||
]);
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
||||
expect(buildElderAiContext).not.toHaveBeenCalled();
|
||||
expect(ChatOpenAI).not.toHaveBeenCalled();
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务未配置" }));
|
||||
});
|
||||
|
||||
it("maps provider errors to sanitized failed history without persisting raw prompts or provider details", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockRejectedValue(new Error("provider exploded with sk-live-secret and full resident prompt"));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 服务暂时不可用", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
dataScopes: ["elder", "health"],
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "AI 服务暂时不可用",
|
||||
}),
|
||||
]);
|
||||
const persistedFailure = JSON.stringify(database.insertedValues[0]);
|
||||
expect(persistedFailure).not.toContain("sk-live-secret");
|
||||
expect(persistedFailure).not.toContain("full resident prompt");
|
||||
expect(persistedFailure).not.toContain("Night wandering");
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务暂时不可用" }));
|
||||
});
|
||||
|
||||
it("saves schema validation failures when the model output cannot be parsed into the analysis contract", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage({
|
||||
...createModelOutput(),
|
||||
overallRiskLevel: "urgent",
|
||||
}));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
errorCategory: "schema_validation_failed",
|
||||
errorReason: "AI 返回结构不符合固定分析格式",
|
||||
}),
|
||||
]);
|
||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
||||
});
|
||||
|
||||
it("degrades analysis when knowledge retrieval fails and records the retrieval audit failure", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({ success: false, reason: "vector store unavailable", status: 500 });
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({ dataGaps: ["No gait score available."] })));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "ai.knowledge.retrieve",
|
||||
result: "failure",
|
||||
reason: "知识库检索失败",
|
||||
}));
|
||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "ai.elder_analysis.generate",
|
||||
result: "success",
|
||||
}));
|
||||
expect(database.insertedValues[0]).toEqual(expect.objectContaining({
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
}));
|
||||
const resultJson = database.insertedValues[0]?.resultJson;
|
||||
expect(resultJson).toEqual(expect.objectContaining({
|
||||
dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("redacts analysis history when the caller lacks a scope permission stored on the history row", async () => {
|
||||
const completedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: createModelOutput(),
|
||||
citationsJson: [residentCitation],
|
||||
modelSummaryJson: createModelOutput().modelSummary,
|
||||
}, 0);
|
||||
useDatabase(createDatabaseFake([completedRow]));
|
||||
|
||||
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
history: [
|
||||
{
|
||||
id: "analysis-1",
|
||||
elderId: "elder-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
restricted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
||||
keyFindings: [
|
||||
{
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "The model references an unavailable source.",
|
||||
citationIds: ["hallucinated-source"],
|
||||
},
|
||||
],
|
||||
citations: [],
|
||||
})));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
||||
expect(database.insertedValues).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
errorCategory: "schema_validation_failed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists only allowed citations actually referenced by findings or recommendations", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
mockSuccessfulContext([residentCitation, unusedResidentCitation]);
|
||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
||||
success: true,
|
||||
data: { results: [knowledgeCitation] },
|
||||
});
|
||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
||||
citations: [residentCitation],
|
||||
dataGaps: [],
|
||||
})));
|
||||
|
||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const resultJson = database.insertedValues[0]?.resultJson;
|
||||
expect(resultJson).toEqual(expect.objectContaining({
|
||||
citations: [residentCitation],
|
||||
}));
|
||||
expect(database.insertedValues[0]?.citationsJson).toEqual([residentCitation]);
|
||||
});
|
||||
});
|
||||
363
modules/ai/server/analysis.ts
Normal file
363
modules/ai/server/analysis.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
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 type {
|
||||
AiCitation,
|
||||
AiErrorCategory,
|
||||
ElderAiAnalysisHistoryItem,
|
||||
ElderAiAnalysisOutput,
|
||||
} from "@/modules/ai/types";
|
||||
import {
|
||||
canViewAnalysisScopes,
|
||||
parseDataScopes,
|
||||
validateElderAiAnalysisOutput,
|
||||
} from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { elderAiAnalyses } from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
||||
|
||||
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
|
||||
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
||||
return new ChatOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
model: config.chatModel,
|
||||
maxTokens: 1800,
|
||||
temperature: 0.2,
|
||||
configuration,
|
||||
});
|
||||
}
|
||||
|
||||
function toIsoString(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function mapErrorCategory(error: unknown): AiErrorCategory {
|
||||
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) {
|
||||
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 {
|
||||
...output,
|
||||
citations: citations.filter((citation) => referencedIds.has(citation.id)),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFailedAnalysis(input: {
|
||||
context: AuthContext;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
dataScopes: string[];
|
||||
category: AiErrorCategory;
|
||||
}): Promise<void> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem {
|
||||
const scopes = parseDataScopes(row.dataScopes);
|
||||
const restricted = !canViewAnalysisScopes(scopes, permissions);
|
||||
if (restricted) {
|
||||
return {
|
||||
id: row.id,
|
||||
elderId: row.elderId,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = validateElderAiAnalysisOutput(row.resultJson);
|
||||
const errorCategory = row.errorCategory as AiErrorCategory;
|
||||
return {
|
||||
id: row.id,
|
||||
elderId: row.elderId,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: false,
|
||||
result: result ?? undefined,
|
||||
errorCategory: errorCategory || undefined,
|
||||
errorReason: row.errorReason || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listElderAiAnalyses(
|
||||
context: AuthContext,
|
||||
elderId: string,
|
||||
): Promise<ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>> {
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
||||
}
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(elderAiAnalyses)
|
||||
.where(and(eq(elderAiAnalyses.organizationId, organizationId), eq(elderAiAnalyses.elderId, elderId)))
|
||||
.orderBy(desc(elderAiAnalyses.createdAt))
|
||||
.limit(20);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
history: rows.map((row) => rowToHistoryItem(row, context.permissions)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateElderAiAnalysis(
|
||||
context: AuthContext,
|
||||
elderId: string,
|
||||
): 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);
|
||||
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}","embeddingModel":"${runtimeConfig.config.embeddingModel}"}。`,
|
||||
`可用 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 database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(elderAiAnalyses)
|
||||
.values({
|
||||
organizationId: residentContext.context.organizationId,
|
||||
elderId,
|
||||
actorAccountId: context.account.id,
|
||||
status: "completed",
|
||||
dataScopes,
|
||||
resultJson: normalizedOutput,
|
||||
citationsJson: normalizedOutput.citations,
|
||||
modelSummaryJson: normalizedOutput.modelSummary,
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return { success: false, reason: "AI 分析保存失败", status: 500 };
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: context.account,
|
||||
organizationId: residentContext.context.organizationId,
|
||||
action: "ai.elder_analysis.generate",
|
||||
targetType: "elder",
|
||||
targetId: elderId,
|
||||
result: "success",
|
||||
reason: "AI 分析已生成",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
analysis: rowToHistoryItem(row, context.permissions),
|
||||
},
|
||||
};
|
||||
}
|
||||
38
modules/ai/server/config.ts
Normal file
38
modules/ai/server/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AI_KNOWLEDGE_EMBEDDING_DIMENSIONS } from "@/modules/core/server/schema";
|
||||
|
||||
export type AiRuntimeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
chatModel: string;
|
||||
embeddingModel: string;
|
||||
embeddingDimensions: number;
|
||||
};
|
||||
|
||||
export type AiRuntimeConfigResult =
|
||||
| { success: true; config: AiRuntimeConfig }
|
||||
| { success: false; reason: string };
|
||||
|
||||
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
||||
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
||||
|
||||
function readOptionalEnv(name: string): string {
|
||||
return process.env[name]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
||||
const apiKey = readOptionalEnv("AI_API_KEY");
|
||||
if (!apiKey) {
|
||||
return { success: false, reason: "AI_API_KEY 未配置" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
||||
apiKey,
|
||||
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
||||
embeddingModel: readOptionalEnv("AI_EMBEDDING_MODEL") || DEFAULT_EMBEDDING_MODEL,
|
||||
embeddingDimensions: AI_KNOWLEDGE_EMBEDDING_DIMENSIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
331
modules/ai/server/elder-context.ts
Normal file
331
modules/ai/server/elder-context.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { AiCitation, ElderAiDataScope } from "@/modules/ai/types";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import {
|
||||
admissions,
|
||||
alertTriggers,
|
||||
beds,
|
||||
careTasks,
|
||||
chronicConditions,
|
||||
elders,
|
||||
familyFeedback,
|
||||
healthAnomalyReviews,
|
||||
healthProfiles,
|
||||
rooms,
|
||||
systemIncidents,
|
||||
vitalRecords,
|
||||
} from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type ElderAiContextResult =
|
||||
| { success: true; context: ElderAiResidentContext }
|
||||
| { success: false; reason: string; status: number };
|
||||
|
||||
export type ElderAiResidentContext = {
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
dataScopes: ElderAiDataScope[];
|
||||
sections: string[];
|
||||
citations: AiCitation[];
|
||||
promptContext: string;
|
||||
};
|
||||
|
||||
function hasPermission(context: AuthContext, permission: string): boolean {
|
||||
return context.permissions.includes(permission as never);
|
||||
}
|
||||
|
||||
function toIsoString(value: Date | null): string {
|
||||
return value ? value.toISOString() : "";
|
||||
}
|
||||
|
||||
function pushCitation(citations: AiCitation[], input: Omit<AiCitation, "id">): string {
|
||||
const id = `ctx-${citations.length + 1}`;
|
||||
citations.push({ id, ...input });
|
||||
return id;
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function appendSection(parts: string[], title: string, lines: string[]): void {
|
||||
const compactLines = lines.map(compactText).filter(Boolean);
|
||||
if (compactLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
parts.push(`## ${title}\n${compactLines.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function buildElderAiContext(context: AuthContext, elderId: string): Promise<ElderAiContextResult> {
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后生成 AI 分析", status: 400 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const elderRows = await database
|
||||
.select()
|
||||
.from(elders)
|
||||
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
const elder = elderRows[0];
|
||||
if (!elder) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
const dataScopes: ElderAiDataScope[] = ["elder"];
|
||||
const citations: AiCitation[] = [];
|
||||
const sections: string[] = [];
|
||||
const elderCitationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: elder.id,
|
||||
title: "老人基础档案",
|
||||
excerpt: `${elder.name},${elder.age} 岁,照护等级 ${elder.careLevel},状态 ${elder.status}`,
|
||||
});
|
||||
|
||||
appendSection(sections, "老人基础档案", [
|
||||
`[${elderCitationId}] 姓名:${elder.name};性别:${elder.gender};年龄:${elder.age};照护等级:${elder.careLevel};状态:${elder.status}`,
|
||||
`主要联系人:${elder.primaryContact};电话:${elder.phone}`,
|
||||
elder.medicalNotes ? `医疗备注:${elder.medicalNotes}` : "",
|
||||
]);
|
||||
|
||||
const [
|
||||
admissionRows,
|
||||
healthProfileRows,
|
||||
vitalRows,
|
||||
conditionRows,
|
||||
reviewRows,
|
||||
careTaskRows,
|
||||
alertRows,
|
||||
feedbackRows,
|
||||
incidentRows,
|
||||
] = await Promise.all([
|
||||
hasPermission(context, "admission:read") || hasPermission(context, "facility:read")
|
||||
? database
|
||||
.select({
|
||||
admission: admissions,
|
||||
bedCode: beds.code,
|
||||
roomCode: rooms.code,
|
||||
})
|
||||
.from(admissions)
|
||||
.leftJoin(beds, eq(beds.id, admissions.bedId))
|
||||
.leftJoin(rooms, eq(rooms.id, beds.roomId))
|
||||
.where(and(eq(admissions.organizationId, organizationId), eq(admissions.elderId, elderId)))
|
||||
.orderBy(desc(admissions.admittedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(healthProfiles)
|
||||
.where(and(eq(healthProfiles.organizationId, organizationId), eq(healthProfiles.elderId, elderId)))
|
||||
.limit(1)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(vitalRecords)
|
||||
.where(and(eq(vitalRecords.organizationId, organizationId), eq(vitalRecords.elderId, elderId)))
|
||||
.orderBy(desc(vitalRecords.recordedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(chronicConditions)
|
||||
.where(and(eq(chronicConditions.organizationId, organizationId), eq(chronicConditions.elderId, elderId)))
|
||||
.orderBy(desc(chronicConditions.updatedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(healthAnomalyReviews)
|
||||
.where(and(eq(healthAnomalyReviews.organizationId, organizationId), eq(healthAnomalyReviews.elderId, elderId)))
|
||||
.orderBy(desc(healthAnomalyReviews.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "care:read")
|
||||
? database
|
||||
.select()
|
||||
.from(careTasks)
|
||||
.where(and(eq(careTasks.organizationId, organizationId), eq(careTasks.elderId, elderId)))
|
||||
.orderBy(desc(careTasks.scheduledAt))
|
||||
.limit(12)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "alert:read")
|
||||
? database
|
||||
.select()
|
||||
.from(alertTriggers)
|
||||
.where(and(eq(alertTriggers.organizationId, organizationId), eq(alertTriggers.elderId, elderId)))
|
||||
.orderBy(desc(alertTriggers.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "family:read")
|
||||
? database
|
||||
.select()
|
||||
.from(familyFeedback)
|
||||
.where(and(eq(familyFeedback.organizationId, organizationId), eq(familyFeedback.elderId, elderId)))
|
||||
.orderBy(desc(familyFeedback.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "incident:read")
|
||||
? database
|
||||
.select()
|
||||
.from(systemIncidents)
|
||||
.where(eq(systemIncidents.organizationId, organizationId))
|
||||
.orderBy(desc(systemIncidents.createdAt))
|
||||
.limit(6)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (admissionRows.length > 0) {
|
||||
if (hasPermission(context, "admission:read")) {
|
||||
dataScopes.push("admission");
|
||||
}
|
||||
if (hasPermission(context, "facility:read")) {
|
||||
dataScopes.push("facility");
|
||||
}
|
||||
appendSection(
|
||||
sections,
|
||||
"入住与床位",
|
||||
admissionRows.map((row) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: row.admission.id,
|
||||
title: "入住记录",
|
||||
excerpt: `${row.admission.status},床位 ${row.roomCode ?? ""}-${row.bedCode ?? ""}`,
|
||||
});
|
||||
return `[${citationId}] 状态:${row.admission.status};房间/床位:${row.roomCode ?? "未知"}/${row.bedCode ?? "未知"};入住:${toIsoString(row.admission.admittedAt)};退住:${toIsoString(row.admission.dischargedAt)};备注:${row.admission.notes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "health:read")) {
|
||||
dataScopes.push("health");
|
||||
const profile = healthProfileRows[0];
|
||||
appendSection(sections, "健康档案", [
|
||||
profile
|
||||
? `[${pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: profile.id,
|
||||
title: "健康档案",
|
||||
excerpt: profile.medicalHistory || profile.medicationNotes || profile.emergencyNotes || "健康档案",
|
||||
})}] 过敏:${profile.allergyNotes};病史:${profile.medicalHistory};用药:${profile.medicationNotes};照护限制:${profile.careRestrictions};紧急备注:${profile.emergencyNotes}`
|
||||
: "暂无健康档案",
|
||||
...vitalRows.map((vital) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: vital.id,
|
||||
title: "生命体征",
|
||||
excerpt: `${toIsoString(vital.recordedAt)} 心率 ${vital.heartRate ?? "-"} 血压 ${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"}`,
|
||||
});
|
||||
return `[${citationId}] ${toIsoString(vital.recordedAt)};来源:${vital.source};血压:${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"};心率:${vital.heartRate ?? "-"};体温:${vital.temperatureTenths ? vital.temperatureTenths / 10 : "-"};血氧:${vital.spo2 ?? "-"};备注:${vital.notes}`;
|
||||
}),
|
||||
...conditionRows.map((condition) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: condition.id,
|
||||
title: "慢病记录",
|
||||
excerpt: `${condition.name} ${condition.status}`,
|
||||
});
|
||||
return `[${citationId}] 慢病:${condition.name};状态:${condition.status};治疗:${condition.treatmentNotes};随访:${condition.followUpNotes}`;
|
||||
}),
|
||||
...reviewRows.map((review) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: review.id,
|
||||
title: "健康异常复核",
|
||||
excerpt: `${review.severity} ${review.title}`,
|
||||
});
|
||||
return `[${citationId}] 异常:${review.title};严重度:${review.severity};状态:${review.status};说明:${review.description};处理:${review.resolutionNotes}`;
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "care:read")) {
|
||||
dataScopes.push("care");
|
||||
appendSection(
|
||||
sections,
|
||||
"护理任务",
|
||||
careTaskRows.map((task) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: task.id,
|
||||
title: "护理任务",
|
||||
excerpt: `${task.priority} ${task.title}`,
|
||||
});
|
||||
return `[${citationId}] ${task.title};类型:${task.careType};优先级:${task.priority};状态:${task.status};计划:${toIsoString(task.scheduledAt)};执行备注:${task.executionNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "alert:read")) {
|
||||
dataScopes.push("alert");
|
||||
appendSection(
|
||||
sections,
|
||||
"预警触发",
|
||||
alertRows.map((alert) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: alert.id,
|
||||
title: "预警触发",
|
||||
excerpt: `${alert.title} ${alert.status}`,
|
||||
});
|
||||
return `[${citationId}] ${alert.title};状态:${alert.status};来源:${alert.source};说明:${alert.description};处理:${alert.handlingNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "family:read")) {
|
||||
dataScopes.push("family");
|
||||
appendSection(
|
||||
sections,
|
||||
"家属反馈",
|
||||
feedbackRows.map((feedback) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: feedback.id,
|
||||
title: "家属反馈",
|
||||
excerpt: `${feedback.feedbackType} ${feedback.status}`,
|
||||
});
|
||||
return `[${citationId}] 类型:${feedback.feedbackType};状态:${feedback.status};内容:${feedback.content};回复:${feedback.responseNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "incident:read")) {
|
||||
dataScopes.push("incident");
|
||||
appendSection(
|
||||
sections,
|
||||
"近期机构事件",
|
||||
incidentRows.map((incident) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: incident.id,
|
||||
title: "系统/应急事件",
|
||||
excerpt: `${incident.severity} ${incident.title}`,
|
||||
});
|
||||
return `[${citationId}] ${incident.title};严重度:${incident.severity};状态:${incident.status};来源:${incident.source};说明:${incident.description}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueScopes = [...new Set(dataScopes)];
|
||||
return {
|
||||
success: true,
|
||||
context: {
|
||||
organizationId,
|
||||
elderId,
|
||||
elderName: elder.name,
|
||||
dataScopes: uniqueScopes,
|
||||
sections,
|
||||
citations,
|
||||
promptContext: sections.join("\n\n"),
|
||||
},
|
||||
};
|
||||
}
|
||||
479
modules/ai/server/knowledge.test.ts
Normal file
479
modules/ai/server/knowledge.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import {
|
||||
createKnowledgeEntry,
|
||||
listKnowledgeEntries,
|
||||
retrieveKnowledge,
|
||||
updateKnowledgeEntry,
|
||||
} from "@/modules/ai/server/knowledge";
|
||||
import type { KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import type { AppDatabase } from "@/modules/core/server/db";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import type { AuthContext, Permission } from "@/modules/core/types";
|
||||
|
||||
const embeddingMocks = vi.hoisted(() => ({
|
||||
embedDocuments: vi.fn(),
|
||||
}));
|
||||
|
||||
const drizzleDsl = vi.hoisted(() => {
|
||||
type TableName = "entries" | "chunks";
|
||||
type ColumnRef = { table: TableName; key: string };
|
||||
const entries = {
|
||||
id: { table: "entries", key: "id" } as ColumnRef,
|
||||
scope: { table: "entries", key: "scope" } as ColumnRef,
|
||||
organizationId: { table: "entries", key: "organizationId" } as ColumnRef,
|
||||
status: { table: "entries", key: "status" } as ColumnRef,
|
||||
updatedAt: { table: "entries", key: "updatedAt" } as ColumnRef,
|
||||
};
|
||||
const chunks = {
|
||||
id: { table: "chunks", key: "id" } as ColumnRef,
|
||||
entryId: { table: "chunks", key: "entryId" } as ColumnRef,
|
||||
scope: { table: "chunks", key: "scope" } as ColumnRef,
|
||||
organizationId: { table: "chunks", key: "organizationId" } as ColumnRef,
|
||||
embedding: { table: "chunks", key: "embedding" } as ColumnRef,
|
||||
sourceTitle: { table: "chunks", key: "sourceTitle" } as ColumnRef,
|
||||
sourceCategory: { table: "chunks", key: "sourceCategory" } as ColumnRef,
|
||||
content: { table: "chunks", key: "content" } as ColumnRef,
|
||||
};
|
||||
return { entries, chunks };
|
||||
});
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@langchain/openai", () => ({
|
||||
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
|
||||
return {
|
||||
embedDocuments: embeddingMocks.embedDocuments,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ai/server/config", () => ({
|
||||
getAiRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/db", () => ({
|
||||
getDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/server/schema", () => ({
|
||||
aiKnowledgeEntries: drizzleDsl.entries,
|
||||
aiKnowledgeChunks: drizzleDsl.chunks,
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => {
|
||||
type JoinedRow = {
|
||||
entries?: Record<string, unknown>;
|
||||
chunks?: Record<string, unknown>;
|
||||
};
|
||||
type ColumnRef = { table: "entries" | "chunks"; key: string };
|
||||
type Predicate = (row: JoinedRow) => boolean;
|
||||
|
||||
function readColumn(row: JoinedRow, column: ColumnRef): unknown {
|
||||
return row[column.table]?.[column.key];
|
||||
}
|
||||
|
||||
return {
|
||||
eq: (column: ColumnRef, expected: unknown): Predicate => (row) => readColumn(row, column) === expected,
|
||||
isNull: (column: ColumnRef): Predicate => (row) => readColumn(row, column) === null || readColumn(row, column) === undefined,
|
||||
and: (...predicates: Predicate[]): Predicate => (row) => predicates.every((predicate) => predicate(row)),
|
||||
or: (...predicates: Predicate[]): Predicate => (row) => predicates.some((predicate) => predicate(row)),
|
||||
desc: (column: ColumnRef) => ({ type: "desc", column }),
|
||||
sql: () => ({ type: "distance" }),
|
||||
};
|
||||
});
|
||||
|
||||
type KnowledgeScope = "platform" | "organization";
|
||||
type KnowledgeStatus = "enabled" | "disabled";
|
||||
|
||||
type KnowledgeRow = {
|
||||
id: string;
|
||||
scope: KnowledgeScope;
|
||||
organizationId: string | null;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: KnowledgeStatus;
|
||||
createdByAccountId: string | null;
|
||||
updatedByAccountId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type KnowledgeChunkRow = {
|
||||
id: string;
|
||||
entryId: string;
|
||||
organizationId: string | null;
|
||||
scope: KnowledgeScope;
|
||||
chunkIndex: number;
|
||||
content: string;
|
||||
embedding: number[];
|
||||
sourceTitle: string;
|
||||
sourceCategory: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type JoinedRow = {
|
||||
entries?: KnowledgeRow;
|
||||
chunks?: KnowledgeChunkRow;
|
||||
};
|
||||
|
||||
type Predicate = (row: JoinedRow) => boolean;
|
||||
type SelectRow = KnowledgeRow | RetrievedChunkRow;
|
||||
|
||||
type RetrievedChunkRow = {
|
||||
entryId: string;
|
||||
chunkId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
embedding: number[];
|
||||
};
|
||||
|
||||
type KnowledgeDatabaseFake = {
|
||||
entries: KnowledgeRow[];
|
||||
chunks: KnowledgeChunkRow[];
|
||||
transaction: ReturnlessFunction;
|
||||
select: ReturnlessFunction;
|
||||
};
|
||||
|
||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
const runtimeConfig: AiRuntimeConfigResult = {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: "",
|
||||
apiKey: "test-api-key",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
embeddingDimensions: 1536,
|
||||
},
|
||||
};
|
||||
|
||||
const organization: NonNullable<AuthContext["organization"]> = {
|
||||
id: "org-1",
|
||||
name: "TeaCare Home",
|
||||
slug: "teacare",
|
||||
status: "active",
|
||||
registrationEnabled: true,
|
||||
oidcEnabled: false,
|
||||
oidcIssuerUrl: "",
|
||||
oidcClientId: "",
|
||||
oidcHasClientSecret: false,
|
||||
oidcScopes: "",
|
||||
oidcRedirectUri: "",
|
||||
oidcAvatarClaim: "",
|
||||
oidcAutoProvision: false,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const account: AuthContext["account"] = {
|
||||
id: "account-1",
|
||||
name: "Knowledge Admin",
|
||||
email: "knowledge@example.com",
|
||||
avatarUrl: "",
|
||||
role: "org_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const baseInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Keep walkways clear and increase night rounds after wandering events.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
||||
|
||||
function createAuthContext(input: { permissions?: Permission[]; activeOrganization?: boolean } = {}): AuthContext {
|
||||
const activeOrganization = input.activeOrganization ?? true;
|
||||
const permissions = input.permissions ?? ["knowledge:read", "knowledge:manage"];
|
||||
return {
|
||||
account,
|
||||
organization: activeOrganization ? organizationContext : undefined,
|
||||
organizations: [
|
||||
{
|
||||
id: organizationContext.id,
|
||||
name: organizationContext.name,
|
||||
slug: organizationContext.slug,
|
||||
status: organizationContext.status,
|
||||
registrationEnabled: organizationContext.registrationEnabled,
|
||||
oidcEnabled: organizationContext.oidcEnabled,
|
||||
roleLabel: "Admin",
|
||||
isActive: activeOrganization,
|
||||
},
|
||||
],
|
||||
membership: activeOrganization
|
||||
? {
|
||||
id: "membership-1",
|
||||
accountId: account.id,
|
||||
organizationId: organizationContext.id,
|
||||
roleId: "role-1",
|
||||
roleKey: "org_admin",
|
||||
roleLabel: "Admin",
|
||||
status: "active",
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
updatedAt: "2026-07-02T00:00:00.000Z",
|
||||
}
|
||||
: undefined,
|
||||
permissions,
|
||||
session: {
|
||||
id: "session-1",
|
||||
accountId: account.id,
|
||||
activeOrganizationId: activeOrganization ? organizationContext.id : undefined,
|
||||
createdAt: "2026-07-02T00:00:00.000Z",
|
||||
expiresAt: "2026-07-09T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEntry(overrides: Partial<KnowledgeRow> = {}): KnowledgeRow {
|
||||
return {
|
||||
id: "entry-org-1",
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Org fall prevention",
|
||||
category: "Safety",
|
||||
tags: "fall",
|
||||
body: "Organization-specific fall prevention guidance.",
|
||||
status: "enabled",
|
||||
createdByAccountId: "account-1",
|
||||
updatedByAccountId: "account-1",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow> = {}): KnowledgeChunkRow {
|
||||
return {
|
||||
id: `${entry.id}-chunk-1`,
|
||||
entryId: entry.id,
|
||||
organizationId: entry.organizationId,
|
||||
scope: entry.scope,
|
||||
chunkIndex: 0,
|
||||
content: entry.body,
|
||||
embedding: [0.1, 0.2, 0.3],
|
||||
sourceTitle: entry.title,
|
||||
sourceCategory: entry.category,
|
||||
createdAt: new Date("2026-07-02T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class SelectBuilder implements PromiseLike<SelectRow[]> {
|
||||
private table: "entries" | "chunks" = "entries";
|
||||
private predicate: Predicate = () => true;
|
||||
|
||||
constructor(
|
||||
private readonly entries: KnowledgeRow[],
|
||||
private readonly chunks: KnowledgeChunkRow[],
|
||||
) {}
|
||||
|
||||
from(table: unknown): SelectBuilder {
|
||||
this.table = table === drizzleDsl.chunks ? "chunks" : "entries";
|
||||
return this;
|
||||
}
|
||||
|
||||
innerJoin(): SelectBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
where(predicate: Predicate): SelectBuilder {
|
||||
this.predicate = predicate;
|
||||
return this;
|
||||
}
|
||||
|
||||
orderBy(): SelectBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
limit(count: number): Promise<SelectRow[]> {
|
||||
return Promise.resolve(this.execute().slice(0, count));
|
||||
}
|
||||
|
||||
then<TResult1 = SelectRow[], TResult2 = never>(
|
||||
onfulfilled?: ((value: SelectRow[]) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return Promise.resolve(this.execute()).then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
private execute(): SelectRow[] {
|
||||
if (this.table === "entries") {
|
||||
return this.entries
|
||||
.filter((entry) => this.predicate({ entries: entry }))
|
||||
.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
|
||||
}
|
||||
|
||||
const rows: RetrievedChunkRow[] = [];
|
||||
for (const chunk of this.chunks) {
|
||||
const entry = this.entries.find((candidate) => candidate.id === chunk.entryId);
|
||||
if (entry && this.predicate({ entries: entry, chunks: chunk })) {
|
||||
rows.push({
|
||||
entryId: chunk.entryId,
|
||||
chunkId: chunk.id,
|
||||
title: chunk.sourceTitle,
|
||||
category: chunk.sourceCategory,
|
||||
content: chunk.content,
|
||||
embedding: chunk.embedding,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
function createDatabaseFake(input: { entries?: KnowledgeRow[]; chunks?: KnowledgeChunkRow[] } = {}): KnowledgeDatabaseFake {
|
||||
const entries = input.entries ?? [];
|
||||
const chunks = input.chunks ?? [];
|
||||
return {
|
||||
entries,
|
||||
chunks,
|
||||
transaction: vi.fn(),
|
||||
select: vi.fn(() => new SelectBuilder(entries, chunks)),
|
||||
};
|
||||
}
|
||||
|
||||
function useDatabase(fake: KnowledgeDatabaseFake): void {
|
||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
||||
embeddingMocks.embedDocuments.mockResolvedValue([[0.4, 0.5, 0.6]]);
|
||||
useDatabase(createDatabaseFake());
|
||||
});
|
||||
|
||||
describe("AI knowledge service", () => {
|
||||
it("lists platform and active-organization entries while excluding other organizations", async () => {
|
||||
const platformEntry = createEntry({
|
||||
id: "entry-platform",
|
||||
scope: "platform",
|
||||
organizationId: null,
|
||||
title: "Platform safety standard",
|
||||
updatedAt: new Date("2026-07-03T00:00:00.000Z"),
|
||||
});
|
||||
const orgEntry = createEntry({ id: "entry-org-1", title: "Org safety note" });
|
||||
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org note" });
|
||||
useDatabase(createDatabaseFake({ entries: [orgEntry, otherOrgEntry, platformEntry] }));
|
||||
|
||||
const entries = await listKnowledgeEntries(createAuthContext());
|
||||
|
||||
expect(entries.map((entry) => entry.id)).toEqual(["entry-platform", "entry-org-1"]);
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({ id: "entry-platform", scope: "platform", organizationId: undefined }),
|
||||
expect.objectContaining({ id: "entry-org-1", scope: "organization", organizationId: "org-1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retrieves only enabled platform and active-organization chunks", async () => {
|
||||
const platformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null, title: "Platform standard" });
|
||||
const orgEntry = createEntry({ id: "entry-org-1", title: "Org guidance" });
|
||||
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled guidance" });
|
||||
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org guidance" });
|
||||
const database = createDatabaseFake({
|
||||
entries: [platformEntry, orgEntry, disabledEntry, otherOrgEntry],
|
||||
chunks: [
|
||||
createChunk(platformEntry),
|
||||
createChunk(orgEntry),
|
||||
createChunk(disabledEntry),
|
||||
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
|
||||
],
|
||||
});
|
||||
useDatabase(database);
|
||||
|
||||
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 10 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
results: [
|
||||
expect.objectContaining({ entryId: "entry-platform", title: "Platform standard" }),
|
||||
expect.objectContaining({ entryId: "entry-org-1", title: "Org guidance" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(embeddingMocks.embedDocuments).toHaveBeenCalledWith(["night fall risk"]);
|
||||
});
|
||||
|
||||
it("orders retrieved chunks by cosine similarity after scope filtering", async () => {
|
||||
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance" });
|
||||
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance" });
|
||||
useDatabase(createDatabaseFake({
|
||||
entries: [weakEntry, strongEntry],
|
||||
chunks: [
|
||||
createChunk(weakEntry, { embedding: [0, 1, 0] }),
|
||||
createChunk(strongEntry, { embedding: [1, 0, 0] }),
|
||||
],
|
||||
}));
|
||||
embeddingMocks.embedDocuments.mockResolvedValueOnce([[1, 0, 0]]);
|
||||
|
||||
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 2 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
results: [
|
||||
expect.objectContaining({ entryId: "entry-strong", score: 1 }),
|
||||
expect.objectContaining({ entryId: "entry-weak", score: 0 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires platform management permission before mutating platform knowledge", async () => {
|
||||
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
|
||||
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));
|
||||
|
||||
const result = await updateKnowledgeEntry(
|
||||
createAuthContext({ permissions: ["knowledge:manage"] }),
|
||||
"entry-platform",
|
||||
baseInput,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an active organization before mutating organization knowledge", async () => {
|
||||
const result = await createKnowledgeEntry(
|
||||
createAuthContext({ activeOrganization: false, permissions: ["knowledge:manage"] }),
|
||||
baseInput,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects attempts to mutate another organization's knowledge", async () => {
|
||||
const result = await createKnowledgeEntry(createAuthContext(), {
|
||||
...baseInput,
|
||||
organizationId: "org-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
|
||||
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces embedding failures without opening a write transaction", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
embeddingMocks.embedDocuments.mockRejectedValue(new Error("embedding provider unavailable"));
|
||||
|
||||
const result = await createKnowledgeEntry(createAuthContext(), baseInput);
|
||||
|
||||
expect(result).toEqual({ success: false, reason: "知识库向量生成失败", status: 500 });
|
||||
expect(database.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
392
modules/ai/server/knowledge.ts
Normal file
392
modules/ai/server/knowledge.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import "server-only";
|
||||
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import type {
|
||||
AiKnowledgeScope,
|
||||
KnowledgeEntry,
|
||||
KnowledgeEntryInput,
|
||||
KnowledgeRetrievalResult,
|
||||
} from "@/modules/ai/types";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { aiKnowledgeChunks, aiKnowledgeEntries } from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type KnowledgeRow = typeof aiKnowledgeEntries.$inferSelect;
|
||||
|
||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||
|
||||
const CHUNK_TARGET_SIZE = 900;
|
||||
const CHUNK_OVERLAP_SIZE = 160;
|
||||
const DEFAULT_RETRIEVAL_LIMIT = 6;
|
||||
|
||||
function toIsoString(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function toKnowledgeEntry(row: KnowledgeRow): KnowledgeEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
organizationId: row.organizationId ?? undefined,
|
||||
title: row.title,
|
||||
category: row.category,
|
||||
tags: row.tags,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
updatedAt: toIsoString(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function createEmbeddings() {
|
||||
const configResult = getAiRuntimeConfig();
|
||||
if (!configResult.success) {
|
||||
return configResult;
|
||||
}
|
||||
const configuration = configResult.config.baseUrl ? { baseURL: configResult.config.baseUrl } : undefined;
|
||||
return {
|
||||
success: true as const,
|
||||
config: configResult.config,
|
||||
embeddings: new OpenAIEmbeddings({
|
||||
apiKey: configResult.config.apiKey,
|
||||
model: configResult.config.embeddingModel,
|
||||
dimensions: configResult.config.embeddingDimensions,
|
||||
configuration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function chunkKnowledgeText(input: KnowledgeEntryInput): string[] {
|
||||
const prefix = [input.title, input.category, input.tags].filter(Boolean).join("\n");
|
||||
const text = `${prefix}\n\n${input.body}`.trim();
|
||||
if (text.length <= CHUNK_TARGET_SIZE) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + CHUNK_TARGET_SIZE, text.length);
|
||||
chunks.push(text.slice(start, end).trim());
|
||||
if (end >= text.length) {
|
||||
break;
|
||||
}
|
||||
start = Math.max(end - CHUNK_OVERLAP_SIZE, start + 1);
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureWritableScope(context: AuthContext, input: KnowledgeEntryInput): ServiceResult<{ organizationId: string | null }> {
|
||||
if (input.scope === "platform") {
|
||||
if (!context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
return { success: true, data: { organizationId: null } };
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后维护知识库", status: 400 };
|
||||
}
|
||||
if (input.organizationId && input.organizationId !== organizationId) {
|
||||
return { success: false, reason: "不能维护其他机构的知识库", status: 403 };
|
||||
}
|
||||
return { success: true, data: { organizationId } };
|
||||
}
|
||||
|
||||
function getVisibleKnowledgeWhere(organizationId?: string) {
|
||||
const platformFilter = and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId));
|
||||
if (!organizationId) {
|
||||
return platformFilter;
|
||||
}
|
||||
return or(
|
||||
platformFilter,
|
||||
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
|
||||
);
|
||||
}
|
||||
|
||||
function getEnabledChunkWhere(organizationId: string) {
|
||||
return and(
|
||||
eq(aiKnowledgeEntries.status, "enabled"),
|
||||
or(
|
||||
and(eq(aiKnowledgeChunks.scope, "platform"), isNull(aiKnowledgeChunks.organizationId)),
|
||||
and(eq(aiKnowledgeChunks.scope, "organization"), eq(aiKnowledgeChunks.organizationId, organizationId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildKnowledgeChunks(input: KnowledgeEntryInput): Promise<ServiceResult<{ chunks: string[]; vectors: number[][] }>> {
|
||||
const embeddingsResult = createEmbeddings();
|
||||
if (!embeddingsResult.success) {
|
||||
return { success: false, reason: embeddingsResult.reason, status: 500 };
|
||||
}
|
||||
|
||||
const chunks = chunkKnowledgeText(input);
|
||||
try {
|
||||
const vectors = await embeddingsResult.embeddings.embedDocuments(chunks);
|
||||
return { success: true, data: { chunks, vectors } };
|
||||
} catch {
|
||||
return { success: false, reason: "知识库向量生成失败", status: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listKnowledgeEntries(context: AuthContext): Promise<KnowledgeEntry[]> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(getVisibleKnowledgeWhere(context.organization?.id))
|
||||
.orderBy(desc(aiKnowledgeEntries.updatedAt));
|
||||
return rows.map(toKnowledgeEntry);
|
||||
}
|
||||
|
||||
export async function createKnowledgeEntry(
|
||||
context: AuthContext,
|
||||
input: KnowledgeEntryInput,
|
||||
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const scope = ensureWritableScope(context, input);
|
||||
if (!scope.success) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const build = await buildKnowledgeChunks(input);
|
||||
if (!build.success) {
|
||||
return build;
|
||||
}
|
||||
|
||||
const row = await database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.insert(aiKnowledgeEntries)
|
||||
.values({
|
||||
scope: input.scope,
|
||||
organizationId: scope.data.organizationId,
|
||||
title: input.title,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
createdByAccountId: context.account.id,
|
||||
updatedByAccountId: context.account.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const entry = rows[0];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (build.data.chunks.length > 0) {
|
||||
await transaction.insert(aiKnowledgeChunks).values(
|
||||
build.data.chunks.map((content, index) => ({
|
||||
entryId: entry.id,
|
||||
organizationId: entry.organizationId,
|
||||
scope: entry.scope,
|
||||
chunkIndex: index,
|
||||
content,
|
||||
embedding: build.data.vectors[index] ?? [],
|
||||
sourceTitle: entry.title,
|
||||
sourceCategory: entry.category,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库创建失败", status: 500 };
|
||||
}
|
||||
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
async function updateEntryWithChunks(
|
||||
id: string,
|
||||
context: AuthContext,
|
||||
input: KnowledgeEntryInput,
|
||||
organizationId: string | null,
|
||||
): Promise<KnowledgeRow | null> {
|
||||
const build = await buildKnowledgeChunks(input);
|
||||
if (!build.success) {
|
||||
throw new Error(build.reason);
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
return database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.update(aiKnowledgeEntries)
|
||||
.set({
|
||||
scope: input.scope,
|
||||
organizationId,
|
||||
title: input.title,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
updatedByAccountId: context.account.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(aiKnowledgeEntries.id, id))
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await transaction.delete(aiKnowledgeChunks).where(eq(aiKnowledgeChunks.entryId, row.id));
|
||||
if (build.data.chunks.length > 0) {
|
||||
await transaction.insert(aiKnowledgeChunks).values(
|
||||
build.data.chunks.map((content, index) => ({
|
||||
entryId: row.id,
|
||||
organizationId: row.organizationId,
|
||||
scope: row.scope,
|
||||
chunkIndex: index,
|
||||
content,
|
||||
embedding: build.data.vectors[index] ?? [],
|
||||
sourceTitle: row.title,
|
||||
sourceCategory: row.category,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeEntry(
|
||||
context: AuthContext,
|
||||
id: string,
|
||||
input: KnowledgeEntryInput,
|
||||
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const scope = ensureWritableScope(context, input);
|
||||
if (!scope.success) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const existingRows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
if (!existing) {
|
||||
return { success: false, reason: "知识库条目不存在", status: 404 };
|
||||
}
|
||||
|
||||
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
|
||||
let row: KnowledgeRow | null;
|
||||
try {
|
||||
row = await updateEntryWithChunks(id, context, input, scope.data.organizationId);
|
||||
} catch {
|
||||
return { success: false, reason: "AI_API_KEY 未配置或知识库向量生成失败", status: 500 };
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库更新失败", status: 500 };
|
||||
}
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeEntry(context: AuthContext, id: string): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const database = getDatabase();
|
||||
const existingRows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
if (!existing) {
|
||||
return { success: false, reason: "知识库条目不存在", status: 404 };
|
||||
}
|
||||
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
|
||||
const rows = await database.delete(aiKnowledgeEntries).where(eq(aiKnowledgeEntries.id, id)).returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库删除失败", status: 500 };
|
||||
}
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
function calculateCosineSimilarity(left: readonly number[], right: readonly number[]): number {
|
||||
if (left.length === 0 || right.length === 0 || left.length !== right.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let dotProduct = 0;
|
||||
let leftMagnitude = 0;
|
||||
let rightMagnitude = 0;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftValue = left[index] ?? 0;
|
||||
const rightValue = right[index] ?? 0;
|
||||
dotProduct += leftValue * rightValue;
|
||||
leftMagnitude += leftValue * leftValue;
|
||||
rightMagnitude += rightValue * rightValue;
|
||||
}
|
||||
|
||||
if (leftMagnitude === 0 || rightMagnitude === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
|
||||
}
|
||||
|
||||
export async function retrieveKnowledge(
|
||||
organizationId: string,
|
||||
query: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<ServiceResult<{ results: KnowledgeRetrievalResult[] }>> {
|
||||
const embeddingsResult = createEmbeddings();
|
||||
if (!embeddingsResult.success) {
|
||||
return { success: false, reason: embeddingsResult.reason, status: 500 };
|
||||
}
|
||||
|
||||
const vectors = await embeddingsResult.embeddings.embedDocuments([query]).catch(() => []);
|
||||
const embedding = vectors[0];
|
||||
if (!embedding) {
|
||||
return { success: false, reason: "知识库向量生成失败", status: 500 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select({
|
||||
entryId: aiKnowledgeChunks.entryId,
|
||||
chunkId: aiKnowledgeChunks.id,
|
||||
title: aiKnowledgeChunks.sourceTitle,
|
||||
category: aiKnowledgeChunks.sourceCategory,
|
||||
content: aiKnowledgeChunks.content,
|
||||
embedding: aiKnowledgeChunks.embedding,
|
||||
})
|
||||
.from(aiKnowledgeChunks)
|
||||
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
|
||||
.where(getEnabledChunkWhere(organizationId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
results: rows
|
||||
.map((row) => ({
|
||||
entryId: row.entryId,
|
||||
chunkId: row.chunkId,
|
||||
title: row.title,
|
||||
category: row.category,
|
||||
content: row.content,
|
||||
score: calculateCosineSimilarity(row.embedding, embedding),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, options?.limit ?? DEFAULT_RETRIEVAL_LIMIT),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKnowledgeScope(scope: AiKnowledgeScope, organizationId?: string): string {
|
||||
return scope === "platform" ? "platform" : `organization:${organizationId ?? ""}`;
|
||||
}
|
||||
168
modules/ai/types.test.ts
Normal file
168
modules/ai/types.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AiCitation, ElderAiAnalysisOutput, ElderAiFinding, ElderAiRecommendation, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import {
|
||||
parseDataScopes,
|
||||
validateElderAiAnalysisOutput,
|
||||
validateKnowledgeEntryInput,
|
||||
} from "@/modules/ai/types";
|
||||
|
||||
const validKnowledgeInput: KnowledgeEntryInput = {
|
||||
scope: "organization",
|
||||
organizationId: "org-1",
|
||||
title: "Fall prevention guidance",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Residents with night wandering need bed exit checks and clear walkways.",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const validFinding: ElderAiFinding = {
|
||||
category: "fall-risk",
|
||||
severity: "warning",
|
||||
evidence: "Night wandering and prior fall history are present.",
|
||||
citationIds: ["resident-1"],
|
||||
};
|
||||
|
||||
const validRecommendation: ElderAiRecommendation = {
|
||||
title: "Increase night checks",
|
||||
priority: "high",
|
||||
rationale: "Additional observation reduces missed night wandering events.",
|
||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
||||
citationIds: ["resident-1"],
|
||||
};
|
||||
|
||||
const validCitation: AiCitation = {
|
||||
id: "resident-1",
|
||||
sourceType: "resident_context",
|
||||
sourceId: "elder-1",
|
||||
title: "Resident profile",
|
||||
excerpt: "Night wandering and prior fall history.",
|
||||
};
|
||||
|
||||
const validAnalysisOutput: ElderAiAnalysisOutput = {
|
||||
overallRiskLevel: "medium",
|
||||
summary: "Resident has elevated fall risk at night.",
|
||||
keyFindings: [validFinding],
|
||||
recommendations: [validRecommendation],
|
||||
dataGaps: ["Recent gait assessment is unavailable."],
|
||||
citations: [validCitation],
|
||||
confidence: 0.72,
|
||||
modelSummary: {
|
||||
provider: "openai-compatible",
|
||||
chatModel: "gpt-test",
|
||||
embeddingModel: "embedding-test",
|
||||
},
|
||||
};
|
||||
|
||||
describe("AI validators", () => {
|
||||
describe("validateKnowledgeEntryInput", () => {
|
||||
it("accepts a scoped entry and trims user-authored text fields", () => {
|
||||
const result = validateKnowledgeEntryInput({
|
||||
...validKnowledgeInput,
|
||||
title: " Fall prevention guidance ",
|
||||
category: " Safety ",
|
||||
tags: " fall,night ",
|
||||
body: " Residents need clear walkways. ",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
input: {
|
||||
...validKnowledgeInput,
|
||||
title: "Fall prevention guidance",
|
||||
category: "Safety",
|
||||
tags: "fall,night",
|
||||
body: "Residents need clear walkways.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "non-object payload", value: null, reason: "请求数据格式无效" },
|
||||
{ name: "unknown scope", value: { ...validKnowledgeInput, scope: "facility" }, reason: "知识库范围无效" },
|
||||
{ name: "unknown status", value: { ...validKnowledgeInput, status: "archived" }, reason: "知识库状态无效" },
|
||||
{ name: "blank title", value: { ...validKnowledgeInput, title: " " }, reason: "知识标题不能为空" },
|
||||
{ name: "blank body", value: { ...validKnowledgeInput, body: "\n\t" }, reason: "知识内容不能为空" },
|
||||
])("rejects $name", ({ value, reason }) => {
|
||||
expect(validateKnowledgeEntryInput(value)).toEqual({ success: false, reason });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateElderAiAnalysisOutput", () => {
|
||||
it("accepts valid model output, coerces numeric confidence strings, and clamps the public score", () => {
|
||||
const result = validateElderAiAnalysisOutput({
|
||||
...validAnalysisOutput,
|
||||
confidence: "1.25",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...validAnalysisOutput,
|
||||
confidence: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "unknown risk level",
|
||||
value: { ...validAnalysisOutput, overallRiskLevel: "urgent" },
|
||||
},
|
||||
{
|
||||
name: "finding with invalid severity",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
keyFindings: [{ ...validFinding, severity: "debug" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recommendation with invalid priority",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
recommendations: [{ ...validRecommendation, priority: "soon" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed citation id array",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
keyFindings: [{ ...validFinding, citationIds: ["resident-1", 42] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid citation source",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
citations: [{ ...validCitation, sourceType: "web" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unexpected model provider",
|
||||
value: {
|
||||
...validAnalysisOutput,
|
||||
modelSummary: { ...validAnalysisOutput.modelSummary, provider: "anthropic" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-finite confidence",
|
||||
value: { ...validAnalysisOutput, confidence: "not-a-number" },
|
||||
},
|
||||
])("rejects $name", ({ value }) => {
|
||||
expect(validateElderAiAnalysisOutput(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataScopes", () => {
|
||||
it("keeps only recognized scope strings in caller order", () => {
|
||||
expect(parseDataScopes(["elder", "health", "unknown", 7, "knowledge", "elder"])).toEqual([
|
||||
"elder",
|
||||
"health",
|
||||
"knowledge",
|
||||
"elder",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "elder", { scopes: ["elder"] }])("returns an empty list for non-array input %#", (value) => {
|
||||
expect(parseDataScopes(value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
332
modules/ai/types.ts
Normal file
332
modules/ai/types.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
|
||||
export const AI_KNOWLEDGE_SCOPES = ["platform", "organization"] as const;
|
||||
export type AiKnowledgeScope = (typeof AI_KNOWLEDGE_SCOPES)[number];
|
||||
|
||||
export const AI_KNOWLEDGE_STATUSES = ["enabled", "disabled"] as const;
|
||||
export type AiKnowledgeStatus = (typeof AI_KNOWLEDGE_STATUSES)[number];
|
||||
|
||||
export const ELDER_AI_ANALYSIS_STATUSES = ["completed", "failed"] as const;
|
||||
export type ElderAiAnalysisStatus = (typeof ELDER_AI_ANALYSIS_STATUSES)[number];
|
||||
|
||||
export const ELDER_AI_DATA_SCOPES = [
|
||||
"elder",
|
||||
"health",
|
||||
"care",
|
||||
"family",
|
||||
"admission",
|
||||
"facility",
|
||||
"alert",
|
||||
"incident",
|
||||
"knowledge",
|
||||
] as const;
|
||||
export type ElderAiDataScope = (typeof ELDER_AI_DATA_SCOPES)[number];
|
||||
|
||||
export const ELDER_AI_RISK_LEVELS = ["low", "medium", "high", "critical", "unknown"] as const;
|
||||
export type ElderAiRiskLevel = (typeof ELDER_AI_RISK_LEVELS)[number];
|
||||
|
||||
export const ELDER_AI_SEVERITIES = ["info", "warning", "critical"] as const;
|
||||
export type ElderAiSeverity = (typeof ELDER_AI_SEVERITIES)[number];
|
||||
|
||||
export const ELDER_AI_RECOMMENDATION_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||
export type ElderAiRecommendationPriority = (typeof ELDER_AI_RECOMMENDATION_PRIORITIES)[number];
|
||||
|
||||
export const AI_ERROR_CATEGORIES = [
|
||||
"missing_config",
|
||||
"provider_error",
|
||||
"timeout",
|
||||
"schema_validation_failed",
|
||||
"retrieval_failed",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type AiErrorCategory = (typeof AI_ERROR_CATEGORIES)[number];
|
||||
|
||||
export type AiCitation = {
|
||||
id: string;
|
||||
sourceType: "resident_context" | "knowledge";
|
||||
sourceId: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
export type ElderAiFinding = {
|
||||
category: string;
|
||||
severity: ElderAiSeverity;
|
||||
evidence: string;
|
||||
citationIds: string[];
|
||||
};
|
||||
|
||||
export type ElderAiRecommendation = {
|
||||
title: string;
|
||||
priority: ElderAiRecommendationPriority;
|
||||
rationale: string;
|
||||
suggestedNextStep: string;
|
||||
citationIds: string[];
|
||||
};
|
||||
|
||||
export type ElderAiModelSummary = {
|
||||
provider: "openai-compatible";
|
||||
chatModel: string;
|
||||
embeddingModel: string;
|
||||
};
|
||||
|
||||
export type ElderAiAnalysisOutput = {
|
||||
overallRiskLevel: ElderAiRiskLevel;
|
||||
summary: string;
|
||||
keyFindings: ElderAiFinding[];
|
||||
recommendations: ElderAiRecommendation[];
|
||||
dataGaps: string[];
|
||||
citations: AiCitation[];
|
||||
confidence: number;
|
||||
modelSummary: ElderAiModelSummary;
|
||||
};
|
||||
|
||||
export type ElderAiAnalysisHistoryItem = {
|
||||
id: string;
|
||||
elderId: string;
|
||||
status: ElderAiAnalysisStatus;
|
||||
dataScopes: ElderAiDataScope[];
|
||||
createdAt: string;
|
||||
restricted: boolean;
|
||||
result?: ElderAiAnalysisOutput;
|
||||
errorCategory?: AiErrorCategory;
|
||||
errorReason?: string;
|
||||
};
|
||||
|
||||
export type KnowledgeEntryInput = {
|
||||
scope: AiKnowledgeScope;
|
||||
organizationId?: string;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: AiKnowledgeStatus;
|
||||
};
|
||||
|
||||
export type KnowledgeEntry = KnowledgeEntryInput & {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type KnowledgeRetrievalResult = {
|
||||
entryId: string;
|
||||
chunkId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
const DATA_SCOPE_PERMISSION_MAP: Record<Exclude<ElderAiDataScope, "elder" | "knowledge">, Permission> = {
|
||||
health: "health:read",
|
||||
care: "care:read",
|
||||
family: "family:read",
|
||||
admission: "admission:read",
|
||||
facility: "facility:read",
|
||||
alert: "alert:read",
|
||||
incident: "incident:read",
|
||||
};
|
||||
|
||||
export function isElderAiDataScope(value: string): value is ElderAiDataScope {
|
||||
return (ELDER_AI_DATA_SCOPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getPermissionForDataScope(scope: ElderAiDataScope): Permission {
|
||||
if (scope === "elder") {
|
||||
return "elder:read";
|
||||
}
|
||||
if (scope === "knowledge") {
|
||||
return "knowledge:read";
|
||||
}
|
||||
return DATA_SCOPE_PERMISSION_MAP[scope];
|
||||
}
|
||||
|
||||
export function canViewAnalysisScopes(scopes: readonly ElderAiDataScope[], permissions: readonly Permission[]): boolean {
|
||||
return scopes.every((scope) => permissions.includes(getPermissionForDataScope(scope)));
|
||||
}
|
||||
|
||||
export function parseDataScopes(value: unknown): ElderAiDataScope[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is ElderAiDataScope => typeof item === "string" && isElderAiDataScope(item));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const items = value.filter((item): item is string => typeof item === "string");
|
||||
return items.length === value.length ? items : null;
|
||||
}
|
||||
|
||||
function validateCitation(value: unknown): AiCitation | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const sourceType = value.sourceType;
|
||||
if (sourceType !== "resident_context" && sourceType !== "knowledge") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.sourceId !== "string" ||
|
||||
typeof value.title !== "string" ||
|
||||
typeof value.excerpt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: value.id,
|
||||
sourceType,
|
||||
sourceId: value.sourceId,
|
||||
title: value.title,
|
||||
excerpt: value.excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFinding(value: unknown): ElderAiFinding | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const citationIds = readStringArray(value.citationIds);
|
||||
if (
|
||||
typeof value.category !== "string" ||
|
||||
typeof value.evidence !== "string" ||
|
||||
!ELDER_AI_SEVERITIES.includes(value.severity as ElderAiSeverity) ||
|
||||
!citationIds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
category: value.category,
|
||||
severity: value.severity as ElderAiSeverity,
|
||||
evidence: value.evidence,
|
||||
citationIds,
|
||||
};
|
||||
}
|
||||
|
||||
function validateRecommendation(value: unknown): ElderAiRecommendation | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const citationIds = readStringArray(value.citationIds);
|
||||
if (
|
||||
typeof value.title !== "string" ||
|
||||
typeof value.rationale !== "string" ||
|
||||
typeof value.suggestedNextStep !== "string" ||
|
||||
!ELDER_AI_RECOMMENDATION_PRIORITIES.includes(value.priority as ElderAiRecommendationPriority) ||
|
||||
!citationIds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: value.title,
|
||||
priority: value.priority as ElderAiRecommendationPriority,
|
||||
rationale: value.rationale,
|
||||
suggestedNextStep: value.suggestedNextStep,
|
||||
citationIds,
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelSummary(value: unknown): ElderAiModelSummary | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.provider !== "openai-compatible" ||
|
||||
typeof value.chatModel !== "string" ||
|
||||
typeof value.embeddingModel !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: "openai-compatible",
|
||||
chatModel: value.chatModel,
|
||||
embeddingModel: value.embeddingModel,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateElderAiAnalysisOutput(value: unknown): ElderAiAnalysisOutput | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const confidenceValue = typeof value.confidence === "string" ? Number(value.confidence) : value.confidence;
|
||||
const keyFindings = Array.isArray(value.keyFindings) ? value.keyFindings.map(validateFinding) : null;
|
||||
const recommendations = Array.isArray(value.recommendations) ? value.recommendations.map(validateRecommendation) : null;
|
||||
const citations = Array.isArray(value.citations) ? value.citations.map(validateCitation) : null;
|
||||
const dataGaps = readStringArray(value.dataGaps);
|
||||
const modelSummary = validateModelSummary(value.modelSummary);
|
||||
|
||||
if (
|
||||
!ELDER_AI_RISK_LEVELS.includes(value.overallRiskLevel as ElderAiRiskLevel) ||
|
||||
typeof value.summary !== "string" ||
|
||||
typeof confidenceValue !== "number" ||
|
||||
!Number.isFinite(confidenceValue) ||
|
||||
!keyFindings ||
|
||||
keyFindings.includes(null) ||
|
||||
!recommendations ||
|
||||
recommendations.includes(null) ||
|
||||
!dataGaps ||
|
||||
!citations ||
|
||||
citations.includes(null) ||
|
||||
!modelSummary
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
overallRiskLevel: value.overallRiskLevel as ElderAiRiskLevel,
|
||||
summary: value.summary,
|
||||
keyFindings: keyFindings.filter((item): item is ElderAiFinding => Boolean(item)),
|
||||
recommendations: recommendations.filter((item): item is ElderAiRecommendation => Boolean(item)),
|
||||
dataGaps,
|
||||
citations: citations.filter((item): item is AiCitation => Boolean(item)),
|
||||
confidence: Math.max(0, Math.min(1, confidenceValue)),
|
||||
modelSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateKnowledgeEntryInput(value: unknown): { success: true; input: KnowledgeEntryInput } | { success: false; reason: string } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const scope = typeof record.scope === "string" ? record.scope : "";
|
||||
const title = typeof record.title === "string" ? record.title.trim() : "";
|
||||
const category = typeof record.category === "string" ? record.category.trim() : "";
|
||||
const tags = typeof record.tags === "string" ? record.tags.trim() : "";
|
||||
const body = typeof record.body === "string" ? record.body.trim() : "";
|
||||
const status = typeof record.status === "string" ? record.status : "enabled";
|
||||
const organizationId = typeof record.organizationId === "string" && record.organizationId.trim() ? record.organizationId.trim() : undefined;
|
||||
|
||||
if (!AI_KNOWLEDGE_SCOPES.includes(scope as AiKnowledgeScope)) {
|
||||
return { success: false, reason: "知识库范围无效" };
|
||||
}
|
||||
if (!AI_KNOWLEDGE_STATUSES.includes(status as AiKnowledgeStatus)) {
|
||||
return { success: false, reason: "知识库状态无效" };
|
||||
}
|
||||
if (!title) {
|
||||
return { success: false, reason: "知识标题不能为空" };
|
||||
}
|
||||
if (!body) {
|
||||
return { success: false, reason: "知识内容不能为空" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
input: {
|
||||
scope: scope as AiKnowledgeScope,
|
||||
organizationId,
|
||||
title,
|
||||
category,
|
||||
tags,
|
||||
body,
|
||||
status: status as AiKnowledgeStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
{ id: "elder:create", label: "新增老人档案", category: "老人", description: "创建老人档案。" },
|
||||
{ id: "elder:update", label: "更新老人档案", category: "老人", description: "更新老人档案和照护信息。" },
|
||||
{ id: "elder:delete", label: "删除老人档案", category: "老人", description: "删除老人档案。" },
|
||||
{ id: "ai:read", label: "查看 AI 分析", category: "AI", description: "查看并生成授权数据范围内的 AI 分析。" },
|
||||
{ id: "ai:manage", label: "管理 AI 能力", category: "AI", description: "预留给 AI 配置与分析治理能力,当前不开放独立管理入口。" },
|
||||
{ id: "knowledge:read", label: "查看知识库", category: "AI", description: "查看并检索平台和机构知识库内容。" },
|
||||
{ id: "knowledge:manage", label: "管理知识库", category: "AI", description: "维护平台或机构知识库条目。" },
|
||||
];
|
||||
|
||||
type SeedRole = {
|
||||
@@ -121,6 +125,10 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
"elder:create",
|
||||
"elder:update",
|
||||
"elder:delete",
|
||||
"ai:read",
|
||||
"ai:manage",
|
||||
"knowledge:read",
|
||||
"knowledge:manage",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,6 +159,10 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
"elder:create",
|
||||
"elder:update",
|
||||
"elder:delete",
|
||||
"ai:read",
|
||||
"ai:manage",
|
||||
"knowledge:read",
|
||||
"knowledge:manage",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -169,6 +181,8 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
||||
"admission:read",
|
||||
"elder:read",
|
||||
"elder:update",
|
||||
"ai:read",
|
||||
"knowledge:read",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
@@ -41,6 +43,11 @@ export const familyContactStatusEnum = pgEnum("family_contact_status", ["active"
|
||||
export const familyVisitStatusEnum = pgEnum("family_visit_status", ["requested", "approved", "completed", "cancelled"]);
|
||||
export const familyFeedbackTypeEnum = pgEnum("family_feedback_type", ["service", "care", "meal", "environment", "other"]);
|
||||
export const familyFeedbackStatusEnum = pgEnum("family_feedback_status", ["open", "in_progress", "resolved", "closed"]);
|
||||
export const aiKnowledgeScopeEnum = pgEnum("ai_knowledge_scope", ["platform", "organization"]);
|
||||
export const aiKnowledgeStatusEnum = pgEnum("ai_knowledge_status", ["enabled", "disabled"]);
|
||||
export const elderAiAnalysisStatusEnum = pgEnum("elder_ai_analysis_status", ["completed", "failed"]);
|
||||
|
||||
export const AI_KNOWLEDGE_EMBEDDING_DIMENSIONS = 1536;
|
||||
|
||||
export const systemSettings = pgTable("system_settings", {
|
||||
id: text("id").primaryKey().default("global"),
|
||||
@@ -521,3 +528,55 @@ export const systemIncidents = pgTable("system_incidents", {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const aiKnowledgeEntries = pgTable("ai_knowledge_entries", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
scope: aiKnowledgeScopeEnum("scope").notNull(),
|
||||
organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
category: text("category").notNull().default(""),
|
||||
tags: text("tags").notNull().default(""),
|
||||
body: text("body").notNull(),
|
||||
status: aiKnowledgeStatusEnum("status").notNull().default("enabled"),
|
||||
createdByAccountId: uuid("created_by_account_id").references(() => accounts.id, { onDelete: "set null" }),
|
||||
updatedByAccountId: uuid("updated_by_account_id").references(() => accounts.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
scopeLookup: index("ai_knowledge_entries_scope_lookup").on(table.scope, table.status),
|
||||
organizationLookup: index("ai_knowledge_entries_organization_lookup").on(table.organizationId, table.status),
|
||||
}));
|
||||
|
||||
export const aiKnowledgeChunks = pgTable("ai_knowledge_chunks", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
entryId: uuid("entry_id").notNull().references(() => aiKnowledgeEntries.id, { onDelete: "cascade" }),
|
||||
organizationId: uuid("organization_id").references(() => organizations.id, { onDelete: "cascade" }),
|
||||
scope: aiKnowledgeScopeEnum("scope").notNull(),
|
||||
chunkIndex: integer("chunk_index").notNull(),
|
||||
content: text("content").notNull(),
|
||||
embedding: jsonb("embedding").$type<number[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
sourceTitle: text("source_title").notNull(),
|
||||
sourceCategory: text("source_category").notNull().default(""),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
entryLookup: index("ai_knowledge_chunks_entry_lookup").on(table.entryId),
|
||||
scopeLookup: index("ai_knowledge_chunks_scope_lookup").on(table.scope, table.organizationId),
|
||||
}));
|
||||
|
||||
export const elderAiAnalyses = pgTable("elder_ai_analyses", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
|
||||
elderId: uuid("elder_id").notNull().references(() => elders.id, { onDelete: "cascade" }),
|
||||
actorAccountId: uuid("actor_account_id").references(() => accounts.id, { onDelete: "set null" }),
|
||||
status: elderAiAnalysisStatusEnum("status").notNull(),
|
||||
dataScopes: jsonb("data_scopes").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
resultJson: jsonb("result_json"),
|
||||
citationsJson: jsonb("citations_json").notNull().default(sql`'[]'::jsonb`),
|
||||
modelSummaryJson: jsonb("model_summary_json").notNull().default(sql`'{}'::jsonb`),
|
||||
errorCategory: text("error_category").notNull().default(""),
|
||||
errorReason: text("error_reason").notNull().default(""),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
elderLookup: index("elder_ai_analyses_elder_lookup").on(table.organizationId, table.elderId, table.createdAt),
|
||||
statusLookup: index("elder_ai_analyses_status_lookup").on(table.organizationId, table.status),
|
||||
}));
|
||||
|
||||
@@ -59,6 +59,10 @@ export const PERMISSIONS = [
|
||||
"elder:create",
|
||||
"elder:update",
|
||||
"elder:delete",
|
||||
"ai:read",
|
||||
"ai:manage",
|
||||
"knowledge:read",
|
||||
"knowledge:manage",
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[number];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Edit3, Filter, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Brain, Edit3, Filter, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -10,6 +10,7 @@ import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { ElderAiAnalysisDialog } from "@/modules/ai/components/ElderAiAnalysisDialog";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
import type { CareLevel, Elder, ElderInput, ElderStatus } from "@/modules/elders/types";
|
||||
@@ -123,10 +124,12 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
||||
const [statusFilter, setStatusFilter] = useState<ElderStatus | typeof ALL_STATUS>(ALL_STATUS);
|
||||
const [careLevelFilter, setCareLevelFilter] = useState<CareLevel | typeof ALL_CARE_LEVELS>(ALL_CARE_LEVELS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [aiTarget, setAiTarget] = useState<Elder | null>(null);
|
||||
|
||||
const canCreate = permissions.includes("elder:create");
|
||||
const canUpdate = permissions.includes("elder:update");
|
||||
const canDelete = permissions.includes("elder:delete");
|
||||
const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
|
||||
const isEditing = editingId !== null;
|
||||
const canSubmit = isEditing ? canUpdate : canCreate;
|
||||
|
||||
@@ -401,6 +404,17 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canUseAi ? (
|
||||
<Button
|
||||
onClick={() => setAiTarget(elder)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Brain className="size-4" aria-hidden="true" />
|
||||
AI 分析
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={!canUpdate}
|
||||
onClick={() => beginEdit(elder)}
|
||||
@@ -581,6 +595,8 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ElderAiAnalysisDialog elder={aiTarget} onClose={() => setAiTarget(null)} open={aiTarget !== null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import {
|
||||
BedDouble,
|
||||
Bell,
|
||||
BookOpenText,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Globe2,
|
||||
@@ -38,6 +39,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
|
||||
family: UserRoundCheck,
|
||||
global: Globe2,
|
||||
health: HeartPulse,
|
||||
knowledge: BookOpenText,
|
||||
notices: Bell,
|
||||
organizations: Building2,
|
||||
roles: Settings,
|
||||
|
||||
@@ -11,6 +11,7 @@ export type NavIconKey =
|
||||
| "family"
|
||||
| "global"
|
||||
| "health"
|
||||
| "knowledge"
|
||||
| "notices"
|
||||
| "organizations"
|
||||
| "roles"
|
||||
@@ -134,6 +135,12 @@ export const navGroups: NavGroup[] = [
|
||||
icon: "health",
|
||||
permission: "health:read",
|
||||
},
|
||||
{
|
||||
path: "/settings/knowledge",
|
||||
label: "知识库",
|
||||
icon: "knowledge",
|
||||
permission: "knowledge:read",
|
||||
},
|
||||
{
|
||||
path: "/settings/audit",
|
||||
label: "审计日志",
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/kumo": "^2.6.0",
|
||||
"@langchain/core": "^1.2.1",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"langchain": "^1.5.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
|
||||
277
pnpm-lock.yaml
generated
277
pnpm-lock.yaml
generated
@@ -10,7 +10,13 @@ importers:
|
||||
dependencies:
|
||||
'@cloudflare/kumo':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)
|
||||
'@langchain/core':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
'@langchain/openai':
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
|
||||
'@phosphor-icons/react':
|
||||
specifier: ^2.1.10
|
||||
version: 2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -26,6 +32,9 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(postgres@3.4.9)
|
||||
langchain:
|
||||
specifier: ^1.5.2
|
||||
version: 1.5.2(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(openai@6.45.0(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0(react@19.2.7)
|
||||
@@ -125,6 +134,9 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@cfworker/json-schema@4.1.1':
|
||||
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
|
||||
|
||||
'@cloudflare/kumo@2.6.0':
|
||||
resolution: {integrity: sha512-rcUUvhrtxI0veNJZLgKVSnxH/L0M48jtc4UoNhvu0l0RiRmjHORFTBYq/phFQyt7JGG3s98QPZc5w1eW+UQIOg==}
|
||||
hasBin: true
|
||||
@@ -842,6 +854,50 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@langchain/core@1.2.1':
|
||||
resolution: {integrity: sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@langchain/langgraph-checkpoint@1.1.3':
|
||||
resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@langchain/core': ^1.1.48
|
||||
|
||||
'@langchain/langgraph-sdk@1.9.25':
|
||||
resolution: {integrity: sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==}
|
||||
peerDependencies:
|
||||
'@langchain/core': ^1.1.48
|
||||
react: ^18 || ^19
|
||||
react-dom: ^18 || ^19
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@langchain/langgraph@1.4.7':
|
||||
resolution: {integrity: sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@langchain/core': ^1.1.48
|
||||
zod: ^3.25.32 || ^4.2.0
|
||||
|
||||
'@langchain/openai@1.5.3':
|
||||
resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@langchain/core': ^1.2.1
|
||||
|
||||
'@langchain/protocol@0.0.18':
|
||||
resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -1542,6 +1598,9 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
|
||||
@@ -2027,6 +2086,9 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
@@ -2279,6 +2341,10 @@ packages:
|
||||
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-network-error@1.3.2:
|
||||
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2337,6 +2403,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2364,6 +2433,32 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
langchain@1.5.2:
|
||||
resolution: {integrity: sha512-5vCWYvzxuY7gJ8UCgSZ17SM45gou5PtRguFgeQIyCnHzGZQUFLHKi/eQArL3Ad98fJ/UiOEAaTXiI3jfIdoABg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@langchain/core': ^1.2.1
|
||||
|
||||
langsmith@0.7.15:
|
||||
resolution: {integrity: sha512-huRfzLKcREE+ABkqKEriXK8Ax9V+xuV3d3x4PINEGi+hi4qyTvB4Nc2dpLSyfW/Ioj6+6d7T8majjWCe7mXc8A==}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '*'
|
||||
'@opentelemetry/exporter-trace-otlp-proto': '*'
|
||||
'@opentelemetry/sdk-trace-base': '*'
|
||||
openai: '*'
|
||||
ws: '>=7'
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@opentelemetry/exporter-trace-otlp-proto':
|
||||
optional: true
|
||||
'@opentelemetry/sdk-trace-base':
|
||||
optional: true
|
||||
openai:
|
||||
optional: true
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
language-subtag-registry@0.3.23:
|
||||
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
|
||||
|
||||
@@ -2527,6 +2622,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mustache@4.2.0:
|
||||
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.15:
|
||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -2607,6 +2706,26 @@ packages:
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
openai@6.45.0:
|
||||
resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==}
|
||||
peerDependencies:
|
||||
'@aws-sdk/credential-provider-node': '>=3.972.0 <4'
|
||||
'@smithy/hash-node': '>=4.3.0 <5'
|
||||
'@smithy/signature-v4': '>=5.4.0 <6'
|
||||
ws: ^8.18.0
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/credential-provider-node':
|
||||
optional: true
|
||||
'@smithy/hash-node':
|
||||
optional: true
|
||||
'@smithy/signature-v4':
|
||||
optional: true
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2615,6 +2734,10 @@ packages:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2623,6 +2746,26 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-queue@6.6.2:
|
||||
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-queue@9.3.1:
|
||||
resolution: {integrity: sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-retry@7.1.1:
|
||||
resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-timeout@3.2.0:
|
||||
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-timeout@7.0.1:
|
||||
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3172,6 +3315,9 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -3206,7 +3352,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@cloudflare/kumo@2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
|
||||
'@cloudflare/kumo@2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@base-ui/react': 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@phosphor-icons/react': 2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -3219,6 +3367,8 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
shiki: 4.3.0
|
||||
tailwind-merge: 3.6.0
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@date-fns/tz'
|
||||
- '@emotion/is-prop-valid'
|
||||
@@ -3688,6 +3838,65 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))':
|
||||
dependencies:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
'@standard-schema/spec': 1.1.0
|
||||
js-tiktoken: 1.0.21
|
||||
langsmith: 0.7.15(openai@6.45.0(zod@4.4.3))
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
- '@opentelemetry/exporter-trace-otlp-proto'
|
||||
- '@opentelemetry/sdk-trace-base'
|
||||
- openai
|
||||
- ws
|
||||
|
||||
'@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))':
|
||||
dependencies:
|
||||
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
|
||||
'@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
'@langchain/protocol': 0.0.18
|
||||
'@types/json-schema': 7.0.15
|
||||
p-queue: 9.3.1
|
||||
p-retry: 7.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@langchain/langgraph@1.4.7(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
|
||||
'@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@langchain/protocol': 0.0.18
|
||||
'@standard-schema/spec': 1.1.0
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
- svelte
|
||||
- vue
|
||||
|
||||
'@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))':
|
||||
dependencies:
|
||||
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
js-tiktoken: 1.0.21
|
||||
openai: 6.45.0(zod@4.4.3)
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-provider-node'
|
||||
- '@smithy/hash-node'
|
||||
- '@smithy/signature-v4'
|
||||
- ws
|
||||
|
||||
'@langchain/protocol@0.0.18': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -4328,6 +4537,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -4929,6 +5140,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
@@ -5189,6 +5402,8 @@ snapshots:
|
||||
|
||||
is-negative-zero@2.0.3: {}
|
||||
|
||||
is-network-error@1.3.2: {}
|
||||
|
||||
is-number-object@1.1.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -5250,6 +5465,10 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
@@ -5277,6 +5496,30 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
langchain@1.5.2(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(openai@6.45.0(zod@4.4.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3))
|
||||
'@langchain/langgraph': 1.4.7(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3)
|
||||
'@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))
|
||||
langsmith: 0.7.15(openai@6.45.0(zod@4.4.3))
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
- '@opentelemetry/exporter-trace-otlp-proto'
|
||||
- '@opentelemetry/sdk-trace-base'
|
||||
- openai
|
||||
- react
|
||||
- react-dom
|
||||
- svelte
|
||||
- vue
|
||||
- ws
|
||||
|
||||
langsmith@0.7.15(openai@6.45.0(zod@4.4.3)):
|
||||
dependencies:
|
||||
p-queue: 6.6.2
|
||||
optionalDependencies:
|
||||
openai: 6.45.0(zod@4.4.3)
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
language-tags@1.0.9:
|
||||
@@ -5419,6 +5662,8 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mustache@4.2.0: {}
|
||||
|
||||
nanoid@3.3.15: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
@@ -5507,6 +5752,10 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openai@6.45.0(zod@4.4.3):
|
||||
optionalDependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -5522,6 +5771,8 @@ snapshots:
|
||||
object-keys: 1.1.1
|
||||
safe-push-apply: 1.0.0
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
@@ -5530,6 +5781,26 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-queue@6.6.2:
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-queue@9.3.1:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.4
|
||||
p-timeout: 7.0.1
|
||||
|
||||
p-retry@7.1.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
p-timeout@3.2.0:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
|
||||
p-timeout@7.0.1: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -6204,4 +6475,6 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user