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

@@ -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 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));
}
}
}
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,
}),
};
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),
},