fix: harden AI analysis and seed defaults
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user