fix: harden AI analysis and seed defaults

This commit is contained in:
2026-07-08 04:38:22 -07:00
parent 9a437f4cbf
commit 6a1add3422
15 changed files with 653 additions and 167 deletions

View File

@@ -350,13 +350,14 @@ GOOGLE_GENERATIVE_AI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
```
## Scenario: Elder AI Analysis MVP With LangChain And PostgreSQL JSONB Embeddings
## Scenario: Elder AI Analysis MVP With LangChain And Keyword Knowledge Retrieval
### 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.
- Current project contract: the elder analysis MVP uses LangChain on the server with an OpenAI-compatible chat 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.
- Knowledge retrieval is local keyword scoring over persisted chunks; it must not require an embedding provider or `pgvector`.
### 2. Signatures
@@ -369,13 +370,13 @@ ANTHROPIC_API_KEY=sk-ant-...
- `DELETE /api/ai/knowledge/[id]`
- DB:
- `ai_knowledge_entries`
- `ai_knowledge_chunks` with `jsonb` `embedding` arrays
- `ai_knowledge_chunks` with text `content`; the legacy JSONB `embedding` column remains defaulted for compatibility and is not read or written by retrieval.
- `elder_ai_analyses`
- Runtime env:
- `AI_API_KEY` required for generation and embedding.
- `AI_API_KEY` required for chat generation only.
- `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`.
- Do not introduce `AI_EMBEDDING_MODEL`; knowledge retrieval must work without embedding config.
### 3. Contracts
@@ -394,7 +395,8 @@ ANTHROPIC_API_KEY=sk-ant-...
- `errorCategory` / `errorReason`: sanitized only for failed rows
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
- 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.
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then scores chunks with local lexical overlap in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension or embedding API calls.
- Completed elder analysis `resultJson.modelSummary` must include `{ provider: "openai-compatible", chatModel, knowledgeRetrieval: "keyword" }`.
- 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
@@ -421,7 +423,7 @@ ANTHROPIC_API_KEY=sk-ant-...
### 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.
- Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, keyword-score ordering, nonmatching chunk exclusion, and no embedding-provider calls.
- 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.

View File

@@ -140,7 +140,7 @@ const analysis: ElderAiAnalysisHistoryItem = {
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
knowledgeRetrieval: "keyword",
},
},
};
@@ -293,14 +293,14 @@ describe("AI API routes", () => {
});
it("propagates knowledge service failures with the service status", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库向量生成失败", status: 500 });
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(body).toEqual({ success: false, reason: "知识库创建失败" });
expect(recordAuditLog).not.toHaveBeenCalled();
});

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data";
import { getDatabase } from "@/modules/core/server/db";
import { seedOrganizationRoles } from "@/modules/core/server/permissions";
import { organizations } from "@/modules/core/server/schema";
@@ -95,6 +96,7 @@ export async function POST(request: Request): Promise<Response> {
}
await seedOrganizationRoles(organization.id);
await seedDefaultWorkspaceData(organization.id);
await recordAuditLog({
actor: auth.context.account,
organizationId: organization.id,

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: pgvector/pgvector:pg17
image: postgres:17-alpine
container_name: teatea-postgres
restart: unless-stopped
environment:

View File

@@ -66,12 +66,6 @@ function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
}
function formatKnowledgeFailureReason(reason: string): string {
if (reason.includes("AI_API_KEY") || reason.includes("向量生成失败")) {
return `保存知识需要可用的 AI 向量配置:${reason}`;
}
return reason;
}
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
const [entries, setEntries] = useState(initialEntries);
@@ -142,7 +136,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(formatKnowledgeFailureReason(result.reason));
setMessage(result.reason);
return;
}
@@ -180,7 +174,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
if (!result.success) {
setMessage(formatKnowledgeFailureReason(result.reason));
setMessage(result.reason);
return;
}
await refreshEntries();
@@ -192,7 +186,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
<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>
<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" />
@@ -277,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
</Card>
<Dialog
description="保存时会写入数据库重建分块和 embedding。"
description="保存时会写入数据库重建分块;检索使用本地关键词匹配。"
onClose={closeDialog}
open={isDialogOpen}
title={isEditing ? "编辑知识" : "新增知识"}

View File

@@ -96,8 +96,6 @@ const runtimeConfig: AiRuntimeConfigResult = {
baseUrl: "https://ai.example.test/v1",
apiKey: "test-api-key",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
embeddingDimensions: 1536,
},
};
@@ -234,7 +232,7 @@ function createModelOutput(overrides: Partial<ElderAiAnalysisOutput> = {}): Elde
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
knowledgeRetrieval: "keyword",
},
...overrides,
};
@@ -387,7 +385,7 @@ describe("elder AI analysis service", () => {
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 });
vi.mocked(retrieveKnowledge).mockResolvedValue({ success: false, reason: "knowledge retrieval unavailable", status: 500 });
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({ dataGaps: ["No gait score available."] })));
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");

View File

@@ -269,7 +269,7 @@ export async function generateElderAiAnalysis(
"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}"}。`,
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","knowledgeRetrieval":"keyword"}。`,
`可用 citations${JSON.stringify(citations)}`,
`住民上下文:\n${residentContext.context.promptContext}`,
`知识库片段:\n${knowledgeItems.map((item, index) => `[kb-${index + 1}] ${item.title}\n${item.content}`).join("\n\n") || "无"}`,

View File

@@ -1,11 +1,8 @@
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 =
@@ -13,7 +10,6 @@ export type AiRuntimeConfigResult =
| { 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() ?? "";
@@ -31,8 +27,6 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult {
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,
},
};
}

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import {
createKnowledgeEntry,
listKnowledgeEntries,
@@ -13,9 +12,6 @@ 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";
@@ -32,7 +28,6 @@ const drizzleDsl = vi.hoisted(() => {
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,
@@ -44,15 +39,10 @@ vi.mock("server-only", () => ({}));
vi.mock("@langchain/openai", () => ({
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
return {
embedDocuments: embeddingMocks.embedDocuments,
};
return {};
}),
}));
vi.mock("@/modules/ai/server/config", () => ({
getAiRuntimeConfig: vi.fn(),
}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
@@ -110,7 +100,6 @@ type KnowledgeChunkRow = {
scope: KnowledgeScope;
chunkIndex: number;
content: string;
embedding: number[];
sourceTitle: string;
sourceCategory: string;
createdAt: Date;
@@ -130,28 +119,20 @@ type RetrievedChunkRow = {
title: string;
category: string;
content: string;
embedding: number[];
};
type TransactionWrite = { table: "entries" | "chunks"; values: unknown };
type KnowledgeDatabaseFake = {
entries: KnowledgeRow[];
chunks: KnowledgeChunkRow[];
transactionWrites: TransactionWrite[];
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",
@@ -262,7 +243,6 @@ function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow>
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"),
@@ -325,7 +305,6 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
title: chunk.sourceTitle,
category: chunk.sourceCategory,
content: chunk.content,
embedding: chunk.embedding,
});
}
}
@@ -336,10 +315,93 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
function createDatabaseFake(input: { entries?: KnowledgeRow[]; chunks?: KnowledgeChunkRow[] } = {}): KnowledgeDatabaseFake {
const entries = input.entries ?? [];
const chunks = input.chunks ?? [];
const transactionWrites: TransactionWrite[] = [];
const tableName = (table: unknown): "entries" | "chunks" => (table === drizzleDsl.chunks ? "chunks" : "entries");
const readRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
};
const readString = (value: unknown, fallback: string): string => (typeof value === "string" ? value : fallback);
const readOrganizationId = (value: unknown, fallback: string | null): string | null => {
if (typeof value === "string") {
return value;
}
if (value === null) {
return null;
}
return fallback;
};
const buildEntryRow = (values: unknown, fallback?: KnowledgeRow): KnowledgeRow => {
const record = readRecord(values);
return createEntry({
id: fallback?.id ?? `entry-created-${entries.length + 1}`,
scope: record.scope === "platform" ? "platform" : "organization",
organizationId: readOrganizationId(record.organizationId, fallback?.organizationId ?? "org-1"),
title: readString(record.title, fallback?.title ?? "Created guidance"),
category: readString(record.category, fallback?.category ?? "Safety"),
tags: readString(record.tags, fallback?.tags ?? "fall"),
body: readString(record.body, fallback?.body ?? "Created body"),
status: record.status === "disabled" ? "disabled" : "enabled",
createdByAccountId: readString(record.createdByAccountId, fallback?.createdByAccountId ?? "account-1"),
updatedByAccountId: readString(record.updatedByAccountId, fallback?.updatedByAccountId ?? "account-1"),
createdAt: fallback?.createdAt ?? new Date("2026-07-02T00:00:00.000Z"),
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
});
};
type TransactionFake = {
insert: ReturnlessFunction;
update: ReturnlessFunction;
delete: ReturnlessFunction;
};
const transactionFake: TransactionFake = {
insert: vi.fn((table: unknown) => ({
values: vi.fn((values: unknown) => {
transactionWrites.push({ table: tableName(table), values });
if (table === drizzleDsl.entries) {
const row = buildEntryRow(values);
entries.push(row);
return { returning: vi.fn(async () => [row]) };
}
return Promise.resolve();
}),
})),
update: vi.fn((table: unknown) => ({
set: vi.fn((values: unknown) => ({
where: vi.fn(() => ({
returning: vi.fn(async () => {
const fallback = entries[0] ?? createEntry();
const row = buildEntryRow(values, fallback);
transactionWrites.push({ table: tableName(table), values });
const existingIndex = entries.findIndex((entry) => entry.id === row.id);
if (existingIndex >= 0) {
entries[existingIndex] = row;
} else {
entries.push(row);
}
return [row];
}),
})),
})),
})),
delete: vi.fn((table: unknown) => ({
where: vi.fn(() => {
transactionWrites.push({ table: tableName(table), values: { delete: true } });
}),
})),
};
const runTransaction: ReturnlessFunction = async (callback: unknown) => {
if (typeof callback !== "function") {
return null;
}
return (callback as (transaction: TransactionFake) => Promise<KnowledgeRow | null>)(transactionFake);
};
return {
entries,
chunks,
transaction: vi.fn(),
transactionWrites,
transaction: vi.fn(runTransaction),
select: vi.fn(() => new SelectBuilder(entries, chunks)),
};
}
@@ -350,8 +412,6 @@ function useDatabase(fake: KnowledgeDatabaseFake): void {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
embeddingMocks.embedDocuments.mockResolvedValue([[0.4, 0.5, 0.6]]);
useDatabase(createDatabaseFake());
});
@@ -404,32 +464,38 @@ describe("AI knowledge service", () => {
],
},
});
expect(embeddingMocks.embedDocuments).toHaveBeenCalledWith(["night fall risk"]);
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
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" });
it("ranks matching chunks by lexical overlap and drops nonmatching chunks after scope filtering", async () => {
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance", body: "General fall prevention guidance." });
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance", body: "Night fall risk guidance for wandering residents." });
const unrelatedEntry = createEntry({ id: "entry-unrelated", title: "Nutrition guidance", body: "Hydration and meals." });
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled night fall risk" });
const otherOrgEntry = createEntry({ id: "entry-other-org", organizationId: "org-2", title: "Other org night fall risk" });
useDatabase(createDatabaseFake({
entries: [weakEntry, strongEntry],
entries: [weakEntry, strongEntry, unrelatedEntry, disabledEntry, otherOrgEntry],
chunks: [
createChunk(weakEntry, { embedding: [0, 1, 0] }),
createChunk(strongEntry, { embedding: [1, 0, 0] }),
createChunk(weakEntry),
createChunk(strongEntry),
createChunk(unrelatedEntry),
createChunk(disabledEntry),
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
],
}));
embeddingMocks.embedDocuments.mockResolvedValueOnce([[1, 0, 0]]);
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 2 });
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 5 });
expect(result).toEqual({
success: true,
data: {
results: [
expect.objectContaining({ entryId: "entry-strong", score: 1 }),
expect.objectContaining({ entryId: "entry-weak", score: 0 }),
expect.objectContaining({ entryId: "entry-weak", score: 1 / 3 }),
],
},
});
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires platform management permission before mutating platform knowledge", async () => {
@@ -443,7 +509,7 @@ describe("AI knowledge service", () => {
);
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires an active organization before mutating organization knowledge", async () => {
@@ -453,7 +519,7 @@ describe("AI knowledge service", () => {
);
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("rejects attempts to mutate another organization's knowledge", async () => {
@@ -463,17 +529,36 @@ describe("AI knowledge service", () => {
});
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("surfaces embedding failures without opening a write transaction", async () => {
const database = createDatabaseFake();
it("writes lexical chunks with empty embedding arrays when creating and updating knowledge", async () => {
const existingEntry = createEntry({ id: "entry-existing", title: "Existing guidance" });
const database = createDatabaseFake({ entries: [existingEntry] });
useDatabase(database);
embeddingMocks.embedDocuments.mockRejectedValue(new Error("embedding provider unavailable"));
const result = await createKnowledgeEntry(createAuthContext(), baseInput);
const created = await createKnowledgeEntry(createAuthContext(), baseInput);
expect(result).toEqual({ success: false, reason: "知识库向量生成失败", status: 500 });
expect(database.transaction).not.toHaveBeenCalled();
expect(created.success).toBe(true);
const createdChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(createdChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
database.transactionWrites.length = 0;
const updated = await updateKnowledgeEntry(createAuthContext(), "entry-existing", baseInput);
expect(updated.success).toBe(true);
const updatedChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(updatedChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,7 @@
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,
@@ -41,22 +39,21 @@ function toKnowledgeEntry(row: KnowledgeRow): KnowledgeEntry {
};
}
function createEmbeddings() {
const configResult = getAiRuntimeConfig();
if (!configResult.success) {
return configResult;
function normalizeSearchText(value: string): string[] {
const tokens = new Set<string>();
const normalizedTokens = value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
for (const token of normalizedTokens) {
if (token.length >= 2) {
tokens.add(token);
}
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,
}),
};
const cjkSequences = token.match(/[\u3400-\u9fff]+/gu) ?? [];
for (const sequence of cjkSequences) {
for (let index = 0; index < sequence.length - 1; index += 1) {
tokens.add(sequence.slice(index, index + 2));
}
}
}
return Array.from(tokens);
}
function chunkKnowledgeText(input: KnowledgeEntryInput): string[] {
@@ -118,19 +115,8 @@ function getEnabledChunkWhere(organizationId: string) {
);
}
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 };
}
function buildKnowledgeChunks(input: KnowledgeEntryInput): string[] {
return chunkKnowledgeText(input);
}
export async function listKnowledgeEntries(context: AuthContext): Promise<KnowledgeEntry[]> {
@@ -152,11 +138,8 @@ export async function createKnowledgeEntry(
return scope;
}
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
const build = await buildKnowledgeChunks(input);
if (!build.success) {
return build;
}
const row = await database.transaction(async (transaction) => {
const rows = await transaction
@@ -178,15 +161,15 @@ export async function createKnowledgeEntry(
if (!entry) {
return null;
}
if (build.data.chunks.length > 0) {
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
build.data.chunks.map((content, index) => ({
chunks.map((content, index) => ({
entryId: entry.id,
organizationId: entry.organizationId,
scope: entry.scope,
chunkIndex: index,
content,
embedding: build.data.vectors[index] ?? [],
embedding: [],
sourceTitle: entry.title,
sourceCategory: entry.category,
})),
@@ -208,10 +191,7 @@ async function updateEntryWithChunks(
input: KnowledgeEntryInput,
organizationId: string | null,
): Promise<KnowledgeRow | null> {
const build = await buildKnowledgeChunks(input);
if (!build.success) {
throw new Error(build.reason);
}
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
return database.transaction(async (transaction) => {
@@ -236,15 +216,15 @@ async function updateEntryWithChunks(
}
await transaction.delete(aiKnowledgeChunks).where(eq(aiKnowledgeChunks.entryId, row.id));
if (build.data.chunks.length > 0) {
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
build.data.chunks.map((content, index) => ({
chunks.map((content, index) => ({
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: index,
content,
embedding: build.data.vectors[index] ?? [],
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
})),
@@ -284,7 +264,7 @@ export async function updateKnowledgeEntry(
try {
row = await updateEntryWithChunks(id, context, input, scope.data.organizationId);
} catch {
return { success: false, reason: "AI_API_KEY 未配置或知识库向量生成失败", status: 500 };
return { success: false, reason: "知识库更新失败", status: 500 };
}
if (!row) {
@@ -316,27 +296,22 @@ export async function deleteKnowledgeEntry(context: AuthContext, id: string): Pr
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) {
function scoreKnowledgeChunk(
chunk: Pick<KnowledgeRetrievalResult, "title" | "category" | "content">,
queryTokens: readonly string[],
): number {
if (queryTokens.length === 0) {
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;
const chunkTokens = new Set(normalizeSearchText(`${chunk.title}\n${chunk.category}\n${chunk.content}`));
let matchedTokens = 0;
for (const token of queryTokens) {
if (chunkTokens.has(token)) {
matchedTokens += 1;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
return 0;
}
return dotProduct / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
return matchedTokens / queryTokens.length;
}
export async function retrieveKnowledge(
@@ -344,16 +319,7 @@ export async function retrieveKnowledge(
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 queryTokens = normalizeSearchText(query);
const database = getDatabase();
const rows = await database
@@ -363,7 +329,6 @@ export async function retrieveKnowledge(
title: aiKnowledgeChunks.sourceTitle,
category: aiKnowledgeChunks.sourceCategory,
content: aiKnowledgeChunks.content,
embedding: aiKnowledgeChunks.embedding,
})
.from(aiKnowledgeChunks)
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
@@ -379,8 +344,9 @@ export async function retrieveKnowledge(
title: row.title,
category: row.category,
content: row.content,
score: calculateCosineSimilarity(row.embedding, embedding),
score: scoreKnowledgeChunk(row, queryTokens),
}))
.filter((row) => row.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, options?.limit ?? DEFAULT_RETRIEVAL_LIMIT),
},

View File

@@ -51,7 +51,7 @@ const validAnalysisOutput: ElderAiAnalysisOutput = {
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
knowledgeRetrieval: "keyword",
},
};
@@ -142,6 +142,13 @@ describe("AI validators", () => {
modelSummary: { ...validAnalysisOutput.modelSummary, provider: "anthropic" },
},
},
{
name: "unexpected knowledge retrieval mode",
value: {
...validAnalysisOutput,
modelSummary: { ...validAnalysisOutput.modelSummary, knowledgeRetrieval: "embedding" },
},
},
{
name: "non-finite confidence",
value: { ...validAnalysisOutput, confidence: "not-a-number" },

View File

@@ -67,7 +67,7 @@ export type ElderAiRecommendation = {
export type ElderAiModelSummary = {
provider: "openai-compatible";
chatModel: string;
embeddingModel: string;
knowledgeRetrieval: "keyword";
};
export type ElderAiAnalysisOutput = {
@@ -241,14 +241,14 @@ function validateModelSummary(value: unknown): ElderAiModelSummary | null {
if (
value.provider !== "openai-compatible" ||
typeof value.chatModel !== "string" ||
typeof value.embeddingModel !== "string"
value.knowledgeRetrieval !== "keyword"
) {
return null;
}
return {
provider: "openai-compatible",
chatModel: value.chatModel,
embeddingModel: value.embeddingModel,
knowledgeRetrieval: "keyword",
};
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
import {
DEFAULT_AI_KNOWLEDGE_ENTRIES,
DEFAULT_PREPARED_AI_ANALYSES,
} from "@/modules/core/server/default-workspace-data";
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
vi.mock("server-only", () => ({}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
}));
describe("default AI workspace seed data", () => {
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
const scopes = new Set(DEFAULT_AI_KNOWLEDGE_ENTRIES.map((entry) => entry.scope));
expect(scopes).toEqual(new Set(["platform", "organization"]));
for (const entry of DEFAULT_AI_KNOWLEDGE_ENTRIES) {
expect(validateKnowledgeEntryInput(entry)).toEqual({ success: true, input: entry });
expect(entry.status).toBe("enabled");
expect(entry.body.length).toBeGreaterThan(80);
expect([entry.title, entry.category, entry.tags, entry.body].join("\n")).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("provides completed elder AI analyses that current validators can display", () => {
expect(DEFAULT_PREPARED_AI_ANALYSES.length).toBeGreaterThanOrEqual(1);
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const validated = validateElderAiAnalysisOutput(analysis.result);
expect(validated).toEqual(analysis.result);
expect(analysis.elderName).toBeTruthy();
expect(analysis.dataScopes).toContain("elder");
expect(analysis.dataScopes).toContain("knowledge");
expect(analysis.createdHoursAgo).toBeGreaterThan(0);
expect(analysis.result.citations.length).toBeGreaterThan(0);
expect(analysis.result.keyFindings.length).toBeGreaterThan(0);
expect(analysis.result.recommendations.length).toBeGreaterThan(0);
expect(JSON.stringify(analysis)).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("keeps every prepared analysis citation reference displayable", () => {
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const citationIds = new Set(analysis.result.citations.map((citation) => citation.id));
const referencedIds = [
...analysis.result.keyFindings.flatMap((finding) => finding.citationIds),
...analysis.result.recommendations.flatMap((recommendation) => recommendation.citationIds),
];
expect(referencedIds.length).toBeGreaterThan(0);
expect(new Set(referencedIds)).toEqual(citationIds);
for (const citationId of referencedIds) {
expect(citationIds.has(citationId)).toBe(true);
}
}
});
});

View File

@@ -1,8 +1,11 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { ElderAiAnalysisOutput } from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
aiKnowledgeChunks,
aiKnowledgeEntries,
alertRules,
alertTriggers,
beds,
@@ -11,6 +14,7 @@ import {
careTasks,
chronicConditions,
deviceAssets,
elderAiAnalyses,
elders,
familyContacts,
familyFeedback,
@@ -198,6 +202,28 @@ type SeedFamilyFeedback = {
status: "open" | "in_progress" | "resolved" | "closed";
};
type SeedAiKnowledgeEntry = {
scope: "platform" | "organization";
title: string;
category: string;
tags: string;
body: string;
status: "enabled" | "disabled";
};
type SeedPreparedAiAnalysis = {
elderName: string;
createdHoursAgo: number;
dataScopes: string[];
result: ElderAiAnalysisOutput;
};
const DEFAULT_AI_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -433,8 +459,8 @@ const DEFAULT_ELDERS: SeedElder[] = [
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "首次入住评估已归档" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "照护计划已同步至护理组" },
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
@@ -902,7 +928,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
source: "设施巡检",
createdHoursAgo: 2,
},
{
@@ -910,7 +936,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
source: "护理排班",
createdHoursAgo: 6,
acknowledgedHoursAgo: 5,
},
@@ -918,8 +944,8 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
description: "重点照护房夜间巡房提醒已恢复,事件记录已归档便于后续追踪。",
source: "夜间巡房系统",
createdHoursAgo: 14,
acknowledgedHoursAgo: 13,
resolvedHoursAgo: 11,
@@ -1136,6 +1162,354 @@ const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
},
];
export const DEFAULT_AI_KNOWLEDGE_ENTRIES: SeedAiKnowledgeEntry[] = [
{
scope: "platform",
title: "夜间低血氧处理流程",
category: "医疗护理SOP",
tags: "血氧,吸氧,夜间,慢阻肺,应急复核",
body: "夜间连续血氧低于 92% 时,护理组需先确认探头位置、手部温度和体位,再记录复测值。血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。慢阻肺老人应同步检查吸氧设备、管路连接和医嘱流量,复核后在生命体征、护理任务和事件记录中闭环。",
status: "enabled",
},
{
scope: "platform",
title: "跌倒高风险住民照护要点",
category: "安全照护SOP",
tags: "防跌倒,夜间巡房,转移位,环境安全",
body: "跌倒高风险老人应保持床旁通道清晰,夜间保留低位照明,呼叫器放在伸手可及位置。转移位、如厕和康复训练需按照照护等级安排单人或双人协助;近期血压波动、认知障碍、术后康复、骨质疏松老人应提高巡视频次。发生跌倒或险情后,先评估意识、疼痛和活动能力,再通知家属并记录完整经过。",
status: "enabled",
},
{
scope: "organization",
title: "重点照护房夜间巡房规范",
category: "机构制度",
tags: "夜班,巡房,重点照护,交接班",
body: "重点照护房夜班至少每两小时巡房一次;存在血氧异常、吞咽风险、卧床压疮风险或夜间迷路风险的老人需补充专项观察。巡房记录应包含老人状态、体位、设备连接、呼叫器可用性和异常处理结果。未按计划完成的巡房任务需要夜班主管在交接班前补录原因和后续安排。",
status: "enabled",
},
{
scope: "organization",
title: "吞咽困难软食与呛咳观察",
category: "营养照护",
tags: "吞咽,软食,呛咳,营养,体重",
body: "吞咽困难老人进食时保持坐位,餐中小口慢食,避免催促。软食、增稠液和营养补充剂按医嘱执行,餐后半小时内不平卧。出现连续呛咳、声音湿哑、口唇发紫、进食量下降或体重持续下降时,应通知医生、营养师和家属,并将观察结果同步到护理任务。",
status: "enabled",
},
{
scope: "organization",
title: "异常情况家属同步规则",
category: "服务沟通",
tags: "家属沟通,异常同步,护理反馈,探访",
body: "涉及跌倒、血氧异常、发热、连续拒食、护理任务明显延误或设备故障影响照护时,责任护理组应在处置完成后同步家属。沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间;对仍在处理中事项,应在交接班中标注负责人,避免多次解释不一致。",
status: "enabled",
},
];
export const DEFAULT_PREPARED_AI_ANALYSES: SeedPreparedAiAnalysis[] = [
{
elderName: "钱松柏",
createdHoursAgo: 1,
dataScopes: ["elder", "health", "care", "incident", "facility", "knowledge"],
result: {
overallRiskLevel: "critical",
summary: "钱松柏夜间血氧低于 90%,且既往有慢阻肺和夜间吸氧记录,应优先完成呼吸状态复核、吸氧设备检查和医生评估。",
keyFindings: [
{
category: "呼吸风险",
severity: "critical",
evidence: "最新设备记录显示夜间血氧 89%,慢阻肺病史和长期吸入治疗增加夜间低氧风险。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
category: "设备闭环",
severity: "warning",
evidence: "系统事件已记录 S101 医养观察房血氧告警,需要确认吸氧设备、管路和医嘱流量。",
citationIds: ["resident-qsb-incident", "kb-night-spo2"],
},
],
recommendations: [
{
title: "立即复测血氧并通知值班医生",
priority: "urgent",
rationale: "血氧低于 90% 属于需要即时复核的高风险信号,且老人存在慢阻肺基础病。",
suggestedNextStep: "夜班护理组先复测血氧、检查吸氧设备和体位,再将复核结果同步给值班医生。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
title: "把低氧事件纳入交接班追踪",
priority: "high",
rationale: "告警、设备检查和医疗复核需要跨班次闭环,避免只完成单点处置。",
suggestedNextStep: "在护理任务中补充复测时间、吸氧流量确认结果和下一次观察时间。",
citationIds: ["resident-qsb-incident"],
},
],
dataGaps: ["缺少最近一次医生复核结论和吸氧流量确认记录。"],
citations: [
{
id: "resident-qsb-vitals",
sourceType: "resident_context",
sourceId: "elder:钱松柏:vitals",
title: "钱松柏生命体征与慢病记录",
excerpt: "慢阻肺,夜间吸氧;最新血氧记录 89%,心率 104需关注呼吸状态。",
},
{
id: "resident-qsb-incident",
sourceType: "resident_context",
sourceId: "elder:钱松柏:incident",
title: "S101 医养观察房血氧告警",
excerpt: "钱松柏夜间血氧持续低于阈值,需要护理组和医生复核。",
},
{
id: "kb-night-spo2",
sourceType: "knowledge",
sourceId: "夜间低血氧处理流程",
title: "夜间低血氧处理流程",
excerpt: "血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。",
},
],
confidence: 0.86,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "马淑芬",
createdHoursAgo: 3,
dataScopes: ["elder", "health", "care", "knowledge"],
result: {
overallRiskLevel: "high",
summary: "马淑芬存在吞咽困难、近期体重下降和晚餐软食观察任务,当前重点是防呛咳、保证摄入量并完成营养师复评。",
keyFindings: [
{
category: "吞咽与营养",
severity: "warning",
evidence: "健康档案记录脑梗后吞咽功能下降、近期体重下降;护理计划要求晚餐软食并观察呛咳。",
citationIds: ["resident-msf-health", "kb-swallowing"],
},
{
category: "护理优先级",
severity: "warning",
evidence: "晚餐软食进食观察任务为紧急优先级,需在进食时段完成并记录摄入情况。",
citationIds: ["resident-msf-care"],
},
],
recommendations: [
{
title: "进食时段安排专人观察",
priority: "high",
rationale: "吞咽风险与体重下降同时出现,餐中观察比事后补记更能降低呛咳风险。",
suggestedNextStep: "重点护理组在晚餐时记录坐位、进食量、呛咳情况和餐后半小时体位。",
citationIds: ["resident-msf-care", "kb-swallowing"],
},
{
title: "同步营养师复评体重趋势",
priority: "normal",
rationale: "近期体重偏低已进入复核,需结合摄入量决定是否调整加餐。",
suggestedNextStep: "汇总三天进食量、体重和呛咳观察后交由营养师复评。",
citationIds: ["resident-msf-health"],
},
],
dataGaps: ["缺少最近三天完整进食量记录。"],
citations: [
{
id: "resident-msf-health",
sourceType: "resident_context",
sourceId: "elder:马淑芬:health",
title: "马淑芬健康档案",
excerpt: "脑梗后吞咽功能下降,近期体重下降;软食,进食时保持坐位,餐后半小时内不平卧。",
},
{
id: "resident-msf-care",
sourceType: "resident_context",
sourceId: "elder:马淑芬:care",
title: "晚餐软食进食观察任务",
excerpt: "晚餐软食进食观察为紧急护理任务,需记录进食和呛咳情况。",
},
{
id: "kb-swallowing",
sourceType: "knowledge",
sourceId: "吞咽困难软食与呛咳观察",
title: "吞咽困难软食与呛咳观察",
excerpt: "吞咽困难老人进食时保持坐位,餐中小口慢食;出现连续呛咳、声音湿哑或进食量下降时应同步医生、营养师和家属。",
},
],
confidence: 0.8,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "王桂兰",
createdHoursAgo: 5,
dataScopes: ["elder", "health", "care", "family", "knowledge"],
result: {
overallRiskLevel: "medium",
summary: "王桂兰近期血压偏高但记录链路完整,建议继续晨晚复测、观察胸闷症状,并在家属探访前同步照护重点。",
keyFindings: [
{
category: "血压趋势",
severity: "warning",
evidence: "近期生命体征显示血压偏高,健康档案要求晨晚血压记录并在胸闷时立即联系医生。",
citationIds: ["resident-wgl-vitals"],
},
{
category: "家属沟通",
severity: "info",
evidence: "主要联系人每周六探访,可在探访前同步血压复测和低盐饮食执行情况。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
recommendations: [
{
title: "保持晨晚血压复测",
priority: "normal",
rationale: "当前为持续偏高而非单次异常,连续趋势比单点数值更适合指导照护安排。",
suggestedNextStep: "护理组在晨晚复测后记录数值、用药时间和是否胸闷。",
citationIds: ["resident-wgl-vitals"],
},
{
title: "探访前同步照护重点",
priority: "low",
rationale: "家属是主要联系人,提前同步可减少探访时的信息差。",
suggestedNextStep: "前台或责任护理员在探访前准备血压趋势、饮食执行和护理安排摘要。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
dataGaps: [],
citations: [
{
id: "resident-wgl-vitals",
sourceType: "resident_context",
sourceId: "elder:王桂兰:vitals",
title: "王桂兰生命体征与健康档案",
excerpt: "高血压十余年;最新血压偏高,需晨晚血压记录,胸闷或血压持续升高时立即联系医生。",
},
{
id: "resident-wgl-family",
sourceType: "resident_context",
sourceId: "elder:王桂兰:family",
title: "王桂兰家属联系人",
excerpt: "主要联系人张敏,每周六探访。",
},
{
id: "kb-family-sync",
sourceType: "knowledge",
sourceId: "异常情况家属同步规则",
title: "异常情况家属同步规则",
excerpt: "沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间。",
},
],
confidence: 0.74,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
];
function buildSeedKnowledgeContent(entry: SeedAiKnowledgeEntry): string {
return [entry.title, entry.category, entry.tags, entry.body].filter(Boolean).join("\n");
}
function getSeedKnowledgeKey(entry: Pick<SeedAiKnowledgeEntry, "scope" | "title">): string {
return `${entry.scope}:${entry.title}`;
}
async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [existingKnowledgeRows, elderRows, existingAnalysisRows] = await Promise.all([
database
.select({ scope: aiKnowledgeEntries.scope, title: aiKnowledgeEntries.title })
.from(aiKnowledgeEntries)
.where(
or(
and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId)),
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
),
),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
database.select({ elderId: elderAiAnalyses.elderId }).from(elderAiAnalyses).where(eq(elderAiAnalyses.organizationId, organizationId)),
]);
const existingKnowledgeKeys = new Set(existingKnowledgeRows.map(getSeedKnowledgeKey));
const missingKnowledgeEntries = DEFAULT_AI_KNOWLEDGE_ENTRIES.filter((entry) => !existingKnowledgeKeys.has(getSeedKnowledgeKey(entry)));
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const analyzedElderIds = new Set(existingAnalysisRows.map((analysis) => analysis.elderId));
const missingAnalyses = DEFAULT_PREPARED_AI_ANALYSES.filter((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
return elderId !== undefined && !analyzedElderIds.has(elderId);
});
if (missingKnowledgeEntries.length === 0 && missingAnalyses.length === 0) {
return;
}
const now = new Date();
await database.transaction(async (transaction) => {
if (missingKnowledgeEntries.length > 0) {
const knowledgeRows = await transaction
.insert(aiKnowledgeEntries)
.values(
missingKnowledgeEntries.map((entry) => ({
scope: entry.scope,
organizationId: entry.scope === "organization" ? organizationId : null,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
createdAt: now,
updatedAt: now,
})),
)
.returning();
if (knowledgeRows.length !== missingKnowledgeEntries.length) {
throw new Error("AI 知识库初始化失败");
}
const sourceByKey = new Map(missingKnowledgeEntries.map((entry) => [getSeedKnowledgeKey(entry), entry]));
await transaction.insert(aiKnowledgeChunks).values(
knowledgeRows.map((row) => {
const source = sourceByKey.get(getSeedKnowledgeKey(row));
if (!source) {
throw new Error("AI 知识库分块初始化失败");
}
return {
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: 0,
content: buildSeedKnowledgeContent(source),
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
createdAt: now,
};
}),
);
}
if (missingAnalyses.length > 0) {
await transaction.insert(elderAiAnalyses).values(
missingAnalyses.map((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
if (!elderId) {
throw new Error("AI 分析记录初始化失败");
}
return {
organizationId,
elderId,
status: "completed" as const,
dataScopes: analysis.dataScopes,
resultJson: analysis.result,
citationsJson: analysis.result.citations,
modelSummaryJson: analysis.result.modelSummary,
createdAt: new Date(now.getTime() - analysis.createdHoursAgo * 60 * 60 * 1000),
};
}),
);
}
});
}
function hasRows(rows: unknown[]): boolean {
return rows.length > 0;
}
@@ -1150,6 +1524,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
await seedDefaultAiWorkspaceData(organizationId);
return;
}
@@ -1608,4 +1983,5 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
});
await seedDefaultAiWorkspaceData(organizationId);
}

View File

@@ -47,8 +47,6 @@ export const aiKnowledgeScopeEnum = pgEnum("ai_knowledge_scope", ["platform", "o
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"),
registrationEnabled: boolean("registration_enabled").notNull().default(true),