fix: harden AI analysis and seed defaults
This commit is contained in:
@@ -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 ? "编辑知识" : "新增知识"}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") || "无"}`,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user