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

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