565 lines
20 KiB
TypeScript
565 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { OpenAIEmbeddings } from "@langchain/openai";
|
|
import {
|
|
createKnowledgeEntry,
|
|
listKnowledgeEntries,
|
|
retrieveKnowledge,
|
|
updateKnowledgeEntry,
|
|
} from "@/modules/ai/server/knowledge";
|
|
import type { KnowledgeEntryInput } from "@/modules/ai/types";
|
|
import type { AppDatabase } from "@/modules/core/server/db";
|
|
import { getDatabase } from "@/modules/core/server/db";
|
|
import type { AuthContext, Permission } from "@/modules/core/types";
|
|
|
|
|
|
const drizzleDsl = vi.hoisted(() => {
|
|
type TableName = "entries" | "chunks";
|
|
type ColumnRef = { table: TableName; key: string };
|
|
const entries = {
|
|
id: { table: "entries", key: "id" } as ColumnRef,
|
|
scope: { table: "entries", key: "scope" } as ColumnRef,
|
|
organizationId: { table: "entries", key: "organizationId" } as ColumnRef,
|
|
status: { table: "entries", key: "status" } as ColumnRef,
|
|
updatedAt: { table: "entries", key: "updatedAt" } as ColumnRef,
|
|
};
|
|
const chunks = {
|
|
id: { table: "chunks", key: "id" } as ColumnRef,
|
|
entryId: { table: "chunks", key: "entryId" } as ColumnRef,
|
|
scope: { table: "chunks", key: "scope" } as ColumnRef,
|
|
organizationId: { table: "chunks", key: "organizationId" } as ColumnRef,
|
|
sourceTitle: { table: "chunks", key: "sourceTitle" } as ColumnRef,
|
|
sourceCategory: { table: "chunks", key: "sourceCategory" } as ColumnRef,
|
|
content: { table: "chunks", key: "content" } as ColumnRef,
|
|
};
|
|
return { entries, chunks };
|
|
});
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
vi.mock("@langchain/openai", () => ({
|
|
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
|
|
return {};
|
|
}),
|
|
}));
|
|
|
|
|
|
vi.mock("@/modules/core/server/db", () => ({
|
|
getDatabase: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/modules/core/server/schema", () => ({
|
|
aiKnowledgeEntries: drizzleDsl.entries,
|
|
aiKnowledgeChunks: drizzleDsl.chunks,
|
|
}));
|
|
|
|
vi.mock("drizzle-orm", () => {
|
|
type JoinedRow = {
|
|
entries?: Record<string, unknown>;
|
|
chunks?: Record<string, unknown>;
|
|
};
|
|
type ColumnRef = { table: "entries" | "chunks"; key: string };
|
|
type Predicate = (row: JoinedRow) => boolean;
|
|
|
|
function readColumn(row: JoinedRow, column: ColumnRef): unknown {
|
|
return row[column.table]?.[column.key];
|
|
}
|
|
|
|
return {
|
|
eq: (column: ColumnRef, expected: unknown): Predicate => (row) => readColumn(row, column) === expected,
|
|
isNull: (column: ColumnRef): Predicate => (row) => readColumn(row, column) === null || readColumn(row, column) === undefined,
|
|
and: (...predicates: Predicate[]): Predicate => (row) => predicates.every((predicate) => predicate(row)),
|
|
or: (...predicates: Predicate[]): Predicate => (row) => predicates.some((predicate) => predicate(row)),
|
|
desc: (column: ColumnRef) => ({ type: "desc", column }),
|
|
sql: () => ({ type: "distance" }),
|
|
};
|
|
});
|
|
|
|
type KnowledgeScope = "platform" | "organization";
|
|
type KnowledgeStatus = "enabled" | "disabled";
|
|
|
|
type KnowledgeRow = {
|
|
id: string;
|
|
scope: KnowledgeScope;
|
|
organizationId: string | null;
|
|
title: string;
|
|
category: string;
|
|
tags: string;
|
|
body: string;
|
|
status: KnowledgeStatus;
|
|
createdByAccountId: string | null;
|
|
updatedByAccountId: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
type KnowledgeChunkRow = {
|
|
id: string;
|
|
entryId: string;
|
|
organizationId: string | null;
|
|
scope: KnowledgeScope;
|
|
chunkIndex: number;
|
|
content: string;
|
|
sourceTitle: string;
|
|
sourceCategory: string;
|
|
createdAt: Date;
|
|
};
|
|
|
|
type JoinedRow = {
|
|
entries?: KnowledgeRow;
|
|
chunks?: KnowledgeChunkRow;
|
|
};
|
|
|
|
type Predicate = (row: JoinedRow) => boolean;
|
|
type SelectRow = KnowledgeRow | RetrievedChunkRow;
|
|
|
|
type RetrievedChunkRow = {
|
|
entryId: string;
|
|
chunkId: string;
|
|
title: string;
|
|
category: string;
|
|
content: string;
|
|
};
|
|
|
|
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 organization: NonNullable<AuthContext["organization"]> = {
|
|
id: "org-1",
|
|
name: "TeaCare Home",
|
|
slug: "teacare",
|
|
status: "active",
|
|
registrationEnabled: true,
|
|
oidcEnabled: false,
|
|
oidcIssuerUrl: "",
|
|
oidcClientId: "",
|
|
oidcHasClientSecret: false,
|
|
oidcScopes: "",
|
|
oidcRedirectUri: "",
|
|
oidcAvatarClaim: "",
|
|
oidcAutoProvision: false,
|
|
createdAt: "2026-07-02T00:00:00.000Z",
|
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
|
};
|
|
|
|
const account: AuthContext["account"] = {
|
|
id: "account-1",
|
|
name: "Knowledge Admin",
|
|
email: "knowledge@example.com",
|
|
avatarUrl: "",
|
|
role: "org_admin",
|
|
status: "active",
|
|
createdAt: "2026-07-02T00:00:00.000Z",
|
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
|
};
|
|
|
|
const baseInput: KnowledgeEntryInput = {
|
|
scope: "organization",
|
|
organizationId: "org-1",
|
|
title: "Fall prevention",
|
|
category: "Safety",
|
|
tags: "fall,night",
|
|
body: "Keep walkways clear and increase night rounds after wandering events.",
|
|
status: "enabled",
|
|
};
|
|
|
|
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
|
|
|
function createAuthContext(input: { permissions?: Permission[]; activeOrganization?: boolean } = {}): AuthContext {
|
|
const activeOrganization = input.activeOrganization ?? true;
|
|
const permissions = input.permissions ?? ["knowledge:read", "knowledge:manage"];
|
|
return {
|
|
account,
|
|
organization: activeOrganization ? organizationContext : undefined,
|
|
organizations: [
|
|
{
|
|
id: organizationContext.id,
|
|
name: organizationContext.name,
|
|
slug: organizationContext.slug,
|
|
status: organizationContext.status,
|
|
registrationEnabled: organizationContext.registrationEnabled,
|
|
oidcEnabled: organizationContext.oidcEnabled,
|
|
roleLabel: "Admin",
|
|
isActive: activeOrganization,
|
|
},
|
|
],
|
|
membership: activeOrganization
|
|
? {
|
|
id: "membership-1",
|
|
accountId: account.id,
|
|
organizationId: organizationContext.id,
|
|
roleId: "role-1",
|
|
roleKey: "org_admin",
|
|
roleLabel: "Admin",
|
|
status: "active",
|
|
createdAt: "2026-07-02T00:00:00.000Z",
|
|
updatedAt: "2026-07-02T00:00:00.000Z",
|
|
}
|
|
: undefined,
|
|
permissions,
|
|
session: {
|
|
id: "session-1",
|
|
accountId: account.id,
|
|
activeOrganizationId: activeOrganization ? organizationContext.id : undefined,
|
|
createdAt: "2026-07-02T00:00:00.000Z",
|
|
expiresAt: "2026-07-09T00:00:00.000Z",
|
|
},
|
|
};
|
|
}
|
|
|
|
function createEntry(overrides: Partial<KnowledgeRow> = {}): KnowledgeRow {
|
|
return {
|
|
id: "entry-org-1",
|
|
scope: "organization",
|
|
organizationId: "org-1",
|
|
title: "Org fall prevention",
|
|
category: "Safety",
|
|
tags: "fall",
|
|
body: "Organization-specific fall prevention guidance.",
|
|
status: "enabled",
|
|
createdByAccountId: "account-1",
|
|
updatedByAccountId: "account-1",
|
|
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow> = {}): KnowledgeChunkRow {
|
|
return {
|
|
id: `${entry.id}-chunk-1`,
|
|
entryId: entry.id,
|
|
organizationId: entry.organizationId,
|
|
scope: entry.scope,
|
|
chunkIndex: 0,
|
|
content: entry.body,
|
|
sourceTitle: entry.title,
|
|
sourceCategory: entry.category,
|
|
createdAt: new Date("2026-07-02T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
class SelectBuilder implements PromiseLike<SelectRow[]> {
|
|
private table: "entries" | "chunks" = "entries";
|
|
private predicate: Predicate = () => true;
|
|
|
|
constructor(
|
|
private readonly entries: KnowledgeRow[],
|
|
private readonly chunks: KnowledgeChunkRow[],
|
|
) {}
|
|
|
|
from(table: unknown): SelectBuilder {
|
|
this.table = table === drizzleDsl.chunks ? "chunks" : "entries";
|
|
return this;
|
|
}
|
|
|
|
innerJoin(): SelectBuilder {
|
|
return this;
|
|
}
|
|
|
|
where(predicate: Predicate): SelectBuilder {
|
|
this.predicate = predicate;
|
|
return this;
|
|
}
|
|
|
|
orderBy(): SelectBuilder {
|
|
return this;
|
|
}
|
|
|
|
limit(count: number): Promise<SelectRow[]> {
|
|
return Promise.resolve(this.execute().slice(0, count));
|
|
}
|
|
|
|
then<TResult1 = SelectRow[], TResult2 = never>(
|
|
onfulfilled?: ((value: SelectRow[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
): Promise<TResult1 | TResult2> {
|
|
return Promise.resolve(this.execute()).then(onfulfilled, onrejected);
|
|
}
|
|
|
|
private execute(): SelectRow[] {
|
|
if (this.table === "entries") {
|
|
return this.entries
|
|
.filter((entry) => this.predicate({ entries: entry }))
|
|
.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
|
|
}
|
|
|
|
const rows: RetrievedChunkRow[] = [];
|
|
for (const chunk of this.chunks) {
|
|
const entry = this.entries.find((candidate) => candidate.id === chunk.entryId);
|
|
if (entry && this.predicate({ entries: entry, chunks: chunk })) {
|
|
rows.push({
|
|
entryId: chunk.entryId,
|
|
chunkId: chunk.id,
|
|
title: chunk.sourceTitle,
|
|
category: chunk.sourceCategory,
|
|
content: chunk.content,
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
}
|
|
|
|
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,
|
|
transactionWrites,
|
|
transaction: vi.fn(runTransaction),
|
|
select: vi.fn(() => new SelectBuilder(entries, chunks)),
|
|
};
|
|
}
|
|
|
|
function useDatabase(fake: KnowledgeDatabaseFake): void {
|
|
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useDatabase(createDatabaseFake());
|
|
});
|
|
|
|
describe("AI knowledge service", () => {
|
|
it("lists platform and active-organization entries while excluding other organizations", async () => {
|
|
const platformEntry = createEntry({
|
|
id: "entry-platform",
|
|
scope: "platform",
|
|
organizationId: null,
|
|
title: "Platform safety standard",
|
|
updatedAt: new Date("2026-07-03T00:00:00.000Z"),
|
|
});
|
|
const orgEntry = createEntry({ id: "entry-org-1", title: "Org safety note" });
|
|
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org note" });
|
|
useDatabase(createDatabaseFake({ entries: [orgEntry, otherOrgEntry, platformEntry] }));
|
|
|
|
const entries = await listKnowledgeEntries(createAuthContext());
|
|
|
|
expect(entries.map((entry) => entry.id)).toEqual(["entry-platform", "entry-org-1"]);
|
|
expect(entries).toEqual([
|
|
expect.objectContaining({ id: "entry-platform", scope: "platform", organizationId: undefined }),
|
|
expect.objectContaining({ id: "entry-org-1", scope: "organization", organizationId: "org-1" }),
|
|
]);
|
|
});
|
|
|
|
it("retrieves only enabled platform and active-organization chunks", async () => {
|
|
const platformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null, title: "Platform standard" });
|
|
const orgEntry = createEntry({ id: "entry-org-1", title: "Org guidance" });
|
|
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled guidance" });
|
|
const otherOrgEntry = createEntry({ id: "entry-org-2", organizationId: "org-2", title: "Other org guidance" });
|
|
const database = createDatabaseFake({
|
|
entries: [platformEntry, orgEntry, disabledEntry, otherOrgEntry],
|
|
chunks: [
|
|
createChunk(platformEntry),
|
|
createChunk(orgEntry),
|
|
createChunk(disabledEntry),
|
|
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
|
|
],
|
|
});
|
|
useDatabase(database);
|
|
|
|
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 10 });
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: {
|
|
results: [
|
|
expect.objectContaining({ entryId: "entry-platform", title: "Platform standard" }),
|
|
expect.objectContaining({ entryId: "entry-org-1", title: "Org guidance" }),
|
|
],
|
|
},
|
|
});
|
|
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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, unrelatedEntry, disabledEntry, otherOrgEntry],
|
|
chunks: [
|
|
createChunk(weakEntry),
|
|
createChunk(strongEntry),
|
|
createChunk(unrelatedEntry),
|
|
createChunk(disabledEntry),
|
|
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
|
|
],
|
|
}));
|
|
|
|
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: 1 / 3 }),
|
|
],
|
|
},
|
|
});
|
|
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires platform management permission before mutating platform knowledge", async () => {
|
|
const existingPlatformEntry = createEntry({ id: "entry-platform", scope: "platform", organizationId: null });
|
|
useDatabase(createDatabaseFake({ entries: [existingPlatformEntry] }));
|
|
|
|
const result = await updateKnowledgeEntry(
|
|
createAuthContext({ permissions: ["knowledge:manage"] }),
|
|
"entry-platform",
|
|
baseInput,
|
|
);
|
|
|
|
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
|
|
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires an active organization before mutating organization knowledge", async () => {
|
|
const result = await createKnowledgeEntry(
|
|
createAuthContext({ activeOrganization: false, permissions: ["knowledge:manage"] }),
|
|
baseInput,
|
|
);
|
|
|
|
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
|
|
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects attempts to mutate another organization's knowledge", async () => {
|
|
const result = await createKnowledgeEntry(createAuthContext(), {
|
|
...baseInput,
|
|
organizationId: "org-2",
|
|
});
|
|
|
|
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
|
|
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
|
|
const created = await createKnowledgeEntry(createAuthContext(), baseInput);
|
|
|
|
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();
|
|
});
|
|
});
|