feat: add local LLMWiki wiki vector index
This commit is contained in:
1 parent
4c9f75f31e
commit
3785345353
48 files changed
+3650
-152
No files matched your search
@@ -95,6 +95,12 @@ offline-backend-20260512/backend/tests/*
|
|||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
!offline-backend-20260512/backend/tests/test_fixed_questions.py
|
||||||
|
# Keep the local LLMWiki vector-index and external API regression coverage versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
|
||||||
deploy
|
deploy
|
||||||
.history
|
.history
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export function SourceCard({
|
|||||||
: null;
|
: null;
|
||||||
const meta = [
|
const meta = [
|
||||||
source.source,
|
source.source,
|
||||||
|
previewTarget?.wikiSlug && source.raw?.matched_sections
|
||||||
|
? `Wiki 章节 ${String((source.raw.matched_sections as Array<{ heading?: string }>)[0]?.heading || "")}`
|
||||||
|
: null,
|
||||||
source.author ? `作者 ${source.author}` : null,
|
source.author ? `作者 ${source.author}` : null,
|
||||||
source.type && source.type !== "result" ? source.type : null,
|
source.type && source.type !== "result" ? source.type : null,
|
||||||
source.time,
|
source.time,
|
||||||
@@ -132,7 +135,7 @@ export function SourceCard({
|
|||||||
previewTarget ? (
|
previewTarget ? (
|
||||||
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||||
<FileTextIcon className="size-3.5" />
|
<FileTextIcon className="size-3.5" />
|
||||||
知识库文档
|
{previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||||
@@ -151,7 +154,7 @@ export function SourceCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
||||||
查看详情
|
{previewTarget.wikiSlug ? "查看 Wiki" : "查看详情"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget
|
|||||||
params.get("wikiSlug")?.trim() ||
|
params.get("wikiSlug")?.trim() ||
|
||||||
params.get("slug")?.trim() ||
|
params.get("slug")?.trim() ||
|
||||||
"";
|
"";
|
||||||
return knowledgeBaseId && chunkId
|
return knowledgeBaseId && (wikiSlug || chunkId || documentId)
|
||||||
? {
|
? {
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
chunkId,
|
chunkId,
|
||||||
@@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora
|
|||||||
fromPreview?.documentId ||
|
fromPreview?.documentId ||
|
||||||
"";
|
"";
|
||||||
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
|
||||||
|
if (!wikiSlug && !chunkId && !documentId) return null;
|
||||||
return {
|
return {
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
chunkId: chunkId || undefined,
|
chunkId: chunkId || undefined,
|
||||||
|
|||||||
@@ -143,6 +143,22 @@ export interface LlmWikiWikiPageList {
|
|||||||
total_pages: number;
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LlmWikiWikiIndexStatus {
|
||||||
|
knowledge_base_id: string;
|
||||||
|
state: "not_synced" | "idle" | "running" | "partial" | "failed" | "rebuilding" | "rebuild_required";
|
||||||
|
local_index_enabled: boolean;
|
||||||
|
wiki_index_enabled: boolean;
|
||||||
|
fully_vectorized: boolean;
|
||||||
|
retrieval_mode: "vector" | "wiki_api";
|
||||||
|
local_page_count?: number;
|
||||||
|
ready_page_count?: number;
|
||||||
|
failed_page_count?: number;
|
||||||
|
vector_count?: number;
|
||||||
|
last_error?: string | null;
|
||||||
|
last_started_at?: string | null;
|
||||||
|
last_completed_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LlmWikiSourceContext {
|
export interface LlmWikiSourceContext {
|
||||||
knowledge_base_name?: string;
|
knowledge_base_name?: string;
|
||||||
document_title?: string;
|
document_title?: string;
|
||||||
@@ -206,9 +222,13 @@ export interface LlmWikiWikiPageUpdate {
|
|||||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
const detail =
|
const rawDetail = payload && typeof payload === "object" && "detail" in payload
|
||||||
payload && typeof payload === "object" && "detail" in payload
|
? (payload as { detail: unknown }).detail
|
||||||
? String((payload as { detail: unknown }).detail)
|
: null;
|
||||||
|
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
|
||||||
|
? String((rawDetail as { message: unknown }).message)
|
||||||
|
: rawDetail
|
||||||
|
? String(rawDetail)
|
||||||
: `${context}失败(${response.status})`;
|
: `${context}失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
@@ -412,6 +432,66 @@ export async function getLlmWikiWikiPage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLlmWikiWikiIndexStatus(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
): Promise<LlmWikiWikiIndexStatus> {
|
||||||
|
return readJson(
|
||||||
|
await apiFetch(
|
||||||
|
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki-index-status`,
|
||||||
|
),
|
||||||
|
"读取 Wiki 向量化状态",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vectorizeLlmWikiKnowledgeBase(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
): Promise<{ job_id: string; status: string; knowledge_base_id: string }> {
|
||||||
|
return readJson(
|
||||||
|
await apiFetch(
|
||||||
|
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/vectorize`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
"触发 Wiki 向量化",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportLlmWikiWikiExcel(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
knowledgeBaseName = "LLMWiki",
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/export.xlsx`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
await readJson<void>(response, "导出 Wiki Excel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `${knowledgeBaseName.replace(/[\\/:*?"<>|]/g, "_") || "LLMWiki"}-Wiki.xlsx`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the DeerFlow-local Wiki mirror used by new vector citations. */
|
||||||
|
export async function getLocalLlmWikiWikiPage(
|
||||||
|
knowledgeBaseId: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<LlmWikiWikiPage> {
|
||||||
|
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||||
|
const page = await readJson<LlmWikiWikiPage & { page_version?: number }>(
|
||||||
|
await apiFetch(
|
||||||
|
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/local-wiki/pages/${safeSlug}`,
|
||||||
|
),
|
||||||
|
"读取本地 Wiki 页面详情",
|
||||||
|
);
|
||||||
|
return { ...page, version: page.version ?? page.page_version ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createLlmWikiWikiPage(
|
export async function createLlmWikiWikiPage(
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
input: LlmWikiWikiPageCreate,
|
input: LlmWikiWikiPageCreate,
|
||||||
@@ -755,6 +835,22 @@ export function useLlmWikiWikiPage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLlmWikiWikiIndexStatus(
|
||||||
|
knowledgeBaseId: string | null,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
const { userId } = useLiveAccount();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["llmwiki", "wiki-index-status", knowledgeBaseId, userId ?? "anonymous"],
|
||||||
|
queryFn: () => getLlmWikiWikiIndexStatus(knowledgeBaseId!),
|
||||||
|
enabled: enabled && !!knowledgeBaseId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const state = query.state.data?.state;
|
||||||
|
return state === "running" || state === "rebuilding" ? 2_000 : 10_000;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useLlmWikiSettings(enabled = true) {
|
export function useLlmWikiSettings(enabled = true) {
|
||||||
const { userId } = useLiveAccount();
|
const { userId } = useLiveAccount();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -822,6 +918,14 @@ export function useLlmWikiActions() {
|
|||||||
deleteLlmWikiWikiPage(id, slug),
|
deleteLlmWikiWikiPage(id, slug),
|
||||||
onSuccess: invalidateKnowledge,
|
onSuccess: invalidateKnowledge,
|
||||||
}),
|
}),
|
||||||
|
vectorizeWiki: useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string }) => vectorizeLlmWikiKnowledgeBase(id),
|
||||||
|
onSuccess: invalidateKnowledge,
|
||||||
|
}),
|
||||||
|
exportWikiExcel: useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name?: string }) =>
|
||||||
|
exportLlmWikiWikiExcel(id, name),
|
||||||
|
}),
|
||||||
publication: useMutation({
|
publication: useMutation({
|
||||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||||
setLlmWikiPublication(id, publish),
|
setLlmWikiPublication(id, publish),
|
||||||
|
|||||||
+10
-5
@@ -20,7 +20,7 @@ import { reasoningPlugins } from "@/core/streamdown";
|
|||||||
import {
|
import {
|
||||||
getLlmWikiDocument,
|
getLlmWikiDocument,
|
||||||
getLlmWikiDocumentPreview,
|
getLlmWikiDocumentPreview,
|
||||||
getLlmWikiWikiPage,
|
getLocalLlmWikiWikiPage,
|
||||||
listLlmWikiDocumentChunks,
|
listLlmWikiDocumentChunks,
|
||||||
type LlmWikiChunk,
|
type LlmWikiChunk,
|
||||||
type LlmWikiDocument,
|
type LlmWikiDocument,
|
||||||
@@ -528,12 +528,17 @@ export function WeKnoraSourceDrawer({
|
|||||||
|
|
||||||
if (detailTarget.wikiSlug) {
|
if (detailTarget.wikiSlug) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
getLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
getLocalLlmWikiWikiPage(detailTarget.knowledgeBaseId, detailTarget.wikiSlug)
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
loadedWikiPage = page;
|
loadedWikiPage = page;
|
||||||
if (!cancelled) setWikiPage(page);
|
if (!cancelled) setWikiPage(page);
|
||||||
})
|
})
|
||||||
.catch(() => undefined),
|
.catch((error) => {
|
||||||
|
// New Wiki citations have no legacy document/chunk fallback, so
|
||||||
|
// surface mirror/index failures instead of rendering an empty drawer.
|
||||||
|
if (!detailTarget.documentId) throw error;
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +654,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
<SheetTitle className="sr-only">{resolvedTitle}</SheetTitle>
|
||||||
<SheetDescription className="sr-only">知识库文档详情</SheetDescription>
|
<SheetDescription className="sr-only">知识库 Wiki 页面详情</SheetDescription>
|
||||||
<div className="relative flex h-full min-h-0 flex-col bg-background">
|
<div className="relative flex h-full min-h-0 flex-col bg-background">
|
||||||
<div className="shrink-0 border-b px-5 py-4 pr-64">
|
<div className="shrink-0 border-b px-5 py-4 pr-64">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -690,7 +695,7 @@ export function WeKnoraSourceDrawer({
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
正在加载完整文档预览
|
正在加载 Wiki 页面
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+49
-2
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, ChevronDown, ChevronRight, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
import { ArrowLeft, ChevronDown, ChevronRight, Cpu, Download, FilePlus2, FileText, Folder, Loader2, RefreshCw, Save, Search, Trash2 } from "lucide-react";
|
||||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
|
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
|
||||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useLlmWikiActions,
|
useLlmWikiActions,
|
||||||
useLlmWikiKnowledgeBase,
|
useLlmWikiKnowledgeBase,
|
||||||
useLlmWikiRuntime,
|
useLlmWikiRuntime,
|
||||||
|
useLlmWikiWikiIndexStatus,
|
||||||
useLlmWikiWikiPage,
|
useLlmWikiWikiPage,
|
||||||
useLlmWikiWikiPages,
|
useLlmWikiWikiPages,
|
||||||
} from "@/strategy-components/api/llmwiki";
|
} from "@/strategy-components/api/llmwiki";
|
||||||
@@ -298,6 +299,7 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
|
|
||||||
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
|
const canWrite = !!details.data?.can_write && !details.data?.is_conversation_deposit;
|
||||||
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
const pages = useLlmWikiWikiPages(noteId || null, 1, 500, "", !!noteId);
|
||||||
|
const indexStatus = useLlmWikiWikiIndexStatus(noteId || null, !!noteId);
|
||||||
const detail = useLlmWikiWikiPage(
|
const detail = useLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
selectedSlug || null,
|
selectedSlug || null,
|
||||||
@@ -561,6 +563,34 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const vectorize = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
try {
|
||||||
|
await actions.vectorizeWiki.mutateAsync({ id: noteId });
|
||||||
|
toast.success("已提交整库 Wiki 向量化任务");
|
||||||
|
await indexStatus.refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "触发向量化失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportExcel = async () => {
|
||||||
|
try {
|
||||||
|
await actions.exportWikiExcel.mutateAsync({ id: noteId, name: title });
|
||||||
|
toast.success("Wiki 已全部导出为 Excel");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "导出 Wiki Excel 失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const vectorStatusText = !indexStatus.data?.local_index_enabled
|
||||||
|
? "本地向量未启用,检索使用 Wiki 接口"
|
||||||
|
: indexStatus.data.fully_vectorized
|
||||||
|
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
||||||
|
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
||||||
|
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
||||||
|
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
|
<header className="flex min-h-14 shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-2">
|
||||||
@@ -571,9 +601,26 @@ export function WeKnoraWikiManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
<h1 className="truncate text-base font-semibold text-foreground">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除</p>
|
<p className="ml-9 mt-0.5 text-xs text-muted-foreground">Wiki 文档新增、编辑、删除 · {vectorStatusText}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void vectorize()}
|
||||||
|
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
||||||
|
>
|
||||||
|
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Cpu className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
手动向量化
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void exportExcel()} disabled={actions.exportWikiExcel.isPending}>
|
||||||
|
{actions.exportWikiExcel.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Download className="mr-2 size-4" />}
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
|
<Button variant="outline" size="sm" onClick={() => void pages.refetch()}>
|
||||||
<RefreshCw className="mr-2 size-4" />
|
<RefreshCw className="mr-2 size-4" />
|
||||||
刷新
|
刷新
|
||||||
|
|||||||
@@ -1329,3 +1329,52 @@ candidates fail with 409 rather than overwrite a later edit. Ambiguous or
|
|||||||
ordinary evidence questions must remain non-mutating, and cancellation or a
|
ordinary evidence questions must remain non-mutating, and cancellation or a
|
||||||
stream error must never save a partial report. The rewrite prompt receives only
|
stream error must never save a partial report. The rewrite prompt receives only
|
||||||
the selected range plus the selected evidence set, never a new web search.
|
the selected range plus the selected evidence set, never a new web search.
|
||||||
|
|
||||||
|
### DeerFlow-local LLMWiki Wiki index
|
||||||
|
|
||||||
|
`llmwiki.local_wiki_index` is an opt-in local-vector subsystem. WeKnora is the
|
||||||
|
Wiki generation/sync source and the Wiki-only fallback while an entire mapping
|
||||||
|
has not completed vectorization. The readiness gate requires `state=idle`, a
|
||||||
|
successful full scan, matching embedding fingerprint, no failed pages,
|
||||||
|
`ready_page_count == local_page_count`, and vectors for a non-empty library.
|
||||||
|
Ready mappings read DeerFlow's `llmwiki_wiki_pages` /
|
||||||
|
`llmwiki_wiki_vectors` / `llmwiki_wiki_sync_states` tables; incomplete mappings
|
||||||
|
call only WeKnora `/knowledgebase/{id}/wiki/pages?query=...` plus Wiki-page
|
||||||
|
detail. Never call `/knowledge-search` from internal search, RAG, or the
|
||||||
|
`llmwiki_search` tool, and never return raw chunks as the fallback. Core code lives under
|
||||||
|
`deerflow.integrations.weknora.local_index` and
|
||||||
|
`deerflow.persistence.llmwiki_index`; preserve the harness → app import
|
||||||
|
firewall. Gateway composition injects the store/search/sync services through
|
||||||
|
`local_index.runtime`, and the lease-protected scheduler starts only when both
|
||||||
|
`enabled` and `sync_enabled` are true.
|
||||||
|
|
||||||
|
Vectors are normalized little-endian float32 BLOBs. A page mirror and all of
|
||||||
|
its vectors are replaced in one transaction only after strict batched
|
||||||
|
embedding succeeds. Never delete the old generation before a remote embedding
|
||||||
|
call and never fall back to WeKnora raw chunks while the local feature flag is
|
||||||
|
enabled. NumPy matrices are per-mapping LRU snapshots invalidated by
|
||||||
|
`index_revision`; large decode/matrix work runs through `asyncio.to_thread`.
|
||||||
|
During a fingerprint rebuild the mapping uses the Wiki-page API until every
|
||||||
|
page is successfully rebuilt. `WikiRetrievalService` partitions mixed requests
|
||||||
|
per mapping, so ready libraries stay local while incomplete libraries use that
|
||||||
|
Wiki-only fallback.
|
||||||
|
|
||||||
|
Owners manually force a full sync/vectorization through `POST
|
||||||
|
/api/llmwiki/knowledge-bases/{id}/vectorize`; status is available through `GET
|
||||||
|
/api/llmwiki/knowledge-bases/{id}/wiki-index-status`. The Wiki management page
|
||||||
|
also exports all remote processed Wiki pages via `GET
|
||||||
|
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx`. Directory export must keep
|
||||||
|
the full path, current directory, parent path, and dynamic per-level columns;
|
||||||
|
long Markdown is split into Excel-safe continuation columns instead of being
|
||||||
|
truncated. Workbook creation runs in `asyncio.to_thread`.
|
||||||
|
|
||||||
|
Internal search authorization always starts from DeerFlow mapping ids and is
|
||||||
|
rechecked by the Gateway. External endpoints use the isolated
|
||||||
|
`/api/external/llmwiki/wiki` namespace and `X-API-Key`; require published +
|
||||||
|
external-enabled + index-enabled on every request and response, and hard-deny
|
||||||
|
the `system` / `对话沉淀` mapping. Never return remote WeKnora ids, provenance
|
||||||
|
refs, credentials, or vector bytes externally. Regression tests:
|
||||||
|
`tests/test_llmwiki_local_index_repository.py`,
|
||||||
|
`tests/test_llmwiki_wiki_sync.py`, `tests/test_llmwiki_vector_search.py`, and
|
||||||
|
`tests/test_external_llmwiki_search.py`, and
|
||||||
|
`tests/test_llmwiki_wiki_retrieval.py`.
|
||||||
@@ -746,3 +746,38 @@ without deleting the audit row. DeerFlow stores only the artifact reference
|
|||||||
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
|
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
|
||||||
never the Markdown body and never a task-system delivery. Continue to preview
|
never the Markdown body and never a task-system delivery. Continue to preview
|
||||||
the file through the existing thread-artifact endpoint.
|
the file through the existing thread-artifact endpoint.
|
||||||
|
|
||||||
|
## DeerFlow-local LLMWiki Wiki vector index
|
||||||
|
|
||||||
|
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
||||||
|
processed Wiki content. A knowledge base uses exact NumPy cosine similarity
|
||||||
|
only after its whole Wiki has completed one coherent vectorization pass; until
|
||||||
|
then internal search calls WeKnora's Wiki-page search/list API and reads the
|
||||||
|
matched Wiki pages. It never falls back to raw documents or chunks. The local
|
||||||
|
vector feature is disabled by default. Configure
|
||||||
|
`llmwiki.local_wiki_index.embedding` first, then enable `enabled`,
|
||||||
|
`sync_enabled`, and `internal_search_enabled` in `config.yaml`.
|
||||||
|
|
||||||
|
The migration creates `llmwiki_wiki_pages`, `llmwiki_wiki_vectors`, and
|
||||||
|
`llmwiki_wiki_sync_states`. Wiki Markdown and normalized float32 vectors are
|
||||||
|
stored in DeerFlow's configured SQL database. Multi-worker sync is protected by
|
||||||
|
database leases; searches use revision-checked per-knowledge-base LRU snapshots
|
||||||
|
after the full-library readiness gate succeeds. Knowledge-base owners can click
|
||||||
|
**手动向量化** in Wiki management (`POST
|
||||||
|
/api/llmwiki/knowledge-bases/{id}/vectorize`) and follow its status at
|
||||||
|
`GET /api/llmwiki/knowledge-bases/{id}/wiki-index-status`. Existing chunk
|
||||||
|
citations remain readable while all new retrieval citations open Wiki pages.
|
||||||
|
|
||||||
|
Wiki management also provides **导出 Excel**. `GET
|
||||||
|
/api/llmwiki/knowledge-bases/{id}/wiki/export.xlsx` exports every processed Wiki
|
||||||
|
page, including its full directory path, current directory, parent-directory
|
||||||
|
path, one column per directory level, metadata, and complete Markdown content
|
||||||
|
(split across continuation columns when Excel's per-cell limit is reached).
|
||||||
|
|
||||||
|
Authenticated operations are available below `/api/llmwiki/wiki-index` and
|
||||||
|
`POST /api/llmwiki/wiki-vector-search`. The service-to-service endpoints below
|
||||||
|
`/api/external/llmwiki/wiki` require `X-API-Key`, are rate-limited, and only
|
||||||
|
expose mappings that are both internally published and explicitly marked
|
||||||
|
`external_search_enabled`; the system `对话沉淀` mapping is always denied.
|
||||||
|
See `docs/LLMWIKI_DEERFLOW_LOCAL_VECTOR_INDEX_ZH.md` for configuration,
|
||||||
|
security boundaries, rollout, and API contracts.
|
||||||
@@ -49,6 +49,8 @@ from app.gateway.routers import (
|
|||||||
light_apps,
|
light_apps,
|
||||||
llm_metrics,
|
llm_metrics,
|
||||||
llmwiki,
|
llmwiki,
|
||||||
|
llmwiki_index,
|
||||||
|
external_llmwiki,
|
||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
menu_overrides,
|
menu_overrides,
|
||||||
@@ -989,6 +991,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Run Alembic migrations before anything else touches the schema.
|
# Run Alembic migrations before anything else touches the schema.
|
||||||
await _run_db_migrations()
|
await _run_db_migrations()
|
||||||
|
|
||||||
|
# Local Wiki mirror/index scans are opt-in and run outside request
|
||||||
|
# handling. Each knowledge base still uses a database lease, so this is
|
||||||
|
# safe when multiple Gateway workers start the same scheduler.
|
||||||
|
app.state.llmwiki_index_scheduler_task = None
|
||||||
|
local_wiki_index = app.state.config.llmwiki.local_wiki_index
|
||||||
|
if (
|
||||||
|
local_wiki_index.enabled
|
||||||
|
and local_wiki_index.sync_enabled
|
||||||
|
and getattr(app.state, "llmwiki_sync_service", None) is not None
|
||||||
|
):
|
||||||
|
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
|
||||||
|
|
||||||
|
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
|
||||||
|
local_wiki_index_scheduler_loop(app)
|
||||||
|
)
|
||||||
|
|
||||||
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
|
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
|
||||||
try:
|
try:
|
||||||
from deerflow.config.memory_config import load_recall_budget_cache
|
from deerflow.config.memory_config import load_recall_budget_cache
|
||||||
@@ -1171,6 +1189,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
logger.exception("Scheduled task service failed to start")
|
logger.exception("Scheduled task service failed to start")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None)
|
||||||
|
if llmwiki_index_scheduler_task is not None:
|
||||||
|
llmwiki_index_scheduler_task.cancel()
|
||||||
|
try:
|
||||||
|
await llmwiki_index_scheduler_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Local Wiki index scheduler shutdown error")
|
||||||
|
llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set()))
|
||||||
|
for job in llmwiki_index_jobs:
|
||||||
|
job.cancel()
|
||||||
|
if llmwiki_index_jobs:
|
||||||
|
await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True)
|
||||||
|
|
||||||
# ?? curator ???
|
# ?? curator ???
|
||||||
curator_task = getattr(app.state, "curator_scheduler_task", None)
|
curator_task = getattr(app.state, "curator_scheduler_task", None)
|
||||||
@@ -1457,6 +1490,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
|
|
||||||
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
|
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
|
||||||
app.include_router(llmwiki.router)
|
app.include_router(llmwiki.router)
|
||||||
|
app.include_router(llmwiki_index.router)
|
||||||
|
app.include_router(external_llmwiki.router)
|
||||||
|
|
||||||
# Memory API is mounted at /api/memory
|
# Memory API is mounted at /api/memory
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
@@ -1654,9 +1689,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# --- ?????????? /page/workspace/roundtable/planning?---
|
# --- ?????????? /page/workspace/roundtable/planning?---
|
||||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||||||
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
||||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||||
|
|
||||||
# Health probes are always unauthenticated (see
|
# Health probes are always unauthenticated (see
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ owner filtering works automatically via the sentinel pattern.
|
|||||||
Fine-grained permission checks remain in authz.py decorators.
|
Fine-grained permission checks remain in authz.py decorators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
@@ -81,6 +83,30 @@ def _is_public(path: str) -> bool:
|
|||||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_external_llmwiki(path: str) -> bool:
|
||||||
|
return path.startswith("/api/external/llmwiki/")
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_external_llmwiki(request: Request) -> JSONResponse | None:
|
||||||
|
"""Authenticate the isolated service-to-service namespace before routing."""
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
config = get_app_config().llmwiki.local_wiki_index.external_api
|
||||||
|
supplied = request.headers.get("X-API-Key", "")
|
||||||
|
valid = bool(config.enabled and supplied) and any(
|
||||||
|
hmac.compare_digest(supplied, candidate)
|
||||||
|
for candidate in (config.api_key, config.previous_api_key)
|
||||||
|
if candidate
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
|
||||||
|
request.state.external_api_key_fingerprint = hashlib.sha256(
|
||||||
|
supplied.encode("utf-8")
|
||||||
|
).hexdigest()[:8]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||||
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
||||||
|
|
||||||
@@ -137,7 +163,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
# Match the application-relative path so a reverse-proxy prefix
|
# Match the application-relative path so a reverse-proxy prefix
|
||||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||||
if _is_public(app_relative_path(request)):
|
relative_path = app_relative_path(request)
|
||||||
|
if _is_external_llmwiki(relative_path):
|
||||||
|
rejected = _authorize_external_llmwiki(request)
|
||||||
|
return rejected if rejected is not None else await call_next(request)
|
||||||
|
if _is_public(relative_path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
if _is_weknora_embed_proxy_request(request):
|
if _is_weknora_embed_proxy_request(request):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|||||||
@@ -308,8 +308,61 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
||||||
from deerflow.persistence.llmwiki import make_llmwiki_store
|
from deerflow.persistence.llmwiki import make_llmwiki_store
|
||||||
|
|
||||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||||
register_llmwiki_store(app.state.llmwiki_store)
|
register_llmwiki_store(app.state.llmwiki_store)
|
||||||
|
|
||||||
|
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
|
||||||
|
# migrations and status inspection work while feature flags are off;
|
||||||
|
# network/search services only exist when explicitly enabled.
|
||||||
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
||||||
|
|
||||||
|
app.state.llmwiki_index_store = make_llmwiki_index_store(sf)
|
||||||
|
app.state.llmwiki_embedding = None
|
||||||
|
app.state.llmwiki_vector_cache = None
|
||||||
|
app.state.llmwiki_vector_search = None
|
||||||
|
app.state.llmwiki_sync_service = None
|
||||||
|
app.state.llmwiki_retrieval_service = None
|
||||||
|
local_index_config = config.llmwiki.local_wiki_index
|
||||||
|
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||||
|
|
||||||
|
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
||||||
|
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
|
||||||
|
if local_index_config.enabled:
|
||||||
|
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
||||||
|
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||||
|
|
||||||
|
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(local_index_config.embedding)
|
||||||
|
app.state.llmwiki_vector_cache = WikiVectorCache(
|
||||||
|
app.state.llmwiki_index_store,
|
||||||
|
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
|
||||||
|
revision_check_seconds=local_index_config.cache_revision_check_seconds,
|
||||||
|
)
|
||||||
|
app.state.llmwiki_vector_search = WikiVectorSearchService(local_index_config, app.state.llmwiki_embedding, app.state.llmwiki_vector_cache)
|
||||||
|
if weknora_client is not None:
|
||||||
|
app.state.llmwiki_sync_service = WikiSyncService(
|
||||||
|
local_index_config,
|
||||||
|
app.state.llmwiki_index_store,
|
||||||
|
app.state.llmwiki_embedding,
|
||||||
|
app.state.llmwiki_vector_cache,
|
||||||
|
weknora_client,
|
||||||
|
)
|
||||||
|
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||||
|
|
||||||
|
app.state.llmwiki_retrieval_service = WikiRetrievalService(
|
||||||
|
app.state.llmwiki_index_store,
|
||||||
|
vector_search=(app.state.llmwiki_vector_search if local_index_config.internal_search_enabled else None),
|
||||||
|
client=weknora_client,
|
||||||
|
)
|
||||||
|
register_local_wiki_index_runtime(
|
||||||
|
store=app.state.llmwiki_index_store,
|
||||||
|
search=app.state.llmwiki_vector_search,
|
||||||
|
sync=app.state.llmwiki_sync_service,
|
||||||
|
retrieval=app.state.llmwiki_retrieval_service,
|
||||||
|
)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
@@ -499,8 +552,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
from deerflow.persistence.notifications import set_notification_store
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
|
||||||
|
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
||||||
|
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
if llmwiki_embedding is not None:
|
||||||
|
await llmwiki_embedding.close()
|
||||||
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
set_notification_store(None)
|
set_notification_store(None)
|
||||||
dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
|
dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Best-effort periodic full scans for the DeerFlow-local Wiki index."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
|
||||||
|
"""Keep fire-and-forget index jobs strongly referenced until completion."""
|
||||||
|
|
||||||
|
jobs: set[asyncio.Task] = getattr(app.state, "llmwiki_index_jobs", None)
|
||||||
|
if jobs is None:
|
||||||
|
jobs = set()
|
||||||
|
app.state.llmwiki_index_jobs = jobs
|
||||||
|
task = asyncio.create_task(coroutine)
|
||||||
|
jobs.add(task)
|
||||||
|
task.add_done_callback(jobs.discard)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_once(app: FastAPI) -> None:
|
||||||
|
service = getattr(app.state, "llmwiki_sync_service", None)
|
||||||
|
mapping_store = getattr(app.state, "llmwiki_store", None)
|
||||||
|
if service is None or mapping_store is None:
|
||||||
|
return
|
||||||
|
config = app.state.config.llmwiki.local_wiki_index
|
||||||
|
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
|
||||||
|
semaphore = asyncio.Semaphore(config.sync_concurrency)
|
||||||
|
|
||||||
|
async def run(mapping):
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
await service.sync_mapping(mapping)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Periodic local Wiki sync failed mapping_id=%s", mapping.get("id"), exc_info=True)
|
||||||
|
|
||||||
|
await asyncio.gather(*(run(mapping) for mapping in mappings))
|
||||||
|
|
||||||
|
|
||||||
|
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
|
||||||
|
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
|
||||||
|
while True:
|
||||||
|
await _sync_once(app)
|
||||||
|
await asyncio.sleep(interval)
|
||||||
@@ -26,8 +26,8 @@ from langchain_core.messages import HumanMessage
|
|||||||
from app.gateway.deps import get_optional_user_from_request
|
from app.gateway.deps import get_optional_user_from_request
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||||
from deerflow.integrations.weknora.client import WeKnoraError
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
from deerflow.integrations.weknora.runtime import get_resolved_llmwiki_runtime
|
||||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
@@ -146,6 +146,24 @@ def _truncate(text: str, max_chars: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index: int) -> dict[str, Any]:
|
def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index: int) -> dict[str, Any]:
|
||||||
|
if result.get("kind") == "wiki_page":
|
||||||
|
sections = list(result.get("matched_sections") or [])
|
||||||
|
content = "\n\n".join(str(section.get("content") or "") for section in sections if section.get("content"))
|
||||||
|
raw = {**result, "source": mapping.get("name") or "LLMWiki"}
|
||||||
|
return {
|
||||||
|
"index": index,
|
||||||
|
"id": result.get("wiki_page_id") or result.get("id"),
|
||||||
|
"title": result.get("title") or mapping.get("name") or f"Wiki 页面 {index}",
|
||||||
|
"type": "llmwiki_wiki",
|
||||||
|
"snippet": _truncate(content or str(result.get("summary") or ""), 360),
|
||||||
|
"content": content,
|
||||||
|
"source": mapping.get("name") or "LLMWiki",
|
||||||
|
"score": float(result.get("score") or 0),
|
||||||
|
"url": result.get("url") or "",
|
||||||
|
"skillName": "llmwiki_search",
|
||||||
|
"mode": "citation",
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
chunk_id = str(result.get("chunk_id") or "")
|
chunk_id = str(result.get("chunk_id") or "")
|
||||||
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
|
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
|
||||||
content = str(result.get("content") or "")
|
content = str(result.get("content") or "")
|
||||||
@@ -175,10 +193,7 @@ def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index:
|
|||||||
|
|
||||||
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||||
if not sources:
|
if not sources:
|
||||||
return (
|
return "当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
||||||
"当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。"
|
|
||||||
"请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
||||||
@@ -187,7 +202,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
|||||||
"",
|
"",
|
||||||
f"用户问题:{query}",
|
f"用户问题:{query}",
|
||||||
"",
|
"",
|
||||||
f"<llmwiki_reference_batch source_count=\"{len(sources)}\">",
|
f'<llmwiki_wiki_reference_batch source_count="{len(sources)}">',
|
||||||
]
|
]
|
||||||
for source in sources:
|
for source in sources:
|
||||||
meta = " / ".join(
|
meta = " / ".join(
|
||||||
@@ -203,7 +218,7 @@ def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
|||||||
lines.append(f"来源:{meta}")
|
lines.append(f"来源:{meta}")
|
||||||
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("</llmwiki_reference_batch>")
|
lines.append("</llmwiki_wiki_reference_batch>")
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +233,8 @@ async def build_llmwiki_rag_context(
|
|||||||
return LlmWikiRagContext([], 0, "", [], "")
|
return LlmWikiRagContext([], 0, "", [], "")
|
||||||
|
|
||||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||||||
if not runtime.weknora_enabled:
|
local_config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
if not (local_config.enabled and local_config.internal_search_enabled) and not runtime.weknora_enabled:
|
||||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||||
store = _store(request)
|
store = _store(request)
|
||||||
if store is None:
|
if store is None:
|
||||||
@@ -235,29 +251,25 @@ async def build_llmwiki_rag_context(
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
remote_to_local = {str(row["weknora_id"]): row for row in rows}
|
search_service = getattr(request.app.state, "llmwiki_retrieval_service", None)
|
||||||
|
if search_service is None:
|
||||||
try:
|
|
||||||
results = await build_weknora_client(runtime).search(query, [str(row["weknora_id"]) for row in rows])
|
|
||||||
except WeKnoraError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"LLMWiki pre-retrieval failed for user=%s kb_count=%s: %s",
|
|
||||||
sanitize_log_param(user_id),
|
|
||||||
len(rows),
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return LlmWikiRagContext(
|
return LlmWikiRagContext(
|
||||||
selected_ids,
|
selected_ids,
|
||||||
0,
|
0,
|
||||||
f"当前用户选择了 LLMWiki 知识库,但自动检索失败:{exc}。请说明暂时无法检索知识库背景资料,不要编造引用。",
|
"Wiki 检索暂不可用。请明确说明系统检索不可用,不要表述为资料不存在。",
|
||||||
[],
|
[],
|
||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
payload = await search_service.search(query, rows, top_k_pages=_MAX_SOURCES)
|
||||||
|
except WikiIndexError as exc:
|
||||||
|
logger.warning("Wiki-only pre-retrieval failed user=%s kb_count=%s code=%s", sanitize_log_param(user_id), len(rows), exc.code)
|
||||||
|
return LlmWikiRagContext(selected_ids, 0, f"Wiki 检索暂不可用({exc.code})。请明确说明系统检索状态,不要声称知识库没有资料。", [], query)
|
||||||
|
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||||
sources: list[dict[str, Any]] = []
|
sources: list[dict[str, Any]] = []
|
||||||
for result in results:
|
for result in payload.get("results") or []:
|
||||||
mapping = remote_to_local.get(str(result.get("knowledge_base_id") or ""))
|
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
|
||||||
if mapping is None or not str(result.get("content") or "").strip():
|
if mapping is None:
|
||||||
continue
|
continue
|
||||||
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
||||||
if len(sources) >= _MAX_SOURCES:
|
if len(sources) >= _MAX_SOURCES:
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""API-key protected public Wiki vector search backed only by local data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/external/llmwiki/wiki", tags=["external-llmwiki"])
|
||||||
|
_requests: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalWikiSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., min_length=1, max_length=10000)
|
||||||
|
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
limit: int | None = Field(default=None, ge=1, le=100)
|
||||||
|
include_content: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _config(request: Request):
|
||||||
|
config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
if not config.enabled or not config.external_api.enabled:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return config, config.external_api
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit(request: Request, rpm: int) -> None:
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
key = f"{getattr(request.state, 'external_api_key_fingerprint', 'unknown')}:{ip}"
|
||||||
|
now = time.monotonic()
|
||||||
|
queue = _requests[key]
|
||||||
|
while queue and queue[0] <= now - 60:
|
||||||
|
queue.popleft()
|
||||||
|
if len(queue) >= rpm:
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||||
|
queue.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_conversation_deposit(mapping: dict[str, Any]) -> bool:
|
||||||
|
return str(mapping.get("owner_user_id") or "") == "system" and str(mapping.get("name") or "").strip() == "对话沉淀"
|
||||||
|
|
||||||
|
|
||||||
|
async def _external_mappings(request: Request, requested: list[str]) -> list[dict[str, Any]]:
|
||||||
|
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
|
||||||
|
allowed = {row["id"]: row for row in mappings if row.get("publication_status") == "published" and row.get("external_search_enabled", False) and row.get("wiki_index_enabled", True) and not _is_conversation_deposit(row)}
|
||||||
|
if requested:
|
||||||
|
ids = list(dict.fromkeys(requested))
|
||||||
|
if any(mapping_id not in allowed for mapping_id in ids):
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
return [allowed[mapping_id] for mapping_id in ids]
|
||||||
|
return list(allowed.values())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vector-search")
|
||||||
|
async def external_vector_search(request: Request, body: ExternalWikiSearchRequest) -> dict[str, Any]:
|
||||||
|
_, external = _config(request)
|
||||||
|
_rate_limit(request, external.requests_per_minute)
|
||||||
|
if len(body.query) > external.max_query_chars:
|
||||||
|
raise HTTPException(status_code=413, detail="Query is too large")
|
||||||
|
if len(body.knowledge_base_ids) > external.max_knowledge_bases:
|
||||||
|
raise HTTPException(status_code=413, detail="Too many knowledge bases")
|
||||||
|
limit = body.limit or external.default_results
|
||||||
|
if limit > external.max_results:
|
||||||
|
raise HTTPException(status_code=400, detail="Result limit is too large")
|
||||||
|
mappings = await _external_mappings(request, body.knowledge_base_ids)
|
||||||
|
search = getattr(request.app.state, "llmwiki_vector_search", None)
|
||||||
|
if search is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
|
||||||
|
try:
|
||||||
|
result = await search.search(body.query, mappings, top_k_pages=limit, external=True)
|
||||||
|
except WikiIndexError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
|
||||||
|
# Re-read publication/external flags after vector computation so a switch
|
||||||
|
# turned off during an in-flight request cannot leak a cached result.
|
||||||
|
still_allowed = {row["id"] for row in await _external_mappings(request, [])}
|
||||||
|
result["results"] = [item for item in result["results"] if item.get("knowledge_base_id") in still_allowed]
|
||||||
|
remaining = external.include_content_max_chars if body.include_content else 0
|
||||||
|
items = []
|
||||||
|
for result_item in result["results"]:
|
||||||
|
section = (result_item.get("matched_sections") or [{}])[0]
|
||||||
|
snippet = str(section.get("content") or "")[:1000]
|
||||||
|
content = ""
|
||||||
|
if remaining > 0:
|
||||||
|
content = "\n\n".join(str(item.get("content") or "") for item in result_item.get("matched_sections") or [])[:remaining]
|
||||||
|
remaining -= len(content)
|
||||||
|
item = {
|
||||||
|
"knowledge_base_id": result_item["knowledge_base_id"],
|
||||||
|
"knowledge_base_name": result_item.get("knowledge_base_name") or "",
|
||||||
|
"wiki_slug": result_item["wiki_slug"],
|
||||||
|
"title": result_item.get("title") or "",
|
||||||
|
"summary": result_item.get("summary") or "",
|
||||||
|
"matched_heading": section.get("heading"),
|
||||||
|
"snippet": snippet,
|
||||||
|
"page_type": result_item.get("page_type") or "article",
|
||||||
|
"page_version": result_item.get("page_version") or 0,
|
||||||
|
"updated_at": result_item.get("updated_at"),
|
||||||
|
"score": result_item.get("score") or 0,
|
||||||
|
}
|
||||||
|
if body.include_content:
|
||||||
|
item["content"] = content
|
||||||
|
items.append(item)
|
||||||
|
return {"query": body.query, "mode": "vector", "items": items, "count": len(items), "partial": result.get("partial", False)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages/{mapping_id}/{slug:path}")
|
||||||
|
async def external_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
|
||||||
|
_, external = _config(request)
|
||||||
|
_rate_limit(request, external.requests_per_minute)
|
||||||
|
mappings = await _external_mappings(request, [mapping_id])
|
||||||
|
if not mappings:
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
page = await request.app.state.llmwiki_index_store.get_page(mapping_id, slug.strip("/"))
|
||||||
|
if page is None or page.get("status") != "published" or page.get("index_status") != "ready" or page.get("is_remote_deleted"):
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
# Recheck after reading the page so publication/external revocation takes
|
||||||
|
# effect even when it races this detail request.
|
||||||
|
await _external_mappings(request, [mapping_id])
|
||||||
|
return {
|
||||||
|
"knowledge_base_id": mapping_id,
|
||||||
|
"knowledge_base_name": mappings[0].get("name") or "",
|
||||||
|
"wiki_slug": page["slug"],
|
||||||
|
"title": page.get("title") or "",
|
||||||
|
"summary": page.get("summary") or "",
|
||||||
|
"content": str(page.get("content_md") or "")[: external.include_content_max_chars],
|
||||||
|
"page_type": page.get("page_type") or "article",
|
||||||
|
"page_version": page.get("page_version") or 0,
|
||||||
|
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
|
||||||
|
}
|
||||||
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from io import BytesIO
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from urllib.parse import quote, urlencode, urlsplit
|
from urllib.parse import quote, urlencode, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.csrf_middleware import is_secure_request
|
from app.gateway.csrf_middleware import is_secure_request
|
||||||
@@ -27,17 +30,28 @@ from app.gateway.llmwiki_deposit import (
|
|||||||
ensure_conversation_deposit_mapping,
|
ensure_conversation_deposit_mapping,
|
||||||
)
|
)
|
||||||
from app.gateway.weknora_embed import (
|
from app.gateway.weknora_embed import (
|
||||||
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
|
||||||
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
|
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
|
||||||
|
)
|
||||||
|
from app.gateway.weknora_embed import (
|
||||||
|
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
||||||
|
)
|
||||||
|
from app.gateway.weknora_embed import (
|
||||||
WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS,
|
WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
from app.gateway.weknora_embed import (
|
||||||
WeKnoraEmbedContext,
|
WeKnoraEmbedContext,
|
||||||
|
)
|
||||||
|
from app.gateway.weknora_embed import (
|
||||||
sign_weknora_embed_payload as _sign_embed_payload,
|
sign_weknora_embed_payload as _sign_embed_payload,
|
||||||
|
)
|
||||||
|
from app.gateway.weknora_embed import (
|
||||||
verify_weknora_embed_cookie as _verify_embed_cookie,
|
verify_weknora_embed_cookie as _verify_embed_cookie,
|
||||||
)
|
)
|
||||||
from deerflow.config.agents_config import load_agent_config
|
from deerflow.config.agents_config import load_agent_config
|
||||||
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
|
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
|
||||||
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
||||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||||
|
|
||||||
@@ -168,6 +182,8 @@ class SearchRequest(BaseModel):
|
|||||||
query: str = Field(..., min_length=1, max_length=4000)
|
query: str = Field(..., min_length=1, max_length=4000)
|
||||||
scope: Literal["personal", "public", "all"] = "all"
|
scope: Literal["personal", "public", "all"] = "all"
|
||||||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
top_k: int = Field(default=8, ge=1, le=50)
|
||||||
|
include_drafts: bool = False
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeUrlImport(BaseModel):
|
class KnowledgeUrlImport(BaseModel):
|
||||||
@@ -223,6 +239,15 @@ def _store(request: Request) -> LlmWikiStore:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None:
|
||||||
|
service = getattr(request.app.state, "llmwiki_sync_service", None)
|
||||||
|
config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
if service is not None and config.enabled and mapping.get("wiki_index_enabled", True):
|
||||||
|
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||||
|
|
||||||
|
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=True))
|
||||||
|
|
||||||
|
|
||||||
async def _actor(request: Request) -> tuple[str, bool]:
|
async def _actor(request: Request) -> tuple[str, bool]:
|
||||||
user = await get_optional_user_from_request(request)
|
user = await get_optional_user_from_request(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -575,12 +600,12 @@ async def _authorize_weknora_api_proxy(
|
|||||||
# page to render upload controls and current-tenant state. They do not
|
# page to render upload controls and current-tenant state. They do not
|
||||||
# expose other knowledge-base contents.
|
# expose other knowledge-base contents.
|
||||||
if read_method and tail and tail[0] in {
|
if read_method and tail and tail[0] in {
|
||||||
"models",
|
"models",
|
||||||
"system",
|
"system",
|
||||||
"storage-backends",
|
"storage-backends",
|
||||||
"vector-stores",
|
"vector-stores",
|
||||||
"web-search-providers",
|
"web-search-providers",
|
||||||
"mcp-services",
|
"mcp-services",
|
||||||
}:
|
}:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -654,7 +679,7 @@ def _with_public_prefix(prefix: str, path: str) -> str:
|
|||||||
|
|
||||||
def _without_public_prefix(prefix: str, path: str) -> str:
|
def _without_public_prefix(prefix: str, path: str) -> str:
|
||||||
if prefix and path.startswith(f"{prefix}/"):
|
if prefix and path.startswith(f"{prefix}/"):
|
||||||
return path[len(prefix):]
|
return path[len(prefix) :]
|
||||||
if prefix and path == prefix:
|
if prefix and path == prefix:
|
||||||
return "/"
|
return "/"
|
||||||
return path
|
return path
|
||||||
@@ -1626,6 +1651,9 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u
|
|||||||
"description": description,
|
"description": description,
|
||||||
"type": source.get("type") or row.get("kb_type") or "document",
|
"type": source.get("type") or row.get("kb_type") or "document",
|
||||||
"publication_status": "published" if is_conversation_deposit else row.get("publication_status") or "private",
|
"publication_status": "published" if is_conversation_deposit else row.get("publication_status") or "private",
|
||||||
|
"wiki_index_enabled": row.get("wiki_index_enabled", True),
|
||||||
|
"external_search_enabled": False if is_conversation_deposit else row.get("external_search_enabled", False),
|
||||||
|
"external_search_updated_at": row.get("external_search_updated_at"),
|
||||||
"owner_user_id": row.get("owner_user_id") if is_admin else None,
|
"owner_user_id": row.get("owner_user_id") if is_admin else None,
|
||||||
"is_owner": is_owner,
|
"is_owner": is_owner,
|
||||||
"can_write": not is_conversation_deposit and (is_admin or is_owner),
|
"can_write": not is_conversation_deposit and (is_admin or is_owner),
|
||||||
@@ -1816,6 +1844,135 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _all_wiki_pages(client: WeKnoraClient, remote_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Read every Wiki page and its processed Markdown, never raw documents."""
|
||||||
|
|
||||||
|
summaries: dict[str, dict[str, Any]] = {}
|
||||||
|
page_number = 1
|
||||||
|
while True:
|
||||||
|
listing = await client.list_wiki_pages(remote_id, page=page_number, page_size=500)
|
||||||
|
rows = [item for item in listing.get("pages", []) if isinstance(item, dict)]
|
||||||
|
for item in rows:
|
||||||
|
slug = str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
|
||||||
|
if slug:
|
||||||
|
summaries[slug] = item
|
||||||
|
total_pages = max(1, int(listing.get("total_pages") or 1))
|
||||||
|
if page_number >= total_pages or not rows:
|
||||||
|
break
|
||||||
|
page_number += 1
|
||||||
|
|
||||||
|
pages: list[dict[str, Any]] = []
|
||||||
|
entries = list(summaries.items())
|
||||||
|
for start in range(0, len(entries), 20):
|
||||||
|
batch = entries[start : start + 20]
|
||||||
|
details = await asyncio.gather(*(client.get_wiki_page(remote_id, slug) for slug, _ in batch))
|
||||||
|
pages.extend({**summary, **detail, "slug": slug} for (slug, summary), detail in zip(batch, details, strict=True))
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def _wiki_directory_parts(page: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> list[str]:
|
||||||
|
parent_slug = str(page.get("parent_slug") or "").strip("/")
|
||||||
|
if parent_slug:
|
||||||
|
ancestors: list[str] = []
|
||||||
|
visited: set[str] = set()
|
||||||
|
current = by_slug.get(parent_slug)
|
||||||
|
while current is not None:
|
||||||
|
slug = str(current.get("slug") or "").strip("/")
|
||||||
|
if not slug or slug in visited:
|
||||||
|
break
|
||||||
|
visited.add(slug)
|
||||||
|
ancestors.insert(0, str(current.get("title") or slug.rsplit("/", 1)[-1]))
|
||||||
|
current_parent = str(current.get("parent_slug") or "").strip("/")
|
||||||
|
current = by_slug.get(current_parent) if current_parent else None
|
||||||
|
if ancestors:
|
||||||
|
return ancestors
|
||||||
|
|
||||||
|
category_path = [str(item).strip() for item in page.get("category_path") or [] if str(item).strip()]
|
||||||
|
if category_path:
|
||||||
|
return category_path
|
||||||
|
wiki_path = [item.strip() for item in str(page.get("wiki_path") or "").split("/") if item.strip()]
|
||||||
|
if len(wiki_path) > 2:
|
||||||
|
return wiki_path[1:-1]
|
||||||
|
slug_parts = [item for item in str(page.get("slug") or "").split("/") if item]
|
||||||
|
return slug_parts[:-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_excel_text(value: Any) -> str:
|
||||||
|
text = str(value or "")
|
||||||
|
return f"'{text}" if text.startswith(("=", "+", "-", "@")) else text
|
||||||
|
|
||||||
|
|
||||||
|
def _build_wiki_excel(knowledge_base_name: str, raw_pages: list[dict[str, Any]]) -> BytesIO:
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
|
||||||
|
pages = [view for raw in raw_pages if (view := _wiki_page_view(raw)) is not None]
|
||||||
|
by_slug = {page["slug"]: page for page in pages if page["slug"]}
|
||||||
|
directories = [_wiki_directory_parts(page, by_slug) for page in pages]
|
||||||
|
max_depth = max((len(parts) for parts in directories), default=0)
|
||||||
|
max_content_parts = max((max(1, (len(str(page.get("content") or "")) + 31_999) // 32_000) for page in pages), default=1)
|
||||||
|
columns = [
|
||||||
|
"知识库",
|
||||||
|
"标题",
|
||||||
|
"Slug",
|
||||||
|
"目录完整路径",
|
||||||
|
"所在目录",
|
||||||
|
"父级目录完整路径",
|
||||||
|
*(f"第{level}级目录" for level in range(1, max_depth + 1)),
|
||||||
|
"摘要",
|
||||||
|
*("正文" if index == 1 else f"正文(续{index - 1})" for index in range(1, max_content_parts + 1)),
|
||||||
|
"页面类型",
|
||||||
|
"状态",
|
||||||
|
"别名",
|
||||||
|
"版本",
|
||||||
|
"创建时间",
|
||||||
|
"更新时间",
|
||||||
|
]
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "Wiki 全量导出"
|
||||||
|
sheet.append(columns)
|
||||||
|
for cell in sheet[1]:
|
||||||
|
cell.font = Font(bold=True, color="FFFFFF")
|
||||||
|
cell.fill = PatternFill("solid", fgColor="1F4E78")
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
for page, directory in zip(pages, directories, strict=True):
|
||||||
|
content = str(page.get("content") or "")
|
||||||
|
content_parts = [content[index : index + 32_000] for index in range(0, len(content), 32_000)] or [""]
|
||||||
|
content_parts.extend([""] * (max_content_parts - len(content_parts)))
|
||||||
|
row = [
|
||||||
|
knowledge_base_name,
|
||||||
|
page.get("title"),
|
||||||
|
page.get("slug"),
|
||||||
|
" / ".join(directory),
|
||||||
|
directory[-1] if directory else "根目录",
|
||||||
|
" / ".join(directory[:-1]),
|
||||||
|
*directory,
|
||||||
|
*([""] * (max_depth - len(directory))),
|
||||||
|
page.get("summary"),
|
||||||
|
*content_parts,
|
||||||
|
page.get("page_type"),
|
||||||
|
page.get("status"),
|
||||||
|
"、".join(str(item) for item in page.get("aliases") or []),
|
||||||
|
page.get("version"),
|
||||||
|
page.get("created_at"),
|
||||||
|
page.get("updated_at"),
|
||||||
|
]
|
||||||
|
sheet.append([_safe_excel_text(value) for value in row])
|
||||||
|
sheet.freeze_panes = "A2"
|
||||||
|
sheet.auto_filter.ref = sheet.dimensions
|
||||||
|
for column in sheet.columns:
|
||||||
|
letter = column[0].column_letter
|
||||||
|
heading = str(column[0].value or "")
|
||||||
|
sheet.column_dimensions[letter].width = 60 if heading.startswith("正文") else 32 if heading in {"摘要", "目录完整路径", "父级目录完整路径"} else 20
|
||||||
|
for cell in column[1:]:
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
output = BytesIO()
|
||||||
|
workbook.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
||||||
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
||||||
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
||||||
@@ -2421,12 +2578,37 @@ async def list_wiki_pages(
|
|||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/export.xlsx")
|
||||||
|
async def export_wiki_pages_excel(request: Request, mapping_id: str) -> StreamingResponse:
|
||||||
|
"""Export every processed Wiki page with its current and parent directories."""
|
||||||
|
|
||||||
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||||||
|
client = _client_or_503()
|
||||||
|
try:
|
||||||
|
pages = await _all_wiki_pages(client, str(row["weknora_id"]))
|
||||||
|
except WeKnoraError as exc:
|
||||||
|
_raise_upstream(exc)
|
||||||
|
try:
|
||||||
|
output = await asyncio.to_thread(_build_wiki_excel, str(row.get("name") or "LLMWiki"), pages)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
safe_name = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", str(row.get("name") or "LLMWiki")).strip(" ._") or "LLMWiki"
|
||||||
|
filename = f"{safe_name}-Wiki-{timestamp}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=\"wiki-export.xlsx\"; filename*=UTF-8''{quote(filename)}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||||||
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
||||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||||
payload = body.model_dump(exclude_none=True)
|
payload = body.model_dump(exclude_none=True)
|
||||||
try:
|
try:
|
||||||
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
||||||
|
_schedule_local_wiki_sync(request, row)
|
||||||
return _wiki_page_view(page) or page
|
return _wiki_page_view(page) or page
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
@@ -2454,6 +2636,7 @@ async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: W
|
|||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
try:
|
try:
|
||||||
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
||||||
|
_schedule_local_wiki_sync(request, row)
|
||||||
return _wiki_page_view(page) or page
|
return _wiki_page_view(page) or page
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
@@ -2464,6 +2647,7 @@ async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None
|
|||||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||||
try:
|
try:
|
||||||
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
||||||
|
_schedule_local_wiki_sync(request, row)
|
||||||
except WeKnoraError as exc:
|
except WeKnoraError as exc:
|
||||||
_raise_upstream(exc)
|
_raise_upstream(exc)
|
||||||
|
|
||||||
@@ -2547,32 +2731,22 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
|
|||||||
rows.append(row)
|
rows.append(row)
|
||||||
else:
|
else:
|
||||||
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
|
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
|
||||||
|
service = getattr(request.app.state, "llmwiki_retrieval_service", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_RETRIEVAL_UNAVAILABLE"})
|
||||||
|
include_drafts = body.include_drafts and (is_admin or all(str(row.get("owner_user_id")) == user_id for row in rows))
|
||||||
try:
|
try:
|
||||||
results = await _client_or_503().search(body.query, [str(row["weknora_id"]) for row in rows])
|
result = await service.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
|
||||||
except WeKnoraError as exc:
|
except WikiIndexError as exc:
|
||||||
_raise_upstream(exc)
|
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
|
||||||
local_by_remote = {str(row["weknora_id"]): row for row in rows}
|
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||||
visible: list[dict[str, Any]] = []
|
result["results"] = [
|
||||||
for result in results:
|
_sanitize_conversation_deposit_value(item)
|
||||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {}))
|
||||||
if mapping is None:
|
else item
|
||||||
continue
|
for item in result.get("results") or []
|
||||||
visible_result = (
|
]
|
||||||
_sanitize_conversation_deposit_value(result)
|
return result
|
||||||
if _is_conversation_deposit_mapping(mapping)
|
|
||||||
else result
|
|
||||||
)
|
|
||||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": visible_result.get("chunk_id") or ""})
|
|
||||||
visible.append(
|
|
||||||
{
|
|
||||||
**visible_result,
|
|
||||||
"knowledge_base_id": mapping["id"],
|
|
||||||
"knowledge_base_name": mapping.get("name") or "",
|
|
||||||
"source": mapping.get("name") or "LLMWiki",
|
|
||||||
"url": f"/weknora-source-preview?{params}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"results": visible, "knowledge_base_count": len(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/llmwiki/conversation-deposits/turn", status_code=202)
|
@router.post("/api/llmwiki/conversation-deposits/turn", status_code=202)
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Authenticated APIs for DeerFlow-local Wiki vector search and administration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_optional_user_from_request
|
||||||
|
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/llmwiki", tags=["llmwiki-index"])
|
||||||
|
|
||||||
|
|
||||||
|
class WikiVectorSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
top_k: int = Field(default=8, ge=1, le=50)
|
||||||
|
include_drafts: bool = False
|
||||||
|
include_content: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class WikiIndexActionRequest(BaseModel):
|
||||||
|
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
force: bool = False
|
||||||
|
reason: str = Field(default="manual", max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiIndexSettingsRequest(BaseModel):
|
||||||
|
wiki_index_enabled: bool | None = None
|
||||||
|
external_search_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _actor(request: Request) -> tuple[str, bool]:
|
||||||
|
user = await get_optional_user_from_request(request)
|
||||||
|
if user is None:
|
||||||
|
return "default", True
|
||||||
|
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def _services(request: Request):
|
||||||
|
config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
if not config.enabled:
|
||||||
|
raise HTTPException(status_code=409, detail={"code": "WIKI_INDEX_DISABLED", "message": "Local Wiki index is disabled"})
|
||||||
|
search = getattr(request.app.state, "llmwiki_vector_search", None)
|
||||||
|
index_store = getattr(request.app.state, "llmwiki_index_store", None)
|
||||||
|
if search is None or index_store is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE", "message": "Local Wiki index is unavailable"})
|
||||||
|
return config, search, index_store
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorized_mappings(request: Request, mapping_ids: list[str]) -> tuple[list[dict[str, Any]], str, bool]:
|
||||||
|
user_id, is_admin = await _actor(request)
|
||||||
|
mapping_store = request.app.state.llmwiki_store
|
||||||
|
if mapping_ids:
|
||||||
|
rows = []
|
||||||
|
for mapping_id in dict.fromkeys(mapping_ids):
|
||||||
|
row = await mapping_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||||
|
if row is None or not row.get("wiki_index_enabled", True):
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
rows.append(row)
|
||||||
|
else:
|
||||||
|
rows = [row for row in await mapping_store.list_visible(user_id, scope="all", is_admin=is_admin) if row.get("wiki_index_enabled", True)]
|
||||||
|
return rows, user_id, is_admin
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wiki-vector-search")
|
||||||
|
async def wiki_vector_search(request: Request, body: WikiVectorSearchRequest) -> dict[str, Any]:
|
||||||
|
config, search, _ = _services(request)
|
||||||
|
if not config.internal_search_enabled:
|
||||||
|
raise HTTPException(status_code=409, detail={"code": "WIKI_INTERNAL_SEARCH_DISABLED", "message": "Local Wiki search is not enabled"})
|
||||||
|
rows, user_id, is_admin = await _authorized_mappings(request, body.knowledge_base_ids)
|
||||||
|
include_drafts = body.include_drafts and (is_admin or all(str(row.get("owner_user_id")) == user_id for row in rows))
|
||||||
|
try:
|
||||||
|
result = await search.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
|
||||||
|
except WikiIndexError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
|
||||||
|
if not body.include_content:
|
||||||
|
for item in result["results"]:
|
||||||
|
item.pop("content", None)
|
||||||
|
item.pop("snippet", None)
|
||||||
|
for section in item["matched_sections"]:
|
||||||
|
section.pop("content", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-bases/{mapping_id}/local-wiki/pages/{slug:path}")
|
||||||
|
async def local_wiki_page(
|
||||||
|
request: Request,
|
||||||
|
mapping_id: str,
|
||||||
|
slug: str,
|
||||||
|
include_provenance: bool = Query(default=False),
|
||||||
|
include_archived: bool = Query(default=False),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_, _, index_store = _services(request)
|
||||||
|
user_id, is_admin = await _actor(request)
|
||||||
|
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||||
|
if mapping is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
page = await index_store.get_page(mapping_id, slug.strip("/"))
|
||||||
|
if page is None or page.get("is_remote_deleted"):
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
owner_or_admin = is_admin or str(mapping.get("owner_user_id")) == user_id
|
||||||
|
if page.get("status") == "archived" and not (include_archived and owner_or_admin):
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
if page.get("status") != "published" and not owner_or_admin:
|
||||||
|
raise HTTPException(status_code=404, detail="Wiki page not found")
|
||||||
|
result = {
|
||||||
|
"id": page["id"],
|
||||||
|
"knowledge_base_id": mapping_id,
|
||||||
|
"title": page.get("title") or "",
|
||||||
|
"slug": page["slug"],
|
||||||
|
"summary": page.get("summary") or "",
|
||||||
|
"content": page.get("content_md") or "",
|
||||||
|
"page_type": page.get("page_type") or "article",
|
||||||
|
"status": page.get("status"),
|
||||||
|
"aliases": page.get("aliases") or [],
|
||||||
|
"category_path": page.get("category_path") or [],
|
||||||
|
"parent_slug": page.get("parent_slug"),
|
||||||
|
"wiki_path": page.get("wiki_path"),
|
||||||
|
"page_version": page.get("page_version") or 0,
|
||||||
|
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
|
||||||
|
"created_at": page.get("remote_created_at") or page.get("created_at"),
|
||||||
|
}
|
||||||
|
if owner_or_admin:
|
||||||
|
result.update({"index_status": page.get("index_status"), "indexed_at": page.get("indexed_at")})
|
||||||
|
if include_provenance:
|
||||||
|
result.update({"source_refs": page.get("source_refs") or [], "chunk_refs": page.get("chunk_refs") or []})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki-index/status")
|
||||||
|
async def wiki_index_status(request: Request) -> dict[str, Any]:
|
||||||
|
_, is_admin = await _actor(request)
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||||
|
config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
index_store = getattr(request.app.state, "llmwiki_index_store", None)
|
||||||
|
if index_store is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
|
||||||
|
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
|
||||||
|
states = {row["knowledge_base_mapping_id"]: row for row in await index_store.list_index_status()}
|
||||||
|
items = []
|
||||||
|
for mapping in mappings:
|
||||||
|
state = states.get(mapping["id"], {"state": "not_synced", "index_revision": 0})
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
**state,
|
||||||
|
"knowledge_base_id": mapping["id"],
|
||||||
|
"knowledge_base_name": mapping.get("name") or "",
|
||||||
|
"wiki_index_enabled": mapping.get("wiki_index_enabled", True),
|
||||||
|
"external_search_enabled": mapping.get("external_search_enabled", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"enabled": config.enabled,
|
||||||
|
"sync_enabled": config.sync_enabled,
|
||||||
|
"internal_search_enabled": config.internal_search_enabled,
|
||||||
|
"embedding_model": config.embedding.model,
|
||||||
|
"embedding_fingerprint": (
|
||||||
|
request.app.state.llmwiki_embedding.fingerprint[:12]
|
||||||
|
if getattr(request.app.state, "llmwiki_embedding", None) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"knowledge_bases": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_sync(app, mapping_ids: list[str], force: bool) -> None:
|
||||||
|
service = getattr(app.state, "llmwiki_sync_service", None)
|
||||||
|
if service is None:
|
||||||
|
return
|
||||||
|
mappings = await app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
|
||||||
|
wanted = set(mapping_ids)
|
||||||
|
for mapping in mappings:
|
||||||
|
if mapping.get("wiki_index_enabled", True) and (not wanted or mapping["id"] in wanted):
|
||||||
|
try:
|
||||||
|
await service.sync_mapping(mapping, force=force)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_one_sync(app, mapping: dict[str, Any], *, force: bool) -> None:
|
||||||
|
service = getattr(app.state, "llmwiki_sync_service", None)
|
||||||
|
if service is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await service.sync_mapping(mapping, force=force)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wiki-index/sync", status_code=202)
|
||||||
|
async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
|
||||||
|
_services(request)
|
||||||
|
_, is_admin = await _actor(request)
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||||
|
job_id = str(uuid4())
|
||||||
|
spawn_local_wiki_index_job(request.app, _run_sync(request.app, body.knowledge_base_ids, body.force))
|
||||||
|
return {"job_id": job_id, "status": "accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wiki-index/rebuild", status_code=202)
|
||||||
|
async def rebuild_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
|
||||||
|
_, _, index_store = _services(request)
|
||||||
|
_, is_admin = await _actor(request)
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||||
|
mappings = await request.app.state.llmwiki_store.list_visible("system", scope="all", is_admin=True)
|
||||||
|
ids = body.knowledge_base_ids or [row["id"] for row in mappings if row.get("wiki_index_enabled", True)]
|
||||||
|
fingerprint = request.app.state.llmwiki_embedding.fingerprint
|
||||||
|
await index_store.mark_rebuild_required(ids, fingerprint)
|
||||||
|
job_id = str(uuid4())
|
||||||
|
spawn_local_wiki_index_job(request.app, _run_sync(request.app, ids, True))
|
||||||
|
return {"job_id": job_id, "status": "accepted", "reason": body.reason}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wiki-index/knowledge-bases/{mapping_id}/sync", status_code=202)
|
||||||
|
async def sync_one_wiki_index(request: Request, mapping_id: str, force: bool = Query(default=False)) -> dict[str, Any]:
|
||||||
|
return await sync_wiki_index(request, WikiIndexActionRequest(knowledge_base_ids=[mapping_id], force=force))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wiki-index/knowledge-bases/{mapping_id}/rebuild", status_code=202)
|
||||||
|
async def rebuild_one_wiki_index(request: Request, mapping_id: str, reason: str = Query(default="manual", max_length=255)) -> dict[str, Any]:
|
||||||
|
return await rebuild_wiki_index(request, WikiIndexActionRequest(knowledge_base_ids=[mapping_id], force=True, reason=reason))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-bases/{mapping_id}/wiki-index-status")
|
||||||
|
async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||||
|
user_id, is_admin = await _actor(request)
|
||||||
|
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||||
|
if mapping is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
index_store = getattr(request.app.state, "llmwiki_index_store", None)
|
||||||
|
if index_store is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
|
||||||
|
states = await index_store.list_index_status([mapping_id])
|
||||||
|
state = states[0] if states else {"knowledge_base_mapping_id": mapping_id, "state": "not_synced", "index_revision": 0}
|
||||||
|
embedding = getattr(request.app.state, "llmwiki_embedding", None)
|
||||||
|
fingerprint = embedding.fingerprint if embedding is not None else None
|
||||||
|
fully_vectorized = bool(mapping.get("wiki_index_enabled", True)) and is_fully_vectorized(state, fingerprint)
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"knowledge_base_id": mapping_id,
|
||||||
|
"local_index_enabled": bool(request.app.state.config.llmwiki.local_wiki_index.enabled),
|
||||||
|
"wiki_index_enabled": mapping.get("wiki_index_enabled", True),
|
||||||
|
"fully_vectorized": fully_vectorized,
|
||||||
|
"retrieval_mode": "vector" if fully_vectorized else "wiki_api",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-bases/{mapping_id}/vectorize", status_code=202)
|
||||||
|
async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||||
|
config = request.app.state.config.llmwiki.local_wiki_index
|
||||||
|
if not config.enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={"code": "WIKI_INDEX_DISABLED", "message": "本地 Wiki 向量化未在配置中启用"},
|
||||||
|
)
|
||||||
|
service = getattr(request.app.state, "llmwiki_sync_service", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail={"code": "WIKI_VECTORIZATION_UNAVAILABLE"})
|
||||||
|
user_id, is_admin = await _actor(request)
|
||||||
|
mapping_store = request.app.state.llmwiki_store
|
||||||
|
mapping = await mapping_store.get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||||
|
if mapping is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
if not mapping.get("wiki_index_enabled", True):
|
||||||
|
mapping = await mapping_store.update_mapping(mapping_id, wiki_index_enabled=True)
|
||||||
|
job_id = str(uuid4())
|
||||||
|
spawn_local_wiki_index_job(request.app, _run_one_sync(request.app, mapping, force=True))
|
||||||
|
return {"job_id": job_id, "status": "accepted", "knowledge_base_id": mapping_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge-bases/{mapping_id}/wiki-index-settings")
|
||||||
|
async def update_wiki_index_settings(request: Request, mapping_id: str, body: WikiIndexSettingsRequest) -> dict[str, Any]:
|
||||||
|
user_id, is_admin = await _actor(request)
|
||||||
|
store = request.app.state.llmwiki_store
|
||||||
|
current = await store.get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||||
|
if current is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
if body.external_search_enabled and str(current.get("owner_user_id")) == "system" and str(current.get("name") or "").strip() == "对话沉淀":
|
||||||
|
raise HTTPException(status_code=403, detail="Conversation deposit can never be exposed externally")
|
||||||
|
updated = await store.update_mapping(mapping_id, wiki_index_enabled=body.wiki_index_enabled, external_search_enabled=body.external_search_enabled)
|
||||||
|
return {"knowledge_base_id": mapping_id, "wiki_index_enabled": updated.get("wiki_index_enabled", True), "external_search_enabled": updated.get("external_search_enabled", False)}
|
||||||
@@ -534,6 +534,45 @@ llmwiki:
|
|||||||
web_base_url: "http://119.254.86.251:8088"
|
web_base_url: "http://119.254.86.251:8088"
|
||||||
admin_email: "lqq@qq.com"
|
admin_email: "lqq@qq.com"
|
||||||
admin_password: "1qaz@WSX"
|
admin_password: "1qaz@WSX"
|
||||||
|
# DeerFlow-local Wiki mirror/vector index. Keep disabled until the
|
||||||
|
# OpenAI-compatible embedding endpoint below is configured and reachable.
|
||||||
|
local_wiki_index:
|
||||||
|
enabled: false
|
||||||
|
sync_enabled: false
|
||||||
|
internal_search_enabled: false
|
||||||
|
reference_ui_enabled: false
|
||||||
|
sync_interval_seconds: 120
|
||||||
|
chunk_max_chars: 1000
|
||||||
|
chunk_overlap_chars: 120
|
||||||
|
chunk_min_chars: 80
|
||||||
|
embedding:
|
||||||
|
enabled: true
|
||||||
|
provider: openai
|
||||||
|
model: bge-m3
|
||||||
|
base_url: ""
|
||||||
|
api_key: ""
|
||||||
|
dimensions: 1024
|
||||||
|
batch_size: 32
|
||||||
|
timeout_seconds: 30
|
||||||
|
max_retries: 3
|
||||||
|
engine: numpy_exact
|
||||||
|
strict_vector: true
|
||||||
|
top_k_sections: 30
|
||||||
|
top_k_pages: 8
|
||||||
|
max_sections_per_page: 2
|
||||||
|
min_similarity: 0.35
|
||||||
|
cache_max_knowledge_bases: 20
|
||||||
|
cache_revision_check_seconds: 5
|
||||||
|
external_api:
|
||||||
|
enabled: false
|
||||||
|
api_key: ""
|
||||||
|
previous_api_key: ""
|
||||||
|
max_query_chars: 1000
|
||||||
|
max_knowledge_bases: 20
|
||||||
|
max_results: 50
|
||||||
|
default_results: 10
|
||||||
|
include_content_max_chars: 12000
|
||||||
|
requests_per_minute: 60
|
||||||
|
|
||||||
log_system:
|
log_system:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# WeKnora Wiki 数据同步至 DeerFlow 并建立本地向量索引——实现级设计方案
|
# WeKnora Wiki 数据同步至 DeerFlow 并建立本地向量索引——实现级设计方案
|
||||||
|
|
||||||
> 文档状态:待实现(Implementation Ready)
|
> 文档状态:首版已实现(Implemented,2026-08-23;2026-08-24 增补整库未向量化时的 Wiki-only 检索回退、知识库所有者手动向量化及全量 Excel 导出)
|
||||||
> 编写日期:2026-08-21
|
> 编写日期:2026-08-21
|
||||||
> 适用代码库:当前 DeerFlow 工作区
|
> 适用代码库:当前 DeerFlow 工作区
|
||||||
> WeKnora 适配基线:v0.7.2
|
> WeKnora 适配基线:v0.7.2
|
||||||
> 关联文档:`docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`
|
> 关联文档:`docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`
|
||||||
> 核心边界:**不修改 WeKnora 后端;WeKnora 仅作为 Wiki 生成源,DeerFlow 保存完整 Wiki 镜像、章节向量和检索索引,并承接全部内部与外部检索。**
|
> 核心边界:**不修改 WeKnora 后端;WeKnora 仅作为 Wiki 生成源,DeerFlow 保存完整 Wiki 镜像、章节向量和检索索引,并承接全部内部与外部检索。**
|
||||||
|
> 现行产品补充决策:内部检索只有在单个知识库整库完成向量化后才使用本地向量;未完成时只调用 WeKnora Wiki 页面检索接口并读取 Wiki 正文,绝不调用原始文档/切片检索。外部 API 继续严格使用本地向量。Wiki 管理页支持所有者手动触发整库向量化,并将全部 Wiki 导出为包含当前目录、完整父级目录和逐级目录列的 Excel。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+100
-9
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
def normalize_service_url(value: str | None) -> str:
|
def normalize_service_url(value: str | None) -> str:
|
||||||
@@ -37,18 +37,12 @@ class WeKnoraConnectionConfig(BaseModel):
|
|||||||
web_base_url: str = Field(default="", description="Optional internal WeKnora administration URL")
|
web_base_url: str = Field(default="", description="Optional internal WeKnora administration URL")
|
||||||
admin_email: str = Field(
|
admin_email: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description=(
|
description=("Optional WeKnora administrator email used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
|
||||||
"Optional WeKnora administrator email used by DeerFlow's server-side "
|
|
||||||
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
admin_password: str = Field(
|
admin_password: str = Field(
|
||||||
default="",
|
default="",
|
||||||
repr=False,
|
repr=False,
|
||||||
description=(
|
description=("Optional WeKnora administrator password used by DeerFlow's server-side WeKnora API client and detail-page iframe proxy. Prefer $ENV references."),
|
||||||
"Optional WeKnora administrator password used by DeerFlow's server-side "
|
|
||||||
"WeKnora API client and detail-page iframe proxy. Prefer $ENV references."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -58,10 +52,107 @@ class WeKnoraConnectionConfig(BaseModel):
|
|||||||
return normalize_service_url(value)
|
return normalize_service_url(value)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalWikiEmbeddingConfig(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
provider: str = "openai"
|
||||||
|
model: str = "bge-m3"
|
||||||
|
base_url: str = ""
|
||||||
|
api_key: str = Field(default="", repr=False)
|
||||||
|
dimensions: int | None = Field(default=1024, ge=1, le=65536)
|
||||||
|
batch_size: int = Field(default=32, ge=1, le=512)
|
||||||
|
timeout_seconds: float = Field(default=30, gt=0, le=300)
|
||||||
|
max_retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
@field_validator("provider")
|
||||||
|
@classmethod
|
||||||
|
def _validate_provider(cls, value: str) -> str:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in {"openai", "openai_compatible"}:
|
||||||
|
raise ValueError("local Wiki embedding provider must be openai or openai_compatible")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("base_url")
|
||||||
|
@classmethod
|
||||||
|
def _validate_url(cls, value: str) -> str:
|
||||||
|
return normalize_service_url(value)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalWikiExternalApiConfig(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
api_key: str = Field(default="", repr=False)
|
||||||
|
previous_api_key: str = Field(default="", repr=False)
|
||||||
|
max_query_chars: int = Field(default=1000, ge=1, le=10000)
|
||||||
|
max_knowledge_bases: int = Field(default=20, ge=1, le=100)
|
||||||
|
max_results: int = Field(default=50, ge=1, le=100)
|
||||||
|
default_results: int = Field(default=10, ge=1, le=100)
|
||||||
|
include_content_max_chars: int = Field(default=12000, ge=0, le=1_000_000)
|
||||||
|
requests_per_minute: int = Field(default=60, ge=1, le=100000)
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_enabled_key(self):
|
||||||
|
if self.enabled and not self.api_key.strip():
|
||||||
|
raise ValueError("llmwiki.local_wiki_index.external_api.api_key is required when external API is enabled")
|
||||||
|
if self.default_results > self.max_results:
|
||||||
|
raise ValueError("external_api.default_results must not exceed max_results")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LocalWikiIndexConfig(BaseModel):
|
||||||
|
"""DeerFlow-owned mirror and exact-vector index for WeKnora Wiki pages."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
sync_enabled: bool = False
|
||||||
|
internal_search_enabled: bool = False
|
||||||
|
reference_ui_enabled: bool = False
|
||||||
|
sync_interval_seconds: int = Field(default=120, ge=10, le=86400)
|
||||||
|
sync_page_size: int = Field(default=100, ge=1, le=500)
|
||||||
|
sync_concurrency: int = Field(default=3, ge=1, le=32)
|
||||||
|
sync_lease_seconds: int = Field(default=600, ge=30, le=86400)
|
||||||
|
sync_request_timeout_seconds: float = Field(default=30, gt=0, le=300)
|
||||||
|
sync_max_retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
sync_retry_backoff_seconds: float = Field(default=5, ge=0, le=300)
|
||||||
|
chunk_max_chars: int = Field(default=1000, ge=100, le=20000)
|
||||||
|
chunk_overlap_chars: int = Field(default=120, ge=0, le=10000)
|
||||||
|
chunk_min_chars: int = Field(default=80, ge=1, le=10000)
|
||||||
|
title_prefix_enabled: bool = True
|
||||||
|
embedding: LocalWikiEmbeddingConfig = Field(default_factory=LocalWikiEmbeddingConfig)
|
||||||
|
engine: str = "numpy_exact"
|
||||||
|
strict_vector: bool = True
|
||||||
|
top_k_sections: int = Field(default=30, ge=1, le=1000)
|
||||||
|
top_k_pages: int = Field(default=8, ge=1, le=100)
|
||||||
|
max_sections_per_page: int = Field(default=2, ge=1, le=20)
|
||||||
|
min_similarity: float = Field(default=0.35, ge=-1, le=1)
|
||||||
|
keyword_boost_enabled: bool = False
|
||||||
|
keyword_boost_weight: float = Field(default=0.15, ge=0, le=1)
|
||||||
|
cache_max_knowledge_bases: int = Field(default=20, ge=1, le=1000)
|
||||||
|
cache_revision_check_seconds: float = Field(default=5, ge=0, le=3600)
|
||||||
|
external_api: LocalWikiExternalApiConfig = Field(default_factory=LocalWikiExternalApiConfig)
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_index(self):
|
||||||
|
if self.chunk_overlap_chars >= self.chunk_max_chars:
|
||||||
|
raise ValueError("chunk_overlap_chars must be smaller than chunk_max_chars")
|
||||||
|
if self.chunk_min_chars > self.chunk_max_chars:
|
||||||
|
raise ValueError("chunk_min_chars must not exceed chunk_max_chars")
|
||||||
|
if self.top_k_pages > self.top_k_sections:
|
||||||
|
raise ValueError("top_k_pages must not exceed top_k_sections")
|
||||||
|
if self.engine != "numpy_exact":
|
||||||
|
raise ValueError("Only the numpy_exact local Wiki vector engine is currently supported")
|
||||||
|
if self.enabled and not self.embedding.enabled:
|
||||||
|
raise ValueError("local Wiki index embedding must be enabled when the index is enabled")
|
||||||
|
if self.enabled and (not self.embedding.base_url or not self.embedding.model.strip()):
|
||||||
|
raise ValueError("local Wiki index embedding base_url and model are required when enabled")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class LlmWikiConfig(BaseModel):
|
class LlmWikiConfig(BaseModel):
|
||||||
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
|
"""Top-level ``config.yaml`` section for conditional WeKnora mode."""
|
||||||
|
|
||||||
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
|
weknora: WeKnoraConnectionConfig = Field(default_factory=WeKnoraConnectionConfig)
|
||||||
|
local_wiki_index: LocalWikiIndexConfig = Field(default_factory=LocalWikiIndexConfig)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"""DeerFlow-local mirror and Wiki-only retrieval for WeKnora Wiki pages."""
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService, is_fully_vectorized
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError, WikiVectorSearchService
|
||||||
|
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||||
|
|
||||||
|
__all__ = ["WikiIndexError", "WikiRetrievalService", "WikiVectorSearchService", "WikiSyncService", "is_fully_vectorized"]
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
"""Markdown-aware Wiki section chunking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
_FRONTMATTER_RE = re.compile(r"\A\s*---\s*\n.*?\n---\s*(?:\n|\Z)", re.DOTALL)
|
||||||
|
_SCRIPT_STYLE_RE = re.compile(r"<\s*(script|style)\b[^>]*>.*?<\s*/\s*\1\s*>", re.IGNORECASE | re.DOTALL)
|
||||||
|
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
||||||
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WikiSection:
|
||||||
|
section_index: int
|
||||||
|
heading: str | None
|
||||||
|
content: str
|
||||||
|
embedding_text: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_markdown(value: str) -> str:
|
||||||
|
text = value.replace("\r\n", "\n")
|
||||||
|
text = _FRONTMATTER_RE.sub("", text)
|
||||||
|
text = _SCRIPT_STYLE_RE.sub("", text)
|
||||||
|
text = _IMAGE_RE.sub(lambda match: match.group(1), text)
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _windows(text: str, max_chars: int, overlap: int) -> list[str]:
|
||||||
|
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
|
||||||
|
chunks: list[str] = []
|
||||||
|
buffer = ""
|
||||||
|
for paragraph in paragraphs:
|
||||||
|
if len(buffer) + len(paragraph) + 2 <= max_chars:
|
||||||
|
buffer = f"{buffer}\n\n{paragraph}".strip()
|
||||||
|
continue
|
||||||
|
if buffer:
|
||||||
|
chunks.append(buffer)
|
||||||
|
buffer = ""
|
||||||
|
if len(paragraph) <= max_chars:
|
||||||
|
buffer = paragraph
|
||||||
|
continue
|
||||||
|
step = max(1, max_chars - overlap)
|
||||||
|
chunks.extend(paragraph[start : start + max_chars] for start in range(0, len(paragraph), step))
|
||||||
|
if buffer:
|
||||||
|
chunks.append(buffer)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_wiki_page(
|
||||||
|
page: dict,
|
||||||
|
*,
|
||||||
|
max_chars: int,
|
||||||
|
overlap: int,
|
||||||
|
min_chars: int,
|
||||||
|
title_prefix_enabled: bool,
|
||||||
|
) -> list[WikiSection]:
|
||||||
|
content = _clean_markdown(str(page.get("content_md") or ""))
|
||||||
|
heading_stack: list[str] = []
|
||||||
|
sections: list[tuple[str | None, str]] = []
|
||||||
|
current_heading: str | None = None
|
||||||
|
body: list[str] = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
match = _HEADING_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
body.append(line)
|
||||||
|
continue
|
||||||
|
if any(item.strip() for item in body):
|
||||||
|
sections.append((current_heading, "\n".join(body).strip()))
|
||||||
|
level = len(match.group(1))
|
||||||
|
heading_stack = heading_stack[: level - 1]
|
||||||
|
heading_stack.append(match.group(2).strip())
|
||||||
|
current_heading = " / ".join(heading_stack)
|
||||||
|
body = []
|
||||||
|
if any(item.strip() for item in body):
|
||||||
|
sections.append((current_heading, "\n".join(body).strip()))
|
||||||
|
if content and not sections:
|
||||||
|
sections.append((None, content))
|
||||||
|
summary = _clean_markdown(str(page.get("summary") or ""))
|
||||||
|
if summary:
|
||||||
|
sections.insert(0, ("页面摘要", summary))
|
||||||
|
|
||||||
|
prefix_lines = [f"页面:{page.get('title') or page.get('slug') or ''}"]
|
||||||
|
aliases = [str(item) for item in page.get("aliases") or [] if str(item).strip()]
|
||||||
|
if aliases:
|
||||||
|
prefix_lines.append(f"别名:{'、'.join(aliases)}")
|
||||||
|
if page.get("page_type"):
|
||||||
|
prefix_lines.append(f"类型:{page['page_type']}")
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[WikiSection] = []
|
||||||
|
for heading, section_text in sections:
|
||||||
|
for chunk in _windows(section_text, max_chars, overlap):
|
||||||
|
normalized = re.sub(r"\s+", " ", chunk).strip()
|
||||||
|
if len(normalized) < min_chars and len(sections) > 1:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
if digest in seen:
|
||||||
|
continue
|
||||||
|
seen.add(digest)
|
||||||
|
context = list(prefix_lines) if title_prefix_enabled else []
|
||||||
|
if heading:
|
||||||
|
context.append(f"章节:{heading}")
|
||||||
|
embedding_text = "\n".join([*context, "", chunk]).strip()
|
||||||
|
result.append(WikiSection(len(result), heading, chunk, embedding_text, digest))
|
||||||
|
return result
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"""Strict OpenAI-compatible embedding client for the Wiki index."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import normalize_vector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiEmbeddingError(RuntimeError):
|
||||||
|
def __init__(self, message: str, *, category: str = "request", retryable: bool = True) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.category = category
|
||||||
|
self.retryable = retryable
|
||||||
|
|
||||||
|
|
||||||
|
class StrictWikiEmbeddingClient:
|
||||||
|
def __init__(self, config: LocalWikiEmbeddingConfig, *, http_client: httpx.AsyncClient | None = None) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._client = http_client
|
||||||
|
self._owns_client = http_client is None
|
||||||
|
self._resolved_dimensions = config.dimensions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dimensions(self) -> int | None:
|
||||||
|
return self._resolved_dimensions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fingerprint(self) -> str:
|
||||||
|
raw = "\0".join((self.config.provider.strip().lower(), self.config.base_url.rstrip("/").lower(), self.config.model.strip(), str(self._resolved_dimensions or "auto")))
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._owns_client and self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
if not self.config.enabled or not self.config.base_url or not self.config.model:
|
||||||
|
raise WikiEmbeddingError("Wiki embedding is not configured", category="configuration", retryable=False)
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
vectors: list[list[float]] = []
|
||||||
|
batch_size = max(1, self.config.batch_size)
|
||||||
|
for start in range(0, len(texts), batch_size):
|
||||||
|
vectors.extend(await self._embed_batch(texts[start : start + batch_size]))
|
||||||
|
if len(vectors) != len(texts):
|
||||||
|
raise WikiEmbeddingError("Embedding response count does not match request", category="response", retryable=False)
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
async def _embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
url = self.config.base_url.rstrip("/") + "/embeddings"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.config.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
||||||
|
payload: dict = {"model": self.config.model, "input": texts}
|
||||||
|
if self.config.dimensions:
|
||||||
|
payload["dimensions"] = self.config.dimensions
|
||||||
|
last_error: WikiEmbeddingError | None = None
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
started = monotonic()
|
||||||
|
try:
|
||||||
|
response = await (await self._get_client()).post(url, headers=headers, json=payload)
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise WikiEmbeddingError("Embedding authentication failed", category="authentication", retryable=False)
|
||||||
|
if response.status_code == 429 or response.status_code >= 500:
|
||||||
|
raise WikiEmbeddingError(f"Embedding service returned HTTP {response.status_code}", category="upstream", retryable=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
items = sorted(data.get("data", []), key=lambda item: int(item.get("index", 0))) if isinstance(data, dict) else []
|
||||||
|
if len(items) != len(texts):
|
||||||
|
raise WikiEmbeddingError("Embedding response count mismatch", category="response", retryable=False)
|
||||||
|
result: list[list[float]] = []
|
||||||
|
for item in items:
|
||||||
|
vector = normalize_vector(item.get("embedding") or [], dimensions=self._resolved_dimensions)
|
||||||
|
if self._resolved_dimensions is None:
|
||||||
|
self._resolved_dimensions = int(vector.size)
|
||||||
|
result.append(vector.astype(float).tolist())
|
||||||
|
logger.info("Wiki embedding batch completed model=%s batch=%d duration_ms=%d", self.config.model, len(texts), int((monotonic() - started) * 1000))
|
||||||
|
return result
|
||||||
|
except WikiEmbeddingError as exc:
|
||||||
|
last_error = exc
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
last_error = WikiEmbeddingError("Embedding request timed out", category="timeout", retryable=True)
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError) as exc:
|
||||||
|
last_error = WikiEmbeddingError(f"Embedding request failed ({type(exc).__name__})", category="request", retryable=isinstance(exc, httpx.HTTPError))
|
||||||
|
if last_error is not None and (not last_error.retryable or attempt >= self.config.max_retries):
|
||||||
|
raise last_error
|
||||||
|
await asyncio.sleep(min(30.0, 0.5 * (2**attempt)))
|
||||||
|
raise last_error or WikiEmbeddingError("Embedding request failed")
|
||||||
|
|
||||||
|
async def embed_query(self, text: str) -> list[float]:
|
||||||
|
result = await self.embed_texts([text])
|
||||||
|
return result[0]
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
"""Normalize unstable WeKnora Wiki response shapes into the local contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_slug(value: Any) -> str:
|
||||||
|
slug = unquote(str(value or "")).replace("\\", "/").strip().strip("/")
|
||||||
|
slug = re.sub(r"/{2,}", "/", slug)
|
||||||
|
if not slug:
|
||||||
|
raise ValueError("Wiki page slug is empty")
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def slug_hash(slug: str) -> str:
|
||||||
|
return hashlib.sha256(normalize_slug(slug).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _list(value: Any) -> list:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return list(value)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return [value.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_wiki_page(mapping_id: str, remote: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
slug = normalize_slug(remote.get("slug") or remote.get("wiki_slug") or remote.get("path"))
|
||||||
|
aliases = sorted({str(item).strip() for item in _list(remote.get("aliases")) if str(item).strip()})
|
||||||
|
category_path = [str(item).strip() for item in _list(remote.get("category_path") or remote.get("categories")) if str(item).strip()]
|
||||||
|
title = str(remote.get("title") or slug.rsplit("/", 1)[-1]).strip()
|
||||||
|
summary = str(remote.get("summary") or remote.get("description") or "").replace("\r\n", "\n").strip()
|
||||||
|
content = str(remote.get("content_md") or remote.get("content") or remote.get("markdown") or "").replace("\r\n", "\n").strip()
|
||||||
|
status = str(remote.get("status") or "published").strip().lower()
|
||||||
|
if status not in {"draft", "published", "archived"}:
|
||||||
|
status = "published"
|
||||||
|
hash_payload = {
|
||||||
|
"title": title,
|
||||||
|
"aliases": aliases,
|
||||||
|
"summary": summary,
|
||||||
|
"content_md": content,
|
||||||
|
"page_type": str(remote.get("page_type") or remote.get("type") or "article").strip().lower(),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
content_hash = hashlib.sha256(json.dumps(hash_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
|
return {
|
||||||
|
"knowledge_base_mapping_id": mapping_id,
|
||||||
|
"remote_page_id": str(remote.get("id") or remote.get("page_id") or "").strip() or None,
|
||||||
|
"slug": slug,
|
||||||
|
"slug_hash": slug_hash(slug),
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"content_md": content,
|
||||||
|
"page_type": hash_payload["page_type"],
|
||||||
|
"status": status,
|
||||||
|
"aliases": aliases,
|
||||||
|
"category_path": category_path,
|
||||||
|
"parent_slug": str(remote.get("parent_slug") or "").strip() or None,
|
||||||
|
"wiki_path": str(remote.get("wiki_path") or remote.get("path") or "").strip() or None,
|
||||||
|
"source_refs": _list(remote.get("source_refs") or remote.get("sources")),
|
||||||
|
"chunk_refs": _list(remote.get("chunk_refs") or remote.get("chunks")),
|
||||||
|
"page_version": int(remote.get("page_version") or remote.get("version") or 0),
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"remote_created_at": _datetime(remote.get("created_at") or remote.get("createdAt")),
|
||||||
|
"remote_updated_at": _datetime(remote.get("updated_at") or remote.get("updatedAt")),
|
||||||
|
}
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
"""Wiki-only retrieval with local-vector preference and remote Wiki fallback.
|
||||||
|
|
||||||
|
The fallback deliberately calls WeKnora's ``/wiki/pages`` API. It never calls
|
||||||
|
the raw knowledge/chunk search endpoint, so callers always receive processed
|
||||||
|
Wiki pages even while a whole knowledge base is awaiting vectorization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.client import WeKnoraClient
|
||||||
|
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError, WikiVectorSearchService
|
||||||
|
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
def is_fully_vectorized(state: dict[str, Any] | None, fingerprint: str | None) -> bool:
|
||||||
|
"""Return whether a completed full scan produced one coherent active index."""
|
||||||
|
|
||||||
|
if not state or not fingerprint:
|
||||||
|
return False
|
||||||
|
if str(state.get("state") or "") != "idle" or not state.get("last_success_at"):
|
||||||
|
return False
|
||||||
|
if str(state.get("embedding_fingerprint") or "") != fingerprint:
|
||||||
|
return False
|
||||||
|
local_count = int(state.get("local_page_count") or 0)
|
||||||
|
ready_count = int(state.get("ready_page_count") or 0)
|
||||||
|
failed_count = int(state.get("failed_page_count") or 0)
|
||||||
|
vector_count = int(state.get("vector_count") or 0)
|
||||||
|
if failed_count or ready_count != local_count:
|
||||||
|
return False
|
||||||
|
return local_count == 0 or vector_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
class WikiRetrievalService:
|
||||||
|
"""Search complete indexes locally and incomplete indexes through Wiki API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: LlmWikiIndexStore,
|
||||||
|
*,
|
||||||
|
vector_search: WikiVectorSearchService | None,
|
||||||
|
client: WeKnoraClient | None,
|
||||||
|
) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.vector_search = vector_search
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
mappings: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
top_k_pages: int = 8,
|
||||||
|
include_drafts: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_query = query.strip()
|
||||||
|
if not normalized_query:
|
||||||
|
raise WikiIndexError("WIKI_QUERY_EMPTY", "Search query is empty", status_code=400)
|
||||||
|
if not mappings:
|
||||||
|
return {
|
||||||
|
"query": normalized_query,
|
||||||
|
"mode": "wiki_api",
|
||||||
|
"results": [],
|
||||||
|
"knowledge_base_count": 0,
|
||||||
|
"partial": False,
|
||||||
|
"warnings": [],
|
||||||
|
"fallback_knowledge_base_ids": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
vector_mappings, fallback_mappings = await self._partition_mappings(mappings)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
vector_used = False
|
||||||
|
fallback_used = False
|
||||||
|
|
||||||
|
if vector_mappings and self.vector_search is not None:
|
||||||
|
try:
|
||||||
|
payload = await self.vector_search.search(
|
||||||
|
normalized_query,
|
||||||
|
vector_mappings,
|
||||||
|
top_k_pages=max(top_k_pages, len(vector_mappings)),
|
||||||
|
include_drafts=include_drafts,
|
||||||
|
)
|
||||||
|
results.extend(payload.get("results") or [])
|
||||||
|
warnings.extend(str(item) for item in payload.get("warnings") or [])
|
||||||
|
vector_used = True
|
||||||
|
except WikiIndexError as exc:
|
||||||
|
# A status row can become stale between classification and
|
||||||
|
# matrix loading. Wiki API remains a safe Wiki-only fallback.
|
||||||
|
warnings.append(f"local_vector:{exc.code}")
|
||||||
|
fallback_mappings.extend(vector_mappings)
|
||||||
|
vector_mappings = []
|
||||||
|
|
||||||
|
fallback_ids = [str(mapping["id"]) for mapping in fallback_mappings]
|
||||||
|
if fallback_mappings:
|
||||||
|
if self.client is None:
|
||||||
|
warnings.extend(f"{mapping['id']}:WIKI_API_UNAVAILABLE" for mapping in fallback_mappings)
|
||||||
|
else:
|
||||||
|
batches = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
self._search_remote_mapping(
|
||||||
|
normalized_query,
|
||||||
|
mapping,
|
||||||
|
limit=max(top_k_pages, 1),
|
||||||
|
include_drafts=include_drafts,
|
||||||
|
)
|
||||||
|
for mapping in fallback_mappings
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for mapping, batch in zip(fallback_mappings, batches, strict=True):
|
||||||
|
if isinstance(batch, Exception):
|
||||||
|
warnings.append(f"{mapping['id']}:WIKI_API_SEARCH_FAILED")
|
||||||
|
else:
|
||||||
|
results.extend(batch)
|
||||||
|
fallback_used = True
|
||||||
|
|
||||||
|
if warnings and not results and not vector_used and not fallback_used:
|
||||||
|
raise WikiIndexError(
|
||||||
|
"WIKI_API_UNAVAILABLE",
|
||||||
|
"Wiki page retrieval is unavailable",
|
||||||
|
status_code=502 if self.client is not None else 503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# De-duplicate by the public Wiki citation identity. Vector scores and
|
||||||
|
# Wiki-API rank scores are both descending relevance hints.
|
||||||
|
unique: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for item in results:
|
||||||
|
key = (str(item.get("knowledge_base_id") or ""), str(item.get("wiki_slug") or item.get("id") or ""))
|
||||||
|
current = unique.get(key)
|
||||||
|
if current is None or float(item.get("score") or 0) > float(current.get("score") or 0):
|
||||||
|
unique[key] = item
|
||||||
|
ranked = sorted(unique.values(), key=lambda item: float(item.get("score") or 0), reverse=True)
|
||||||
|
mode = "mixed" if vector_used and fallback_used else "vector" if vector_used else "wiki_api"
|
||||||
|
fingerprint = self.vector_search.embedding.fingerprint[:12] if vector_used and self.vector_search is not None else None
|
||||||
|
return {
|
||||||
|
"query": normalized_query,
|
||||||
|
"mode": mode,
|
||||||
|
"results": ranked[:top_k_pages],
|
||||||
|
"knowledge_base_count": len(mappings),
|
||||||
|
"index_fingerprint": fingerprint,
|
||||||
|
"partial": bool(warnings),
|
||||||
|
"warnings": warnings,
|
||||||
|
"fallback_knowledge_base_ids": fallback_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _partition_mappings(self, mappings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
if self.vector_search is None:
|
||||||
|
return [], list(mappings)
|
||||||
|
mapping_ids = [str(mapping["id"]) for mapping in mappings]
|
||||||
|
states = {
|
||||||
|
str(state["knowledge_base_mapping_id"]): state
|
||||||
|
for state in await self.store.list_index_status(mapping_ids)
|
||||||
|
}
|
||||||
|
fingerprint = self.vector_search.embedding.fingerprint
|
||||||
|
vector_mappings = [
|
||||||
|
mapping
|
||||||
|
for mapping in mappings
|
||||||
|
if mapping.get("wiki_index_enabled", True)
|
||||||
|
and is_fully_vectorized(states.get(str(mapping["id"])), fingerprint)
|
||||||
|
]
|
||||||
|
ready_ids = {str(mapping["id"]) for mapping in vector_mappings}
|
||||||
|
return vector_mappings, [mapping for mapping in mappings if str(mapping["id"]) not in ready_ids]
|
||||||
|
|
||||||
|
async def _search_remote_mapping(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
mapping: dict[str, Any],
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
include_drafts: bool,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if self.client is None:
|
||||||
|
return []
|
||||||
|
remote_id = str(mapping["weknora_id"])
|
||||||
|
listing = await self.client.list_wiki_pages(
|
||||||
|
remote_id,
|
||||||
|
page=1,
|
||||||
|
page_size=min(max(limit * 3, 20), 100),
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
summaries = [item for item in listing.get("pages", []) if isinstance(item, dict)]
|
||||||
|
slugs = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
|
||||||
|
for item in summaries
|
||||||
|
if str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
|
||||||
|
)
|
||||||
|
)[: max(limit * 2, limit)]
|
||||||
|
details = await asyncio.gather(
|
||||||
|
*(self.client.get_wiki_page(remote_id, slug) for slug in slugs),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for rank, (slug, detail) in enumerate(zip(slugs, details, strict=True)):
|
||||||
|
if isinstance(detail, Exception):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
page = normalize_wiki_page(str(mapping["id"]), detail)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if page["status"] == "archived" or (page["status"] == "draft" and not include_drafts):
|
||||||
|
continue
|
||||||
|
params = urlencode({"knowledge_base_id": mapping["id"], "wiki_slug": slug})
|
||||||
|
content = str(page.get("content_md") or "")
|
||||||
|
score = float(detail.get("score") or max(0.01, 1.0 - rank * 0.05))
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"kind": "wiki_page",
|
||||||
|
"id": page.get("remote_page_id") or f"{mapping['id']}:{slug}",
|
||||||
|
"knowledge_base_id": mapping["id"],
|
||||||
|
"knowledge_base_name": mapping.get("name") or "",
|
||||||
|
"wiki_page_id": page.get("remote_page_id") or f"{mapping['id']}:{slug}",
|
||||||
|
"wiki_slug": slug,
|
||||||
|
"title": page.get("title") or slug,
|
||||||
|
"summary": page.get("summary") or "",
|
||||||
|
"page_type": page.get("page_type") or "article",
|
||||||
|
"page_version": int(page.get("page_version") or 0),
|
||||||
|
"updated_at": page.get("remote_updated_at"),
|
||||||
|
"score": score,
|
||||||
|
"content": content,
|
||||||
|
"snippet": content[:360],
|
||||||
|
"matched_heading": None,
|
||||||
|
"matched_sections": [
|
||||||
|
{
|
||||||
|
"section_index": 0,
|
||||||
|
"heading": None,
|
||||||
|
"content": content,
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": f"/weknora-source-preview?{params}",
|
||||||
|
"source": mapping.get("name") or "LLMWiki",
|
||||||
|
"type": "llmwiki_wiki",
|
||||||
|
"mode": "citation",
|
||||||
|
"retrieval_mode": "wiki_api",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["WikiRetrievalService", "is_fully_vectorized"]
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
"""App-to-harness runtime injection for local Wiki index services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
|
||||||
|
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||||
|
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
|
||||||
|
|
||||||
|
_store: LlmWikiIndexStore | None = None
|
||||||
|
_search: WikiVectorSearchService | None = None
|
||||||
|
_sync: WikiSyncService | None = None
|
||||||
|
_retrieval: WikiRetrievalService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_local_wiki_index_runtime(
|
||||||
|
*,
|
||||||
|
store: LlmWikiIndexStore | None,
|
||||||
|
search: WikiVectorSearchService | None,
|
||||||
|
sync: WikiSyncService | None,
|
||||||
|
retrieval: WikiRetrievalService | None = None,
|
||||||
|
) -> None:
|
||||||
|
global _store, _search, _sync, _retrieval
|
||||||
|
_store, _search, _sync, _retrieval = store, search, sync, retrieval
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_wiki_index_store() -> LlmWikiIndexStore:
|
||||||
|
if _store is None:
|
||||||
|
raise RuntimeError("Local Wiki index store is not initialized")
|
||||||
|
return _store
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_wiki_search_service() -> WikiVectorSearchService:
|
||||||
|
if _search is None:
|
||||||
|
raise RuntimeError("Local Wiki vector search is not initialized")
|
||||||
|
return _search
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_wiki_sync_service() -> WikiSyncService:
|
||||||
|
if _sync is None:
|
||||||
|
raise RuntimeError("Local Wiki sync service is not initialized")
|
||||||
|
return _sync
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_wiki_retrieval_service() -> WikiRetrievalService:
|
||||||
|
if _retrieval is None:
|
||||||
|
raise RuntimeError("Wiki-only retrieval service is not initialized")
|
||||||
|
return _retrieval
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
"""Unified exact-vector search over DeerFlow-local Wiki section snapshots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from collections import OrderedDict, defaultdict
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||||
|
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache, WikiVectorSnapshot
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import normalize_vector
|
||||||
|
|
||||||
|
|
||||||
|
class WikiIndexError(RuntimeError):
|
||||||
|
def __init__(self, code: str, message: str, *, status_code: int) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class WikiVectorSearchService:
|
||||||
|
def __init__(self, config: LocalWikiIndexConfig, embedding: StrictWikiEmbeddingClient, cache: WikiVectorCache) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.embedding = embedding
|
||||||
|
self.cache = cache
|
||||||
|
self._query_vectors: OrderedDict[str, tuple[float, np.ndarray]] = OrderedDict()
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
mappings: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
top_k_pages: int | None = None,
|
||||||
|
include_drafts: bool = False,
|
||||||
|
external: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_query = query.strip()
|
||||||
|
if not normalized_query:
|
||||||
|
raise WikiIndexError("WIKI_QUERY_EMPTY", "Search query is empty", status_code=400)
|
||||||
|
if not mappings:
|
||||||
|
return {"query": normalized_query, "mode": "vector", "results": [], "knowledge_base_count": 0, "index_fingerprint": self.embedding.fingerprint[:12], "partial": False, "warnings": []}
|
||||||
|
try:
|
||||||
|
query_vector = await self._embed_query(normalized_query)
|
||||||
|
except (WikiEmbeddingError, ValueError) as exc:
|
||||||
|
raise WikiIndexError("WIKI_EMBEDDING_UNAVAILABLE", str(exc), status_code=503) from None
|
||||||
|
fingerprint = self.embedding.fingerprint
|
||||||
|
tasks = [self._search_mapping(normalized_query, query_vector, mapping, fingerprint, include_drafts=include_drafts, external=external) for mapping in mappings]
|
||||||
|
batches = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
for mapping, batch in zip(mappings, batches, strict=True):
|
||||||
|
if isinstance(batch, Exception):
|
||||||
|
warnings.append(f"{mapping.get('id')}: {batch}")
|
||||||
|
else:
|
||||||
|
candidates.extend(batch)
|
||||||
|
if warnings and not candidates and len(warnings) == len(mappings):
|
||||||
|
if any("FINGERPRINT" in item for item in warnings):
|
||||||
|
code = "WIKI_INDEX_FINGERPRINT_MISMATCH"
|
||||||
|
elif any("rebuild" in item.lower() for item in warnings):
|
||||||
|
code = "WIKI_INDEX_REBUILDING"
|
||||||
|
else:
|
||||||
|
code = "WIKI_INDEX_NOT_READY"
|
||||||
|
raise WikiIndexError(code, "Local Wiki index is not ready", status_code=409)
|
||||||
|
candidates.sort(key=lambda item: item["score"], reverse=True)
|
||||||
|
grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for candidate in candidates:
|
||||||
|
page = candidate["page"]
|
||||||
|
key = (str(page["knowledge_base_mapping_id"]), str(page["id"]))
|
||||||
|
if len(grouped[key]) < self.config.max_sections_per_page:
|
||||||
|
grouped[key].append(candidate)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for sections in grouped.values():
|
||||||
|
first = sections[0]
|
||||||
|
page = first["page"]
|
||||||
|
mapping = first["mapping"]
|
||||||
|
second_score = sections[1]["score"] if len(sections) > 1 else 0.0
|
||||||
|
page_score = first["score"] + (0.05 * second_score if second_score else 0)
|
||||||
|
params = urlencode({"knowledge_base_id": mapping["id"], "wiki_slug": page["slug"]})
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"kind": "wiki_page",
|
||||||
|
"id": page["id"],
|
||||||
|
"knowledge_base_id": mapping["id"],
|
||||||
|
"knowledge_base_name": mapping.get("name") or "",
|
||||||
|
"wiki_page_id": page["id"],
|
||||||
|
"wiki_slug": page["slug"],
|
||||||
|
"title": page.get("title") or page["slug"],
|
||||||
|
"summary": page.get("summary") or "",
|
||||||
|
"page_type": page.get("page_type") or "article",
|
||||||
|
"page_version": int(page.get("page_version") or 0),
|
||||||
|
"updated_at": page.get("remote_updated_at") or page.get("updated_at"),
|
||||||
|
"score": float(page_score),
|
||||||
|
"content": "\n\n".join(str(item["section_content"]) for item in sections),
|
||||||
|
"snippet": str(sections[0]["section_content"])[:360],
|
||||||
|
"matched_heading": sections[0].get("heading"),
|
||||||
|
"matched_sections": [{"section_index": int(item["section_index"]), "heading": item.get("heading"), "content": item["section_content"], "score": float(item["score"])} for item in sections],
|
||||||
|
"url": f"/weknora-source-preview?{params}",
|
||||||
|
"source": mapping.get("name") or "LLMWiki",
|
||||||
|
"type": "llmwiki_wiki",
|
||||||
|
"mode": "citation",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results.sort(key=lambda item: item["score"], reverse=True)
|
||||||
|
limit = min(top_k_pages or self.config.top_k_pages, self.config.top_k_pages if top_k_pages is None else 50)
|
||||||
|
return {"query": normalized_query, "mode": "vector", "results": results[:limit], "knowledge_base_count": len(mappings), "index_fingerprint": fingerprint[:12], "partial": bool(warnings), "warnings": warnings}
|
||||||
|
|
||||||
|
async def _embed_query(self, query: str) -> np.ndarray:
|
||||||
|
digest = hashlib.sha256(query.encode("utf-8")).hexdigest()
|
||||||
|
key = f"{self.embedding.fingerprint}:{digest}"
|
||||||
|
cached = self._query_vectors.get(key)
|
||||||
|
if cached is not None and monotonic() - cached[0] <= 300:
|
||||||
|
self._query_vectors.move_to_end(key)
|
||||||
|
return cached[1]
|
||||||
|
vector = normalize_vector(await self.embedding.embed_query(query), dimensions=self.embedding.dimensions)
|
||||||
|
key = f"{self.embedding.fingerprint}:{digest}"
|
||||||
|
self._query_vectors[key] = (monotonic(), vector)
|
||||||
|
self._query_vectors.move_to_end(key)
|
||||||
|
while len(self._query_vectors) > 256:
|
||||||
|
self._query_vectors.popitem(last=False)
|
||||||
|
return vector
|
||||||
|
|
||||||
|
async def _search_mapping(self, query: str, vector: np.ndarray, mapping: dict[str, Any], fingerprint: str, *, include_drafts: bool, external: bool) -> list[dict[str, Any]]:
|
||||||
|
mapping_id = str(mapping["id"])
|
||||||
|
try:
|
||||||
|
snapshot = await self.cache.get(mapping_id, fingerprint)
|
||||||
|
except ValueError as exc:
|
||||||
|
await self.cache.store.mark_rebuild_required([mapping_id], fingerprint)
|
||||||
|
self.cache.invalidate(mapping_id)
|
||||||
|
raise RuntimeError("WIKI_INDEX_REBUILD_REQUIRED") from exc
|
||||||
|
if snapshot.matrix.size == 0:
|
||||||
|
return []
|
||||||
|
if snapshot.dimensions != vector.size:
|
||||||
|
raise RuntimeError("WIKI_INDEX_FINGERPRINT_MISMATCH")
|
||||||
|
eligible = np.asarray(
|
||||||
|
[
|
||||||
|
index
|
||||||
|
for index, row in enumerate(snapshot.rows)
|
||||||
|
if row["page"].get("status") != "draft" or (include_drafts and not external)
|
||||||
|
],
|
||||||
|
dtype=int,
|
||||||
|
)
|
||||||
|
if eligible.size == 0:
|
||||||
|
return []
|
||||||
|
indices, scores = await asyncio.to_thread(self._top_indices, snapshot, vector, eligible)
|
||||||
|
terms = {term.lower() for term in re.findall(r"[\w\u4e00-\u9fff]{2,}", query)}
|
||||||
|
candidates = []
|
||||||
|
for index, score in zip(indices, scores, strict=True):
|
||||||
|
row = snapshot.rows[int(index)]
|
||||||
|
page = row["page"]
|
||||||
|
vector_score = float(score)
|
||||||
|
if vector_score < self.config.min_similarity:
|
||||||
|
continue
|
||||||
|
final_score = vector_score
|
||||||
|
if self.config.keyword_boost_enabled and terms:
|
||||||
|
haystack = f"{page.get('title', '')} {row.get('heading', '')} {row.get('section_content', '')}".lower()
|
||||||
|
keyword_score = sum(term in haystack for term in terms) / len(terms)
|
||||||
|
weight = self.config.keyword_boost_weight
|
||||||
|
final_score = (1 - weight) * vector_score + weight * keyword_score
|
||||||
|
candidates.append({**row, "page": page, "mapping": mapping, "score": final_score})
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _top_indices(self, snapshot: WikiVectorSnapshot, vector: np.ndarray, eligible: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
scores = snapshot.matrix[eligible] @ vector
|
||||||
|
count = min(self.config.top_k_sections, scores.size)
|
||||||
|
if count <= 0:
|
||||||
|
return np.asarray([], dtype=int), np.asarray([], dtype=np.float32)
|
||||||
|
positions = np.argpartition(scores, -count)[-count:]
|
||||||
|
positions = positions[np.argsort(scores[positions])[::-1]]
|
||||||
|
return eligible[positions], scores[positions]
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
"""Lease-protected full Wiki mirror scan and page-level vector replacement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||||
|
from deerflow.integrations.weknora.client import WeKnoraClient
|
||||||
|
from deerflow.integrations.weknora.local_index.chunking import chunk_wiki_page
|
||||||
|
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
|
||||||
|
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import encode_vector
|
||||||
|
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSyncLeaseLost(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_error(exc: BaseException) -> str:
|
||||||
|
return f"{type(exc).__name__}: {str(exc)[:1000]}"
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSyncService:
|
||||||
|
def __init__(self, config: LocalWikiIndexConfig, store: LlmWikiIndexStore, embedding: StrictWikiEmbeddingClient, cache: WikiVectorCache, client: WeKnoraClient) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self.embedding = embedding
|
||||||
|
self.cache = cache
|
||||||
|
self.client = client
|
||||||
|
self.owner = f"{platform.node()}:{uuid4()}"
|
||||||
|
|
||||||
|
async def sync_mapping(self, mapping: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
|
||||||
|
mapping_id = str(mapping["id"])
|
||||||
|
sync_id = str(uuid4())
|
||||||
|
if self.embedding.dimensions is None:
|
||||||
|
# Auto-dimension configurations need one successful response to
|
||||||
|
# produce a stable fingerprint before lease/rebuild decisions.
|
||||||
|
await self.embedding.embed_query("DeerFlow local Wiki embedding dimension probe")
|
||||||
|
fingerprint = self.embedding.fingerprint
|
||||||
|
revision, indexed_fingerprint, prior_state = await self.store.get_index_revision(mapping_id)
|
||||||
|
if revision > 0 and indexed_fingerprint and indexed_fingerprint != fingerprint and prior_state not in {"rebuild_required", "rebuilding"}:
|
||||||
|
await self.store.mark_rebuild_required([mapping_id], fingerprint)
|
||||||
|
self.cache.invalidate(mapping_id)
|
||||||
|
prior_state = "rebuild_required"
|
||||||
|
rebuilding = prior_state == "rebuild_required"
|
||||||
|
acquired = await self.store.try_acquire_sync_lease(mapping_id, owner=self.owner, sync_id=sync_id, lease_seconds=self.config.sync_lease_seconds, fingerprint=fingerprint)
|
||||||
|
if not acquired:
|
||||||
|
return {"mapping_id": mapping_id, "status": "already_running"}
|
||||||
|
lease_lost = asyncio.Event()
|
||||||
|
|
||||||
|
async def _lease_heartbeat() -> None:
|
||||||
|
interval = max(5.0, min(60.0, self.config.sync_lease_seconds / 3))
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
try:
|
||||||
|
renewed = await self.store.renew_sync_lease(
|
||||||
|
mapping_id,
|
||||||
|
owner=self.owner,
|
||||||
|
lease_seconds=self.config.sync_lease_seconds,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
renewed = False
|
||||||
|
logger.exception("Local Wiki sync lease heartbeat failed mapping_id=%s", mapping_id)
|
||||||
|
if not renewed:
|
||||||
|
lease_lost.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
heartbeat = asyncio.create_task(_lease_heartbeat())
|
||||||
|
|
||||||
|
def _ensure_lease() -> None:
|
||||||
|
if lease_lost.is_set():
|
||||||
|
raise WikiSyncLeaseLost("Local Wiki sync lease was lost")
|
||||||
|
|
||||||
|
remote_count = 0
|
||||||
|
changed = 0
|
||||||
|
failed = 0
|
||||||
|
try:
|
||||||
|
remote_id = str(mapping["weknora_id"])
|
||||||
|
await self.client.get_knowledge_base(remote_id)
|
||||||
|
page_number = 1
|
||||||
|
while True:
|
||||||
|
batch = await self.client.list_wiki_pages(remote_id, page=page_number, page_size=self.config.sync_page_size)
|
||||||
|
pages = [page for page in batch.get("pages", []) if isinstance(page, dict)]
|
||||||
|
remote_count += len(pages)
|
||||||
|
for summary in pages:
|
||||||
|
try:
|
||||||
|
_ensure_lease()
|
||||||
|
slug = str(summary.get("slug") or summary.get("wiki_slug") or summary.get("path") or "")
|
||||||
|
remote_page = await self.client.get_wiki_page(remote_id, slug)
|
||||||
|
page = normalize_wiki_page(mapping_id, {**summary, **remote_page})
|
||||||
|
existing = await self.store.get_page_by_slug_hash(mapping_id, page["slug_hash"])
|
||||||
|
active_fingerprint = self.embedding.fingerprint
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and existing
|
||||||
|
and existing.get("content_hash") == page["content_hash"]
|
||||||
|
and existing.get("indexed_fingerprint") == active_fingerprint
|
||||||
|
and existing.get("index_status") == "ready"
|
||||||
|
and not existing.get("is_remote_deleted")
|
||||||
|
):
|
||||||
|
await self.store.touch_page(str(existing["id"]), sync_id)
|
||||||
|
continue
|
||||||
|
sections = (
|
||||||
|
[]
|
||||||
|
if page["status"] == "archived"
|
||||||
|
else chunk_wiki_page(page, max_chars=self.config.chunk_max_chars, overlap=self.config.chunk_overlap_chars, min_chars=self.config.chunk_min_chars, title_prefix_enabled=self.config.title_prefix_enabled)
|
||||||
|
)
|
||||||
|
embeddings = await self.embedding.embed_texts([section.embedding_text for section in sections]) if sections else []
|
||||||
|
fingerprint = self.embedding.fingerprint
|
||||||
|
vectors = []
|
||||||
|
for section, vector in zip(sections, embeddings, strict=True):
|
||||||
|
blob, dimensions = encode_vector(vector, dimensions=self.embedding.dimensions)
|
||||||
|
vectors.append(
|
||||||
|
{"section_index": section.section_index, "heading": section.heading, "section_content": section.content, "content_hash": section.content_hash, "embedding_dimensions": dimensions, "vector_blob": blob}
|
||||||
|
)
|
||||||
|
_ensure_lease()
|
||||||
|
await self.store.replace_page_vectors(page=page, vectors=vectors, fingerprint=fingerprint, sync_id=sync_id)
|
||||||
|
self.cache.invalidate(mapping_id)
|
||||||
|
changed += 1
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, WikiSyncLeaseLost):
|
||||||
|
raise
|
||||||
|
failed += 1
|
||||||
|
try:
|
||||||
|
fallback = normalize_wiki_page(mapping_id, summary)
|
||||||
|
await self.store.record_page_failure(page=fallback, sync_id=sync_id, error=_safe_error(exc))
|
||||||
|
except Exception as record_exc:
|
||||||
|
logger.exception("Could not record local Wiki page failure mapping_id=%s", mapping_id)
|
||||||
|
raise RuntimeError("Could not safely retain a failed Wiki page during the full scan") from record_exc
|
||||||
|
logger.warning("Local Wiki page sync failed mapping_id=%s: %s", mapping_id, _safe_error(exc))
|
||||||
|
_ensure_lease()
|
||||||
|
total_pages = int(batch.get("total_pages") or 1)
|
||||||
|
if page_number >= total_pages:
|
||||||
|
break
|
||||||
|
page_number += 1
|
||||||
|
_ensure_lease()
|
||||||
|
deleted = await self.store.mark_missing_pages_deleted(mapping_id, sync_id)
|
||||||
|
if deleted:
|
||||||
|
self.cache.invalidate(mapping_id)
|
||||||
|
await self.store.finish_sync(
|
||||||
|
mapping_id,
|
||||||
|
owner=self.owner,
|
||||||
|
sync_id=sync_id,
|
||||||
|
success=not (rebuilding and failed),
|
||||||
|
remote_page_count=remote_count,
|
||||||
|
error=f"{failed} page(s) failed" if failed else None,
|
||||||
|
)
|
||||||
|
return {"mapping_id": mapping_id, "status": "partial" if failed else "completed", "remote_pages": remote_count, "changed_pages": changed, "deleted_pages": deleted, "failed_pages": failed}
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self.store.finish_sync(
|
||||||
|
mapping_id,
|
||||||
|
owner=self.owner,
|
||||||
|
sync_id=sync_id,
|
||||||
|
success=False,
|
||||||
|
remote_page_count=remote_count,
|
||||||
|
error="Sync cancelled",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await self.store.finish_sync(mapping_id, owner=self.owner, sync_id=sync_id, success=False, remote_page_count=remote_count, error=_safe_error(exc))
|
||||||
|
logger.warning("Local Wiki sync failed mapping_id=%s: %s", mapping_id, _safe_error(exc))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
heartbeat.cancel()
|
||||||
|
await asyncio.gather(heartbeat, return_exceptions=True)
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
"""Per-knowledge-base LRU snapshots for NumPy exact search."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import decode_vector
|
||||||
|
from deerflow.persistence.llmwiki_index import LlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WikiVectorSnapshot:
|
||||||
|
mapping_id: str
|
||||||
|
index_revision: int
|
||||||
|
embedding_fingerprint: str
|
||||||
|
dimensions: int
|
||||||
|
matrix: np.ndarray
|
||||||
|
rows: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class WikiVectorCache:
|
||||||
|
def __init__(self, store: LlmWikiIndexStore, *, max_knowledge_bases: int = 20, revision_check_seconds: float = 5) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.max_knowledge_bases = max_knowledge_bases
|
||||||
|
self.revision_check_seconds = revision_check_seconds
|
||||||
|
self._snapshots: OrderedDict[str, WikiVectorSnapshot] = OrderedDict()
|
||||||
|
self._checked_at: dict[str, float] = {}
|
||||||
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
def invalidate(self, mapping_id: str) -> None:
|
||||||
|
self._snapshots.pop(mapping_id, None)
|
||||||
|
self._checked_at.pop(mapping_id, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._snapshots.clear()
|
||||||
|
self._checked_at.clear()
|
||||||
|
|
||||||
|
async def get(self, mapping_id: str, fingerprint: str) -> WikiVectorSnapshot:
|
||||||
|
cached = self._snapshots.get(mapping_id)
|
||||||
|
now = monotonic()
|
||||||
|
if cached is not None and cached.embedding_fingerprint == fingerprint and now - self._checked_at.get(mapping_id, 0) < self.revision_check_seconds:
|
||||||
|
self._snapshots.move_to_end(mapping_id)
|
||||||
|
return cached
|
||||||
|
lock = self._locks.setdefault(mapping_id, asyncio.Lock())
|
||||||
|
async with lock:
|
||||||
|
revision, active_fingerprint, state = await self.store.get_index_revision(mapping_id)
|
||||||
|
self._checked_at[mapping_id] = monotonic()
|
||||||
|
cached = self._snapshots.get(mapping_id)
|
||||||
|
if state in {"not_synced", "rebuild_required", "rebuilding", "disabled"} or revision <= 0:
|
||||||
|
raise RuntimeError(f"WIKI_INDEX_NOT_READY:{state}")
|
||||||
|
if active_fingerprint != fingerprint:
|
||||||
|
raise RuntimeError("WIKI_INDEX_FINGERPRINT_MISMATCH")
|
||||||
|
if cached is not None and cached.index_revision == revision and cached.embedding_fingerprint == fingerprint:
|
||||||
|
self._snapshots.move_to_end(mapping_id)
|
||||||
|
return cached
|
||||||
|
raw = await self.store.load_vector_snapshot(mapping_id, fingerprint)
|
||||||
|
snapshot = await asyncio.to_thread(self._decode_snapshot, mapping_id, raw)
|
||||||
|
self._snapshots[mapping_id] = snapshot
|
||||||
|
self._snapshots.move_to_end(mapping_id)
|
||||||
|
while len(self._snapshots) > self.max_knowledge_bases:
|
||||||
|
evicted, _ = self._snapshots.popitem(last=False)
|
||||||
|
self._checked_at.pop(evicted, None)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_snapshot(mapping_id: str, raw: dict[str, Any]) -> WikiVectorSnapshot:
|
||||||
|
rows = list(raw.get("rows") or [])
|
||||||
|
if not rows:
|
||||||
|
return WikiVectorSnapshot(mapping_id, int(raw.get("revision") or 0), str(raw.get("fingerprint") or ""), 0, np.empty((0, 0), dtype=np.float32), [])
|
||||||
|
dimensions = int(rows[0]["embedding_dimensions"])
|
||||||
|
vectors = []
|
||||||
|
valid_rows = []
|
||||||
|
for row in rows:
|
||||||
|
if int(row["embedding_dimensions"]) != dimensions:
|
||||||
|
raise ValueError("Mixed vector dimensions in one Wiki snapshot")
|
||||||
|
vectors.append(decode_vector(row["vector_blob"], dimensions))
|
||||||
|
valid_rows.append(row)
|
||||||
|
matrix = np.ascontiguousarray(np.vstack(vectors), dtype=np.float32)
|
||||||
|
matrix.setflags(write=False)
|
||||||
|
return WikiVectorSnapshot(mapping_id, int(raw.get("revision") or 0), str(raw.get("fingerprint") or ""), dimensions, matrix, valid_rows)
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
"""Validated little-endian normalized float32 Wiki vector codec."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_vector(values, *, dimensions: int | None = None) -> np.ndarray:
|
||||||
|
vector = np.asarray(values, dtype=np.float32)
|
||||||
|
if vector.ndim != 1 or vector.size == 0:
|
||||||
|
raise ValueError("Embedding vector must be a non-empty one-dimensional array")
|
||||||
|
if dimensions is not None and vector.size != dimensions:
|
||||||
|
raise ValueError(f"Embedding dimension mismatch: expected {dimensions}, got {vector.size}")
|
||||||
|
if not np.isfinite(vector).all():
|
||||||
|
raise ValueError("Embedding vector contains NaN or infinity")
|
||||||
|
norm = float(np.linalg.norm(vector))
|
||||||
|
if not np.isfinite(norm) or norm <= 0:
|
||||||
|
raise ValueError("Embedding vector has zero norm")
|
||||||
|
return np.asarray(vector / norm, dtype="<f4")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_vector(values, *, dimensions: int | None = None) -> tuple[bytes, int]:
|
||||||
|
vector = normalize_vector(values, dimensions=dimensions)
|
||||||
|
return vector.tobytes(order="C"), int(vector.size)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_vector(blob: bytes, dimensions: int) -> np.ndarray:
|
||||||
|
if len(blob) != dimensions * 4:
|
||||||
|
raise ValueError(f"Corrupt vector BLOB: expected {dimensions * 4} bytes, got {len(blob)}")
|
||||||
|
vector = np.frombuffer(blob, dtype="<f4")
|
||||||
|
if not np.isfinite(vector).all():
|
||||||
|
raise ValueError("Stored vector contains NaN or infinity")
|
||||||
|
return vector.copy()
|
||||||
+9
-1
@@ -22,7 +22,15 @@ class LlmWikiStore(ABC):
|
|||||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
|
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None: ...
|
async def update_mapping(
|
||||||
|
self,
|
||||||
|
mapping_id: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
wiki_index_enabled: bool | None = None,
|
||||||
|
external_search_enabled: bool | None = None,
|
||||||
|
) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def adopt_conversation_deposit_mapping(
|
async def adopt_conversation_deposit_mapping(
|
||||||
|
|||||||
+18
-6
@@ -33,6 +33,9 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
"published_by": None,
|
"published_by": None,
|
||||||
"reviewed_by": None,
|
"reviewed_by": None,
|
||||||
"published_at": None,
|
"published_at": None,
|
||||||
|
"wiki_index_enabled": True,
|
||||||
|
"external_search_enabled": False,
|
||||||
|
"external_search_updated_at": None,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
@@ -65,7 +68,15 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
return dict(row)
|
return dict(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
|
async def update_mapping(
|
||||||
|
self,
|
||||||
|
mapping_id: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
wiki_index_enabled: bool | None = None,
|
||||||
|
external_search_enabled: bool | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
row = self._rows.get(mapping_id)
|
row = self._rows.get(mapping_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -73,6 +84,11 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
row["name"] = name
|
row["name"] = name
|
||||||
if description is not None:
|
if description is not None:
|
||||||
row["description"] = description
|
row["description"] = description
|
||||||
|
if wiki_index_enabled is not None:
|
||||||
|
row["wiki_index_enabled"] = wiki_index_enabled
|
||||||
|
if external_search_enabled is not None:
|
||||||
|
row["external_search_enabled"] = external_search_enabled
|
||||||
|
row["external_search_updated_at"] = _now()
|
||||||
row["updated_at"] = _now()
|
row["updated_at"] = _now()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
@@ -143,11 +159,7 @@ class MemoryLlmWikiStore(LlmWikiStore):
|
|||||||
|
|
||||||
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
async def get_conversation_deposit_mapping(self) -> dict[str, Any] | None:
|
||||||
for row in self._rows.values():
|
for row in self._rows.values():
|
||||||
if (
|
if row.get("owner_user_id") == "system" and row.get("name") == "对话沉淀" and row.get("publication_status") == "published":
|
||||||
row.get("owner_user_id") == "system"
|
|
||||||
and row.get("name") == "对话沉淀"
|
|
||||||
and row.get("publication_status") == "published"
|
|
||||||
):
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import String, UniqueConstraint
|
from sqlalchemy import Boolean, String, UniqueConstraint, false, true
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
@@ -24,6 +24,9 @@ class LlmWikiKnowledgeBaseRow(Base):
|
|||||||
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
published_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
reviewed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
reviewed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
wiki_index_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=true(), index=True)
|
||||||
|
external_search_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false(), index=True)
|
||||||
|
external_search_updated_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -19,7 +19,7 @@ def _now() -> datetime:
|
|||||||
|
|
||||||
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
|
def _to_dict(row: LlmWikiKnowledgeBaseRow | LlmWikiConversationDepositRow) -> dict[str, Any]:
|
||||||
data = row.to_dict()
|
data = row.to_dict()
|
||||||
for key in ("published_at", "created_at", "updated_at"):
|
for key in ("published_at", "external_search_updated_at", "created_at", "updated_at"):
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
data[key] = value.isoformat()
|
data[key] = value.isoformat()
|
||||||
@@ -82,7 +82,15 @@ class LlmWikiRepository(LlmWikiStore):
|
|||||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
return _to_dict(row) if row else None
|
return _to_dict(row) if row else None
|
||||||
|
|
||||||
async def update_mapping(self, mapping_id: str, *, name: str | None = None, description: str | None = None) -> dict[str, Any] | None:
|
async def update_mapping(
|
||||||
|
self,
|
||||||
|
mapping_id: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
wiki_index_enabled: bool | None = None,
|
||||||
|
external_search_enabled: bool | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
row = await session.get(LlmWikiKnowledgeBaseRow, mapping_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -91,6 +99,11 @@ class LlmWikiRepository(LlmWikiStore):
|
|||||||
row.name = name
|
row.name = name
|
||||||
if description is not None:
|
if description is not None:
|
||||||
row.description = description
|
row.description = description
|
||||||
|
if wiki_index_enabled is not None:
|
||||||
|
row.wiki_index_enabled = wiki_index_enabled
|
||||||
|
if external_search_enabled is not None:
|
||||||
|
row.external_search_enabled = external_search_enabled
|
||||||
|
row.external_search_updated_at = _now()
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _to_dict(row)
|
return _to_dict(row)
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
"""Persistence facade for the DeerFlow-local Wiki vector index."""
|
||||||
|
|
||||||
|
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
|
||||||
|
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
|
||||||
|
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
|
||||||
|
from deerflow.persistence.llmwiki_index.sql import SqlLlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
def make_llmwiki_index_store(session_factory):
|
||||||
|
return MemoryLlmWikiIndexStore() if session_factory is None else SqlLlmWikiIndexStore(session_factory)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LlmWikiIndexStore",
|
||||||
|
"MemoryLlmWikiIndexStore",
|
||||||
|
"SqlLlmWikiIndexStore",
|
||||||
|
"LlmWikiWikiPageRow",
|
||||||
|
"LlmWikiWikiVectorRow",
|
||||||
|
"LlmWikiWikiSyncStateRow",
|
||||||
|
"make_llmwiki_index_store",
|
||||||
|
]
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""Storage contract for local Wiki pages, vectors, revisions and sync leases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class LlmWikiIndexStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def touch_page(self, page_id: str, sync_id: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def replace_page_vectors(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: dict[str, Any],
|
||||||
|
vectors: list[dict[str, Any]],
|
||||||
|
fingerprint: str,
|
||||||
|
sync_id: str,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int: ...
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
"""In-memory local Wiki index store used by tests and DB-less mode."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryLlmWikiIndexStore(LlmWikiIndexStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pages: dict[str, dict[str, Any]] = {}
|
||||||
|
self.vectors: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
self.states: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def _state(self, mapping_id: str) -> dict[str, Any]:
|
||||||
|
return self.states.setdefault(mapping_id, {"knowledge_base_mapping_id": mapping_id, "state": "idle", "index_revision": 0, "embedding_fingerprint": None})
|
||||||
|
|
||||||
|
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None:
|
||||||
|
row = next((p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and p["slug_hash"] == slug_hash), None)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None:
|
||||||
|
row = next((p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and p["slug"] == slug), None)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def touch_page(self, page_id: str, sync_id: str) -> None:
|
||||||
|
if page_id in self.pages:
|
||||||
|
self.pages[page_id]["last_seen_sync_id"] = sync_id
|
||||||
|
self.pages[page_id]["last_error"] = None
|
||||||
|
|
||||||
|
async def replace_page_vectors(self, *, page: dict[str, Any], vectors: list[dict[str, Any]], fingerprint: str, sync_id: str) -> dict[str, Any]:
|
||||||
|
existing = await self.get_page_by_slug_hash(page["knowledge_base_mapping_id"], page["slug_hash"])
|
||||||
|
page_id = existing["id"] if existing else str(uuid4())
|
||||||
|
now = _now().isoformat()
|
||||||
|
row = {
|
||||||
|
**(existing or {}),
|
||||||
|
**page,
|
||||||
|
"id": page_id,
|
||||||
|
"index_status": "ready",
|
||||||
|
"indexed_fingerprint": fingerprint,
|
||||||
|
"indexed_at": now,
|
||||||
|
"last_error": None,
|
||||||
|
"is_remote_deleted": False,
|
||||||
|
"last_seen_sync_id": sync_id,
|
||||||
|
"created_at": (existing or {}).get("created_at", now),
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
self.pages[page_id] = row
|
||||||
|
self.vectors[page_id] = [{**item, "page_id": page_id, "knowledge_base_mapping_id": page["knowledge_base_mapping_id"], "embedding_fingerprint": fingerprint} for item in vectors]
|
||||||
|
state = self._state(page["knowledge_base_mapping_id"])
|
||||||
|
state["index_revision"] += 1
|
||||||
|
state["embedding_fingerprint"] = fingerprint
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None:
|
||||||
|
existing = await self.get_page_by_slug_hash(page["knowledge_base_mapping_id"], page["slug_hash"])
|
||||||
|
if existing:
|
||||||
|
self.pages[existing["id"]].update(
|
||||||
|
{
|
||||||
|
"last_seen_sync_id": sync_id,
|
||||||
|
"last_error": error,
|
||||||
|
# Preserve the last atomically completed generation.
|
||||||
|
"index_status": "ready" if self.vectors.get(existing["id"]) else "failed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
page_id = str(uuid4())
|
||||||
|
self.pages[page_id] = {**page, "id": page_id, "index_status": "failed", "last_error": error, "last_seen_sync_id": sync_id, "is_remote_deleted": False}
|
||||||
|
|
||||||
|
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int:
|
||||||
|
changed = 0
|
||||||
|
for page in self.pages.values():
|
||||||
|
if page["knowledge_base_mapping_id"] == mapping_id and page.get("last_seen_sync_id") != sync_id and not page.get("is_remote_deleted"):
|
||||||
|
page.update({"is_remote_deleted": True, "index_status": "deleted"})
|
||||||
|
self.vectors.pop(page["id"], None)
|
||||||
|
changed += 1
|
||||||
|
if changed:
|
||||||
|
self._state(mapping_id)["index_revision"] += 1
|
||||||
|
return changed
|
||||||
|
|
||||||
|
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]:
|
||||||
|
rows = []
|
||||||
|
for page_id, vectors in self.vectors.items():
|
||||||
|
page = self.pages[page_id]
|
||||||
|
if page["knowledge_base_mapping_id"] != mapping_id or page.get("is_remote_deleted") or page.get("status") not in {"published", "draft"} or page.get("index_status") != "ready":
|
||||||
|
continue
|
||||||
|
rows.extend({**vector, "page": dict(page)} for vector in vectors if vector["embedding_fingerprint"] == fingerprint)
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
return {"revision": state["index_revision"], "fingerprint": fingerprint, "rows": rows}
|
||||||
|
|
||||||
|
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]:
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
return int(state["index_revision"]), state.get("embedding_fingerprint"), str(state.get("state") or "idle")
|
||||||
|
|
||||||
|
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool:
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
now = _now()
|
||||||
|
expires = state.get("lease_expires_at")
|
||||||
|
if state.get("lease_owner") is not None and isinstance(expires, datetime) and expires >= now:
|
||||||
|
return False
|
||||||
|
state.update(
|
||||||
|
{
|
||||||
|
"state": "rebuilding" if state.get("state") == "rebuild_required" else "running",
|
||||||
|
"active_sync_id": sync_id,
|
||||||
|
"lease_owner": owner,
|
||||||
|
"lease_expires_at": now + timedelta(seconds=lease_seconds),
|
||||||
|
"last_started_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool:
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
if state.get("lease_owner") != owner:
|
||||||
|
return False
|
||||||
|
state["lease_expires_at"] = _now() + timedelta(seconds=lease_seconds)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None:
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
if state.get("lease_owner") != owner or state.get("active_sync_id") != sync_id:
|
||||||
|
return
|
||||||
|
pages = [p for p in self.pages.values() if p["knowledge_base_mapping_id"] == mapping_id and not p.get("is_remote_deleted")]
|
||||||
|
was_rebuilding = state.get("state") == "rebuilding"
|
||||||
|
state.update(
|
||||||
|
{
|
||||||
|
"state": "partial" if success and error else "idle" if success else "rebuild_required" if was_rebuilding else "failed",
|
||||||
|
"lease_owner": None,
|
||||||
|
"lease_expires_at": None,
|
||||||
|
"active_sync_id": None,
|
||||||
|
"last_error": error,
|
||||||
|
"remote_page_count": remote_page_count,
|
||||||
|
"local_page_count": len(pages),
|
||||||
|
"ready_page_count": sum(p.get("index_status") == "ready" for p in pages),
|
||||||
|
"failed_page_count": sum(p.get("index_status") in {"failed", "stale"} or p.get("last_error") is not None for p in pages),
|
||||||
|
"vector_count": sum(len(self.vectors.get(p["id"], [])) for p in pages),
|
||||||
|
"last_completed_at": _now() if success and not error else state.get("last_completed_at"),
|
||||||
|
"last_success_at": _now() if success else state.get("last_success_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]:
|
||||||
|
wanted = set(mapping_ids or [])
|
||||||
|
return [dict(value) for key, value in self.states.items() if not wanted or key in wanted]
|
||||||
|
|
||||||
|
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int:
|
||||||
|
for mapping_id in mapping_ids:
|
||||||
|
state = self._state(mapping_id)
|
||||||
|
state.update({"state": "rebuild_required", "embedding_fingerprint": fingerprint})
|
||||||
|
for page in self.pages.values():
|
||||||
|
if page["knowledge_base_mapping_id"] == mapping_id and not page.get("is_remote_deleted"):
|
||||||
|
page["index_status"] = "stale"
|
||||||
|
return len(mapping_ids)
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"""Portable ORM rows for the DeerFlow-local WeKnora Wiki mirror/index."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, false
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmWikiWikiPageRow(Base):
|
||||||
|
__tablename__ = "llmwiki_wiki_pages"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("knowledge_base_mapping_id", "slug_hash", name="uq_llmwiki_wiki_page_mapping_slug_hash"),
|
||||||
|
Index("ix_llmwiki_wiki_page_mapping_visibility", "knowledge_base_mapping_id", "status", "index_status", "is_remote_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
remote_page_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
slug_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(512), nullable=False, default="", index=True)
|
||||||
|
summary: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="")
|
||||||
|
content_md: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="")
|
||||||
|
page_type: Mapped[str] = mapped_column(String(32), nullable=False, default="article", index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="published", index=True)
|
||||||
|
aliases: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||||
|
category_path: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||||
|
parent_slug: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
wiki_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
source_refs: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||||
|
chunk_refs: Mapped[list] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||||
|
page_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
remote_created_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
remote_updated_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True, index=True)
|
||||||
|
index_status: Mapped[str] = mapped_column(String(24), nullable=False, default="pending", index=True)
|
||||||
|
indexed_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
indexed_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_remote_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=false(), index=True)
|
||||||
|
last_seen_sync_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, onupdate=_now, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmWikiWikiVectorRow(Base):
|
||||||
|
__tablename__ = "llmwiki_wiki_vectors"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("page_id", "section_index", "embedding_fingerprint", name="uq_llmwiki_wiki_vector_page_section_fingerprint"),
|
||||||
|
Index("ix_llmwiki_wiki_vector_snapshot", "knowledge_base_mapping_id", "embedding_fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
page_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_wiki_pages.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
section_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
heading: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
section_content: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
embedding_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
embedding_dimensions: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
vector_blob: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class LlmWikiWikiSyncStateRow(Base):
|
||||||
|
__tablename__ = "llmwiki_wiki_sync_states"
|
||||||
|
|
||||||
|
knowledge_base_mapping_id: Mapped[str] = mapped_column(String(36), ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(24), nullable=False, default="idle")
|
||||||
|
active_sync_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
lease_expires_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
last_started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
last_completed_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
last_success_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
remote_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
local_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
ready_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
vector_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
failed_page_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
index_revision: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
embedding_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, onupdate=_now)
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
"""SQLAlchemy implementation of the local Wiki index store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import case, delete, func, or_, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.llmwiki_index.base import LlmWikiIndexStore
|
||||||
|
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _dict(row) -> dict[str, Any]:
|
||||||
|
value = row.to_dict()
|
||||||
|
for key, item in tuple(value.items()):
|
||||||
|
if isinstance(item, datetime):
|
||||||
|
value[key] = item.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SqlLlmWikiIndexStore(LlmWikiIndexStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def _ensure_state(self, session: AsyncSession, mapping_id: str) -> LlmWikiWikiSyncStateRow:
|
||||||
|
row = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
|
||||||
|
if row is None:
|
||||||
|
row = LlmWikiWikiSyncStateRow(knowledge_base_mapping_id=mapping_id, state="idle", index_revision=0, updated_at=_now())
|
||||||
|
session.add(row)
|
||||||
|
await session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def get_page_by_slug_hash(self, mapping_id: str, slug_hash: str) -> dict[str, Any] | None:
|
||||||
|
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == slug_hash)
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _dict(row) if row else None
|
||||||
|
|
||||||
|
async def get_page(self, mapping_id: str, slug: str) -> dict[str, Any] | None:
|
||||||
|
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug == slug)
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _dict(row) if row else None
|
||||||
|
|
||||||
|
async def touch_page(self, page_id: str, sync_id: str) -> None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(LlmWikiWikiPageRow)
|
||||||
|
.where(LlmWikiWikiPageRow.id == page_id)
|
||||||
|
.values(last_seen_sync_id=sync_id, last_error=None, updated_at=_now())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def replace_page_vectors(self, *, page: dict[str, Any], vectors: list[dict[str, Any]], fingerprint: str, sync_id: str) -> dict[str, Any]:
|
||||||
|
mapping_id = str(page["knowledge_base_mapping_id"])
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == page["slug_hash"]).with_for_update()
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is not None and row.slug != page["slug"]:
|
||||||
|
raise ValueError("Wiki slug hash collision detected")
|
||||||
|
now = _now()
|
||||||
|
if row is None:
|
||||||
|
row = LlmWikiWikiPageRow(id=str(uuid4()), created_at=now, **page)
|
||||||
|
session.add(row)
|
||||||
|
else:
|
||||||
|
for key, value in page.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
row.index_status = "ready"
|
||||||
|
row.indexed_fingerprint = fingerprint
|
||||||
|
row.indexed_at = now
|
||||||
|
row.last_error = None
|
||||||
|
row.is_remote_deleted = False
|
||||||
|
row.last_seen_sync_id = sync_id
|
||||||
|
row.updated_at = now
|
||||||
|
await session.flush()
|
||||||
|
await session.execute(delete(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id == row.id))
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
LlmWikiWikiVectorRow(
|
||||||
|
id=str(uuid4()),
|
||||||
|
page_id=row.id,
|
||||||
|
knowledge_base_mapping_id=mapping_id,
|
||||||
|
section_index=int(item["section_index"]),
|
||||||
|
heading=item.get("heading"),
|
||||||
|
section_content=str(item["section_content"]),
|
||||||
|
content_hash=str(item["content_hash"]),
|
||||||
|
embedding_fingerprint=fingerprint,
|
||||||
|
embedding_dimensions=int(item["embedding_dimensions"]),
|
||||||
|
vector_blob=bytes(item["vector_blob"]),
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
for item in vectors
|
||||||
|
]
|
||||||
|
)
|
||||||
|
state = await self._ensure_state(session, mapping_id)
|
||||||
|
state.index_revision = int(state.index_revision or 0) + 1
|
||||||
|
state.embedding_fingerprint = fingerprint
|
||||||
|
state.updated_at = now
|
||||||
|
await session.commit()
|
||||||
|
return _dict(row)
|
||||||
|
|
||||||
|
async def record_page_failure(self, *, page: dict[str, Any], sync_id: str, error: str) -> None:
|
||||||
|
mapping_id = str(page["knowledge_base_mapping_id"])
|
||||||
|
safe_error = error[:4000]
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.slug_hash == page["slug_hash"])
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
now = _now()
|
||||||
|
if row is None:
|
||||||
|
row = LlmWikiWikiPageRow(id=str(uuid4()), created_at=now, **page)
|
||||||
|
session.add(row)
|
||||||
|
row.index_status = "failed"
|
||||||
|
else:
|
||||||
|
vector_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id == row.id))).scalar_one())
|
||||||
|
# Preserve the last atomically completed generation while the
|
||||||
|
# newer remote version remains pending after an embedding error.
|
||||||
|
row.index_status = "ready" if vector_count else "failed"
|
||||||
|
row.last_seen_sync_id = sync_id
|
||||||
|
row.last_error = safe_error
|
||||||
|
row.updated_at = now
|
||||||
|
await self._ensure_state(session, mapping_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def mark_missing_pages_deleted(self, mapping_id: str, sync_id: str) -> int:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = (
|
||||||
|
select(LlmWikiWikiPageRow)
|
||||||
|
.where(
|
||||||
|
LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id,
|
||||||
|
LlmWikiWikiPageRow.is_remote_deleted.is_(False),
|
||||||
|
or_(LlmWikiWikiPageRow.last_seen_sync_id.is_(None), LlmWikiWikiPageRow.last_seen_sync_id != sync_id),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
rows = list((await session.execute(stmt)).scalars())
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
now = _now()
|
||||||
|
page_ids = [row.id for row in rows]
|
||||||
|
await session.execute(delete(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.page_id.in_(page_ids)))
|
||||||
|
for row in rows:
|
||||||
|
row.is_remote_deleted = True
|
||||||
|
row.index_status = "deleted"
|
||||||
|
row.updated_at = now
|
||||||
|
state = await self._ensure_state(session, mapping_id)
|
||||||
|
state.index_revision = int(state.index_revision or 0) + 1
|
||||||
|
state.updated_at = now
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def load_vector_snapshot(self, mapping_id: str, fingerprint: str) -> dict[str, Any]:
|
||||||
|
stmt = (
|
||||||
|
select(LlmWikiWikiVectorRow, LlmWikiWikiPageRow)
|
||||||
|
.join(LlmWikiWikiPageRow, LlmWikiWikiPageRow.id == LlmWikiWikiVectorRow.page_id)
|
||||||
|
.where(
|
||||||
|
LlmWikiWikiVectorRow.knowledge_base_mapping_id == mapping_id,
|
||||||
|
LlmWikiWikiVectorRow.embedding_fingerprint == fingerprint,
|
||||||
|
LlmWikiWikiPageRow.index_status == "ready",
|
||||||
|
LlmWikiWikiPageRow.is_remote_deleted.is_(False),
|
||||||
|
LlmWikiWikiPageRow.status.in_(["published", "draft"]),
|
||||||
|
)
|
||||||
|
.order_by(LlmWikiWikiVectorRow.page_id, LlmWikiWikiVectorRow.section_index)
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
|
||||||
|
pairs = list((await session.execute(stmt)).all())
|
||||||
|
rows = [{**_dict(vector), "page": _dict(page)} for vector, page in pairs]
|
||||||
|
return {"revision": int(state.index_revision if state else 0), "fingerprint": fingerprint, "rows": rows}
|
||||||
|
|
||||||
|
async def get_index_revision(self, mapping_id: str) -> tuple[int, str | None, str]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
|
||||||
|
if state is None:
|
||||||
|
return 0, None, "not_synced"
|
||||||
|
return int(state.index_revision or 0), state.embedding_fingerprint, state.state
|
||||||
|
|
||||||
|
async def try_acquire_sync_lease(self, mapping_id: str, *, owner: str, sync_id: str, lease_seconds: int, fingerprint: str) -> bool:
|
||||||
|
now = _now()
|
||||||
|
async with self._sf() as session:
|
||||||
|
try:
|
||||||
|
await self._ensure_state(session, mapping_id)
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# Another worker created the one-per-KB state row between our
|
||||||
|
# read and insert. Roll back this isolated bootstrap session;
|
||||||
|
# the conditional UPDATE below is the actual lease election.
|
||||||
|
await session.rollback()
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = (
|
||||||
|
update(LlmWikiWikiSyncStateRow)
|
||||||
|
.where(
|
||||||
|
LlmWikiWikiSyncStateRow.knowledge_base_mapping_id == mapping_id,
|
||||||
|
or_(
|
||||||
|
LlmWikiWikiSyncStateRow.lease_owner.is_(None),
|
||||||
|
LlmWikiWikiSyncStateRow.lease_expires_at.is_(None),
|
||||||
|
LlmWikiWikiSyncStateRow.lease_expires_at < now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
state=case((LlmWikiWikiSyncStateRow.state == "rebuild_required", "rebuilding"), else_="running"),
|
||||||
|
active_sync_id=sync_id,
|
||||||
|
lease_owner=owner,
|
||||||
|
lease_expires_at=now + timedelta(seconds=lease_seconds),
|
||||||
|
last_started_at=now,
|
||||||
|
last_error=None,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return bool(result.rowcount)
|
||||||
|
|
||||||
|
async def renew_sync_lease(self, mapping_id: str, *, owner: str, lease_seconds: int) -> bool:
|
||||||
|
now = _now()
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(LlmWikiWikiSyncStateRow)
|
||||||
|
.where(LlmWikiWikiSyncStateRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiSyncStateRow.lease_owner == owner)
|
||||||
|
.values(lease_expires_at=now + timedelta(seconds=lease_seconds), updated_at=now)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return bool(result.rowcount)
|
||||||
|
|
||||||
|
async def finish_sync(self, mapping_id: str, *, owner: str, sync_id: str, success: bool, remote_page_count: int, error: str | None = None) -> None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
state = await session.get(LlmWikiWikiSyncStateRow, mapping_id)
|
||||||
|
if state is None or state.lease_owner != owner or state.active_sync_id != sync_id:
|
||||||
|
return
|
||||||
|
visible = [LlmWikiWikiPageRow.knowledge_base_mapping_id == mapping_id, LlmWikiWikiPageRow.is_remote_deleted.is_(False)]
|
||||||
|
local_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiPageRow).where(*visible))).scalar_one())
|
||||||
|
ready_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiPageRow).where(*visible, LlmWikiWikiPageRow.index_status == "ready"))).scalar_one())
|
||||||
|
failed_count = int(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(LlmWikiWikiPageRow)
|
||||||
|
.where(
|
||||||
|
*visible,
|
||||||
|
or_(
|
||||||
|
LlmWikiWikiPageRow.index_status.in_(["failed", "stale"]),
|
||||||
|
LlmWikiWikiPageRow.last_error.is_not(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
vector_count = int((await session.execute(select(func.count()).select_from(LlmWikiWikiVectorRow).where(LlmWikiWikiVectorRow.knowledge_base_mapping_id == mapping_id))).scalar_one())
|
||||||
|
now = _now()
|
||||||
|
was_rebuilding = state.state == "rebuilding"
|
||||||
|
state.state = "partial" if success and error else "idle" if success else "rebuild_required" if was_rebuilding else "failed"
|
||||||
|
state.active_sync_id = None
|
||||||
|
state.lease_owner = None
|
||||||
|
state.lease_expires_at = None
|
||||||
|
state.last_error = (error or "")[:4000] or None
|
||||||
|
state.remote_page_count = remote_page_count
|
||||||
|
state.local_page_count = local_count
|
||||||
|
state.ready_page_count = ready_count
|
||||||
|
state.failed_page_count = failed_count
|
||||||
|
state.vector_count = vector_count
|
||||||
|
if success and not error:
|
||||||
|
state.last_completed_at = now
|
||||||
|
if success:
|
||||||
|
state.last_success_at = now
|
||||||
|
state.updated_at = now
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def list_index_status(self, mapping_ids: list[str] | None = None) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(LlmWikiWikiSyncStateRow)
|
||||||
|
if mapping_ids:
|
||||||
|
stmt = stmt.where(LlmWikiWikiSyncStateRow.knowledge_base_mapping_id.in_(mapping_ids))
|
||||||
|
stmt = stmt.order_by(LlmWikiWikiSyncStateRow.updated_at.desc())
|
||||||
|
async with self._sf() as session:
|
||||||
|
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def mark_rebuild_required(self, mapping_ids: list[str], fingerprint: str) -> int:
|
||||||
|
if not mapping_ids:
|
||||||
|
return 0
|
||||||
|
now = _now()
|
||||||
|
async with self._sf() as session:
|
||||||
|
for mapping_id in mapping_ids:
|
||||||
|
state = await self._ensure_state(session, mapping_id)
|
||||||
|
state.state = "rebuild_required"
|
||||||
|
state.embedding_fingerprint = fingerprint
|
||||||
|
state.updated_at = now
|
||||||
|
await session.execute(update(LlmWikiWikiPageRow).where(LlmWikiWikiPageRow.knowledge_base_mapping_id.in_(mapping_ids), LlmWikiWikiPageRow.is_remote_deleted.is_(False)).values(index_status="stale", updated_at=now))
|
||||||
|
await session.commit()
|
||||||
|
return len(mapping_ids)
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
"""Add DeerFlow-local WeKnora Wiki mirror and vector index.
|
||||||
|
|
||||||
|
Revision ID: 20260823_01
|
||||||
|
Revises: 20260821_02
|
||||||
|
Create Date: 2026-08-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = "20260823_01"
|
||||||
|
down_revision = "20260821_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("llmwiki_knowledge_bases")}
|
||||||
|
if "wiki_index_enabled" not in columns:
|
||||||
|
op.add_column("llmwiki_knowledge_bases", sa.Column("wiki_index_enabled", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
|
if "external_search_enabled" not in columns:
|
||||||
|
op.add_column("llmwiki_knowledge_bases", sa.Column("external_search_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
if "external_search_updated_at" not in columns:
|
||||||
|
op.add_column("llmwiki_knowledge_bases", sa.Column("external_search_updated_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
mapping_indexes = {index["name"] for index in inspector.get_indexes("llmwiki_knowledge_bases")}
|
||||||
|
if "ix_llmwiki_knowledge_bases_wiki_index_enabled" not in mapping_indexes:
|
||||||
|
op.create_index("ix_llmwiki_knowledge_bases_wiki_index_enabled", "llmwiki_knowledge_bases", ["wiki_index_enabled"])
|
||||||
|
if "ix_llmwiki_knowledge_bases_external_search_enabled" not in mapping_indexes:
|
||||||
|
op.create_index("ix_llmwiki_knowledge_bases_external_search_enabled", "llmwiki_knowledge_bases", ["external_search_enabled"])
|
||||||
|
|
||||||
|
long_text = sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
|
||||||
|
portable_json = sa.JSON().with_variant(sa.Text(), "mysql")
|
||||||
|
if not inspector.has_table("llmwiki_wiki_pages"):
|
||||||
|
op.create_table(
|
||||||
|
"llmwiki_wiki_pages",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("knowledge_base_mapping_id", sa.String(36), sa.ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("remote_page_id", sa.String(128), nullable=True),
|
||||||
|
sa.Column("slug", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("slug_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("title", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("summary", long_text, nullable=False),
|
||||||
|
sa.Column("content_md", long_text, nullable=False),
|
||||||
|
sa.Column("page_type", sa.String(32), nullable=False, server_default="article"),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="published"),
|
||||||
|
sa.Column("aliases", portable_json, nullable=False),
|
||||||
|
sa.Column("category_path", portable_json, nullable=False),
|
||||||
|
sa.Column("parent_slug", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("wiki_path", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("source_refs", portable_json, nullable=False),
|
||||||
|
sa.Column("chunk_refs", portable_json, nullable=False),
|
||||||
|
sa.Column("page_version", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("remote_created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("remote_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("index_status", sa.String(24), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("indexed_fingerprint", sa.String(64), nullable=True),
|
||||||
|
sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_remote_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("last_seen_sync_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("knowledge_base_mapping_id", "slug_hash", name="uq_llmwiki_wiki_page_mapping_slug_hash"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_knowledge_base_mapping_id", "llmwiki_wiki_pages", ["knowledge_base_mapping_id"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_page_mapping_visibility", "llmwiki_wiki_pages", ["knowledge_base_mapping_id", "status", "index_status", "is_remote_deleted"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_remote_page_id", "llmwiki_wiki_pages", ["remote_page_id"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_title", "llmwiki_wiki_pages", ["title"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_page_type", "llmwiki_wiki_pages", ["page_type"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_status", "llmwiki_wiki_pages", ["status"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_page_version", "llmwiki_wiki_pages", ["page_version"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_content_hash", "llmwiki_wiki_pages", ["content_hash"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_remote_updated_at", "llmwiki_wiki_pages", ["remote_updated_at"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_index_status", "llmwiki_wiki_pages", ["index_status"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_is_remote_deleted", "llmwiki_wiki_pages", ["is_remote_deleted"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_last_seen_sync_id", "llmwiki_wiki_pages", ["last_seen_sync_id"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_pages_updated_at", "llmwiki_wiki_pages", ["updated_at"])
|
||||||
|
|
||||||
|
if not inspector.has_table("llmwiki_wiki_vectors"):
|
||||||
|
op.create_table(
|
||||||
|
"llmwiki_wiki_vectors",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("page_id", sa.String(36), sa.ForeignKey("llmwiki_wiki_pages.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("knowledge_base_mapping_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("section_index", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("heading", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("section_content", long_text, nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("embedding_fingerprint", sa.String(64), nullable=False),
|
||||||
|
sa.Column("embedding_dimensions", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("vector_blob", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("page_id", "section_index", "embedding_fingerprint", name="uq_llmwiki_wiki_vector_page_section_fingerprint"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_llmwiki_wiki_vector_snapshot", "llmwiki_wiki_vectors", ["knowledge_base_mapping_id", "embedding_fingerprint"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_vectors_page_id", "llmwiki_wiki_vectors", ["page_id"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_vectors_knowledge_base_mapping_id", "llmwiki_wiki_vectors", ["knowledge_base_mapping_id"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_vectors_content_hash", "llmwiki_wiki_vectors", ["content_hash"])
|
||||||
|
op.create_index("ix_llmwiki_wiki_vectors_embedding_fingerprint", "llmwiki_wiki_vectors", ["embedding_fingerprint"])
|
||||||
|
|
||||||
|
if not inspector.has_table("llmwiki_wiki_sync_states"):
|
||||||
|
op.create_table(
|
||||||
|
"llmwiki_wiki_sync_states",
|
||||||
|
sa.Column("knowledge_base_mapping_id", sa.String(36), sa.ForeignKey("llmwiki_knowledge_bases.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("state", sa.String(24), nullable=False, server_default="idle"),
|
||||||
|
sa.Column("active_sync_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("lease_owner", sa.String(128), nullable=True),
|
||||||
|
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("remote_page_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("local_page_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("ready_page_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("vector_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("failed_page_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("index_revision", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("embedding_fingerprint", sa.String(64), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
for table in ("llmwiki_wiki_vectors", "llmwiki_wiki_pages", "llmwiki_wiki_sync_states"):
|
||||||
|
if inspector.has_table(table):
|
||||||
|
op.drop_table(table)
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("llmwiki_knowledge_bases")}
|
||||||
|
indexes = {index["name"] for index in inspector.get_indexes("llmwiki_knowledge_bases")}
|
||||||
|
for index in ("ix_llmwiki_knowledge_bases_external_search_enabled", "ix_llmwiki_knowledge_bases_wiki_index_enabled"):
|
||||||
|
if index in indexes:
|
||||||
|
op.drop_index(index, table_name="llmwiki_knowledge_bases")
|
||||||
|
for column in ("external_search_updated_at", "external_search_enabled", "wiki_index_enabled"):
|
||||||
|
if column in columns:
|
||||||
|
op.drop_column("llmwiki_knowledge_bases", column)
|
||||||
+4
@@ -52,6 +52,7 @@ from deerflow.persistence.intent_turns.model import IntentTurnRow
|
|||||||
from deerflow.persistence.light_apps.model import LightAppRow
|
from deerflow.persistence.light_apps.model import LightAppRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
||||||
|
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
|
||||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
@@ -129,6 +130,9 @@ __all__ = [
|
|||||||
"LlmCallMetricRow",
|
"LlmCallMetricRow",
|
||||||
"LlmWikiKnowledgeBaseRow",
|
"LlmWikiKnowledgeBaseRow",
|
||||||
"LlmWikiConversationDepositRow",
|
"LlmWikiConversationDepositRow",
|
||||||
|
"LlmWikiWikiPageRow",
|
||||||
|
"LlmWikiWikiVectorRow",
|
||||||
|
"LlmWikiWikiSyncStateRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
|
|||||||
+23
-51
@@ -6,28 +6,25 @@ import json
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from langchain.tools import ToolRuntime, tool
|
from langchain.tools import ToolRuntime, tool
|
||||||
from langgraph.typing import ContextT
|
from langgraph.typing import ContextT
|
||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
from deerflow.config import get_app_config
|
||||||
from deerflow.config.agents_config import load_agent_config
|
from deerflow.config.agents_config import load_agent_config
|
||||||
from deerflow.integrations.weknora.client import WeKnoraError
|
from deerflow.integrations.weknora.local_index.runtime import get_local_wiki_retrieval_service
|
||||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_llmwiki_store, get_resolved_llmwiki_runtime
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||||
|
from deerflow.integrations.weknora.runtime import get_llmwiki_store, get_resolved_llmwiki_runtime
|
||||||
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
|
from deerflow.runtime.user_context import get_current_user, get_effective_user_id
|
||||||
|
|
||||||
|
|
||||||
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
_CONVERSATION_DEPOSIT_NAME = "对话沉淀"
|
||||||
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
_CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
def _is_conversation_deposit_mapping(row: Mapping[str, Any]) -> bool:
|
||||||
return (
|
return str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
|
||||||
str(row.get("owner_user_id") or "") == _CONVERSATION_DEPOSIT_OWNER_USER_ID
|
|
||||||
and str(row.get("name") or "").strip() == _CONVERSATION_DEPOSIT_NAME
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||||||
@@ -79,9 +76,10 @@ async def llmwiki_search_tool(
|
|||||||
query: Focused natural-language retrieval query.
|
query: Focused natural-language retrieval query.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
local_config = get_app_config().llmwiki.local_wiki_index
|
||||||
resolved = get_resolved_llmwiki_runtime()
|
resolved = get_resolved_llmwiki_runtime()
|
||||||
if not resolved.weknora_enabled:
|
if not (local_config.enabled and local_config.internal_search_enabled) and not resolved.weknora_enabled:
|
||||||
return "LLMWiki retrieval is unavailable because WeKnora mode is not enabled."
|
return "LLMWiki retrieval is unavailable because no retrieval provider is enabled."
|
||||||
|
|
||||||
store = get_llmwiki_store()
|
store = get_llmwiki_store()
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
@@ -111,52 +109,26 @@ async def llmwiki_search_tool(
|
|||||||
return "No authorized LLMWiki knowledge bases are available."
|
return "No authorized LLMWiki knowledge bases are available."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await build_weknora_client(resolved).search(
|
payload = await get_local_wiki_retrieval_service().search(query.strip(), rows)
|
||||||
query.strip(),
|
except (RuntimeError, WikiIndexError) as exc:
|
||||||
[str(row["weknora_id"]) for row in rows],
|
code = exc.code if isinstance(exc, WikiIndexError) else "WIKI_RETRIEVAL_UNAVAILABLE"
|
||||||
)
|
return f"LLMWiki Wiki-only retrieval failed ({code}); retrieval is unavailable, not empty."
|
||||||
except (RuntimeError, WeKnoraError) as exc:
|
mapping_by_id = {str(row["id"]): row for row in rows}
|
||||||
return f"LLMWiki retrieval failed: {exc}"
|
results = []
|
||||||
|
for item in payload["results"]:
|
||||||
local_by_remote = {str(row["weknora_id"]): row for row in rows}
|
mapping = mapping_by_id.get(str(item.get("knowledge_base_id") or ""))
|
||||||
citations = []
|
visible = _sanitize_conversation_deposit_value(item) if mapping and _is_conversation_deposit_mapping(mapping) else item
|
||||||
for result in results:
|
results.append(visible)
|
||||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
|
||||||
if mapping is None:
|
|
||||||
continue
|
|
||||||
visible_result = (
|
|
||||||
_sanitize_conversation_deposit_value(result)
|
|
||||||
if _is_conversation_deposit_mapping(mapping)
|
|
||||||
else result
|
|
||||||
)
|
|
||||||
params = urlencode(
|
|
||||||
{
|
|
||||||
"knowledge_base_id": mapping["id"],
|
|
||||||
"chunk_id": visible_result.get("chunk_id") or "",
|
|
||||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
|
||||||
"document_id": visible_result.get("knowledge_id") or "",
|
|
||||||
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
citations.append(
|
|
||||||
{
|
|
||||||
"title": visible_result.get("title") or visible_result.get("filename") or mapping.get("name") or "LLMWiki source",
|
|
||||||
"content": visible_result.get("content") or "",
|
|
||||||
"score": visible_result.get("score") or 0,
|
|
||||||
"knowledge_base_id": mapping["id"],
|
|
||||||
"knowledge_base_name": mapping.get("name") or "",
|
|
||||||
"source": mapping.get("name") or "LLMWiki",
|
|
||||||
"knowledge_id": visible_result.get("knowledge_id") or "",
|
|
||||||
"chunk_id": visible_result.get("chunk_id") or "",
|
|
||||||
"url": f"/weknora-source-preview?{params}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"query": query,
|
"query": query,
|
||||||
"selection": [row["id"] for row in rows],
|
"selection": [row["id"] for row in rows],
|
||||||
"knowledge_base_count": len(rows),
|
"knowledge_base_count": len(rows),
|
||||||
"results": citations,
|
"mode": payload.get("mode") or "wiki_api",
|
||||||
|
"results": results,
|
||||||
|
"partial": payload.get("partial", False),
|
||||||
|
"warnings": payload.get("warnings", []),
|
||||||
|
"fallback_knowledge_base_ids": payload.get("fallback_knowledge_base_ids", []),
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
@@ -21,7 +21,8 @@ dependencies = [
|
|||||||
"langgraph-cli>=0.4.24",
|
"langgraph-cli>=0.4.24",
|
||||||
"langgraph-runtime-inmem>=0.28.0",
|
"langgraph-runtime-inmem>=0.28.0",
|
||||||
"markdownify>=1.2.2",
|
"markdownify>=1.2.2",
|
||||||
"markitdown[all,xlsx]>=0.0.1a2",
|
"markitdown[all,xlsx]>=0.0.1a2",
|
||||||
|
"numpy>=2.0.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"readabilipy>=0.3.0",
|
"readabilipy>=0.3.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dependencies = [
|
|||||||
"croniter>=6.2.2",
|
"croniter>=6.2.2",
|
||||||
"hindsight-client>=0.4.22",
|
"hindsight-client>=0.4.22",
|
||||||
"asyncmy>=0.2.11",
|
"asyncmy>=0.2.11",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.gateway.auth_middleware import _authorize_external_llmwiki
|
||||||
|
from app.gateway.routers.external_llmwiki import _external_mappings
|
||||||
|
|
||||||
|
|
||||||
|
def _request(key: str) -> Request:
|
||||||
|
return Request({"type": "http", "method": "POST", "path": "/api/external/llmwiki/wiki/vector-search", "headers": [(b"x-api-key", key.encode())]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_api_key_uses_isolated_fail_closed_auth(monkeypatch) -> None:
|
||||||
|
config = SimpleNamespace(enabled=True, api_key="current", previous_api_key="previous")
|
||||||
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: SimpleNamespace(llmwiki=SimpleNamespace(local_wiki_index=SimpleNamespace(external_api=config))))
|
||||||
|
assert _authorize_external_llmwiki(_request("current")) is None
|
||||||
|
assert _authorize_external_llmwiki(_request("previous")) is None
|
||||||
|
rejected = _authorize_external_llmwiki(_request("wrong"))
|
||||||
|
assert rejected is not None
|
||||||
|
assert rejected.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_external_mappings_require_explicit_public_external_permission() -> None:
|
||||||
|
rows = [
|
||||||
|
{"id": "allowed", "publication_status": "published", "external_search_enabled": True, "wiki_index_enabled": True},
|
||||||
|
{"id": "private", "publication_status": "private", "external_search_enabled": True, "wiki_index_enabled": True},
|
||||||
|
{"id": "not-opted-in", "publication_status": "published", "external_search_enabled": False, "wiki_index_enabled": True},
|
||||||
|
{
|
||||||
|
"id": "deposit",
|
||||||
|
"name": "对话沉淀",
|
||||||
|
"owner_user_id": "system",
|
||||||
|
"publication_status": "published",
|
||||||
|
"external_search_enabled": True,
|
||||||
|
"wiki_index_enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
async def list_visible(self, *_args, **_kwargs):
|
||||||
|
return rows
|
||||||
|
|
||||||
|
request = Request({"type": "http", "method": "POST", "path": "/", "headers": [], "app": SimpleNamespace(state=SimpleNamespace(llmwiki_store=Store()))})
|
||||||
|
assert [row["id"] for row in await _external_mappings(request, [])] == ["allowed"]
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await _external_mappings(request, ["not-opted-in"])
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import decode_vector, encode_vector
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.llmwiki.model import LlmWikiKnowledgeBaseRow
|
||||||
|
from deerflow.persistence.llmwiki_index.model import LlmWikiWikiPageRow, LlmWikiWikiSyncStateRow, LlmWikiWikiVectorRow
|
||||||
|
from deerflow.persistence.llmwiki_index.sql import SqlLlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sql_store_atomically_replaces_vectors_and_marks_missing_deleted(tmp_path) -> None:
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'wiki.db'}")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(
|
||||||
|
lambda sync_connection: Base.metadata.create_all(
|
||||||
|
sync_connection,
|
||||||
|
tables=[LlmWikiKnowledgeBaseRow.__table__, LlmWikiWikiPageRow.__table__, LlmWikiWikiVectorRow.__table__, LlmWikiWikiSyncStateRow.__table__],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
session.add(
|
||||||
|
LlmWikiKnowledgeBaseRow(
|
||||||
|
id="mapping-1",
|
||||||
|
weknora_id="remote-1",
|
||||||
|
owner_user_id="u1",
|
||||||
|
name="Wiki",
|
||||||
|
description="",
|
||||||
|
kb_type="document",
|
||||||
|
publication_status="private",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
store = SqlLlmWikiIndexStore(session_factory)
|
||||||
|
assert await store.try_acquire_sync_lease("mapping-1", owner="worker", sync_id="sync-1", lease_seconds=60, fingerprint="fp")
|
||||||
|
assert not await store.try_acquire_sync_lease("mapping-1", owner="worker", sync_id="sync-2", lease_seconds=60, fingerprint="fp")
|
||||||
|
page = normalize_wiki_page("mapping-1", {"id": "p1", "slug": "people/alice", "title": "Alice", "content": "Alice founded Acme.", "status": "published"})
|
||||||
|
blob, dimensions = encode_vector([3.0, 4.0], dimensions=2)
|
||||||
|
saved = await store.replace_page_vectors(
|
||||||
|
page=page,
|
||||||
|
vectors=[{"section_index": 0, "heading": "History", "section_content": "Alice founded Acme.", "content_hash": "section", "embedding_dimensions": dimensions, "vector_blob": blob}],
|
||||||
|
fingerprint="fp",
|
||||||
|
sync_id="sync-1",
|
||||||
|
)
|
||||||
|
snapshot = await store.load_vector_snapshot("mapping-1", "fp")
|
||||||
|
assert saved["slug"] == "people/alice"
|
||||||
|
assert len(snapshot["rows"]) == 1
|
||||||
|
assert decode_vector(snapshot["rows"][0]["vector_blob"], 2).tolist() == pytest.approx([0.6, 0.8])
|
||||||
|
|
||||||
|
assert await store.mark_missing_pages_deleted("mapping-1", "sync-2") == 1
|
||||||
|
assert (await store.load_vector_snapshot("mapping-1", "fp"))["rows"] == []
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_codec_rejects_corrupt_and_non_finite_vectors() -> None:
|
||||||
|
with pytest.raises(ValueError, match="NaN"):
|
||||||
|
encode_vector([1.0, float("nan")], dimensions=2)
|
||||||
|
with pytest.raises(ValueError, match="Corrupt"):
|
||||||
|
decode_vector(b"123", 2)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||||
|
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
|
||||||
|
from deerflow.integrations.weknora.local_index.search import WikiIndexError, WikiVectorSearchService
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_codec import encode_vector
|
||||||
|
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedding:
|
||||||
|
dimensions = 2
|
||||||
|
fingerprint = "fp"
|
||||||
|
|
||||||
|
async def embed_query(self, text: str) -> list[float]:
|
||||||
|
return [1.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_index_requires_supported_enabled_embedding() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="embedding must be enabled"):
|
||||||
|
LocalWikiIndexConfig(enabled=True, embedding={"enabled": False})
|
||||||
|
with pytest.raises(ValidationError, match="provider must be openai"):
|
||||||
|
LocalWikiIndexConfig(embedding={"provider": "custom"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exact_search_groups_sections_by_wiki_page_and_filters_drafts() -> None:
|
||||||
|
store = MemoryLlmWikiIndexStore()
|
||||||
|
await store.try_acquire_sync_lease("kb", owner="w", sync_id="s", lease_seconds=60, fingerprint="fp")
|
||||||
|
published = normalize_wiki_page("kb", {"slug": "alice", "title": "Alice", "summary": "Founder", "content": "Alpha", "status": "published"})
|
||||||
|
draft = normalize_wiki_page("kb", {"slug": "secret", "title": "Secret", "content": "Draft", "status": "draft"})
|
||||||
|
a, _ = encode_vector([1.0, 0.0], dimensions=2)
|
||||||
|
b, _ = encode_vector([0.9, 0.1], dimensions=2)
|
||||||
|
await store.replace_page_vectors(
|
||||||
|
page=published,
|
||||||
|
vectors=[
|
||||||
|
{"section_index": 0, "heading": "A", "section_content": "Alpha", "content_hash": "a", "embedding_dimensions": 2, "vector_blob": a},
|
||||||
|
{"section_index": 1, "heading": "B", "section_content": "Beta", "content_hash": "b", "embedding_dimensions": 2, "vector_blob": b},
|
||||||
|
],
|
||||||
|
fingerprint="fp",
|
||||||
|
sync_id="s",
|
||||||
|
)
|
||||||
|
await store.replace_page_vectors(page=draft, vectors=[{"section_index": 0, "heading": None, "section_content": "Draft", "content_hash": "d", "embedding_dimensions": 2, "vector_blob": a}], fingerprint="fp", sync_id="s")
|
||||||
|
config = LocalWikiIndexConfig(min_similarity=0, top_k_sections=10, top_k_pages=8, max_sections_per_page=2)
|
||||||
|
service = WikiVectorSearchService(config, FakeEmbedding(), WikiVectorCache(store, revision_check_seconds=0))
|
||||||
|
mapping = {"id": "kb", "name": "People"}
|
||||||
|
|
||||||
|
result = await service.search("alice", [mapping])
|
||||||
|
assert [item["wiki_slug"] for item in result["results"]] == ["alice"]
|
||||||
|
assert len(result["results"][0]["matched_sections"]) == 2
|
||||||
|
assert "wiki_slug=alice" in result["results"][0]["url"]
|
||||||
|
|
||||||
|
with_drafts = await service.search("alice", [mapping], include_drafts=True)
|
||||||
|
assert {item["wiki_slug"] for item in with_drafts["results"]} == {"alice", "secret"}
|
||||||
|
|
||||||
|
narrow_service = WikiVectorSearchService(
|
||||||
|
LocalWikiIndexConfig(min_similarity=0, top_k_sections=1, top_k_pages=1),
|
||||||
|
FakeEmbedding(),
|
||||||
|
WikiVectorCache(store, revision_check_seconds=0),
|
||||||
|
)
|
||||||
|
assert [item["wiki_slug"] for item in (await narrow_service.search("alice", [mapping]))["results"]] == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_corrupt_blob_marks_index_for_rebuild() -> None:
|
||||||
|
store = MemoryLlmWikiIndexStore()
|
||||||
|
await store.try_acquire_sync_lease("kb", owner="w", sync_id="s", lease_seconds=60, fingerprint="fp")
|
||||||
|
page = normalize_wiki_page("kb", {"slug": "broken", "title": "Broken", "content": "Broken", "status": "published"})
|
||||||
|
blob, _ = encode_vector([1.0, 0.0], dimensions=2)
|
||||||
|
saved = await store.replace_page_vectors(
|
||||||
|
page=page,
|
||||||
|
vectors=[{"section_index": 0, "heading": None, "section_content": "Broken", "content_hash": "x", "embedding_dimensions": 2, "vector_blob": blob}],
|
||||||
|
fingerprint="fp",
|
||||||
|
sync_id="s",
|
||||||
|
)
|
||||||
|
store.vectors[saved["id"]][0]["vector_blob"] = b"broken"
|
||||||
|
service = WikiVectorSearchService(
|
||||||
|
LocalWikiIndexConfig(min_similarity=0, top_k_sections=10, top_k_pages=8),
|
||||||
|
FakeEmbedding(),
|
||||||
|
WikiVectorCache(store, revision_check_seconds=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(WikiIndexError) as exc_info:
|
||||||
|
await service.search("broken", [{"id": "kb", "name": "Broken"}])
|
||||||
|
assert exc_info.value.code == "WIKI_INDEX_REBUILDING"
|
||||||
|
assert store.states["kb"]["state"] == "rebuild_required"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from app.gateway.routers.llmwiki import _build_wiki_excel
|
||||||
|
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService, is_fully_vectorized
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def __init__(self, states):
|
||||||
|
self.states = states
|
||||||
|
|
||||||
|
async def list_index_status(self, mapping_ids=None):
|
||||||
|
wanted = set(mapping_ids or [])
|
||||||
|
return [state for state in self.states if not wanted or state["knowledge_base_mapping_id"] in wanted]
|
||||||
|
|
||||||
|
|
||||||
|
class _WikiClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.list_calls = []
|
||||||
|
self.raw_search_called = False
|
||||||
|
|
||||||
|
async def list_wiki_pages(self, knowledge_base_id, *, page=1, page_size=50, query=""):
|
||||||
|
self.list_calls.append((knowledge_base_id, query))
|
||||||
|
return {"pages": [{"slug": "guides/install", "title": "安装"}], "total": 1, "total_pages": 1}
|
||||||
|
|
||||||
|
async def get_wiki_page(self, knowledge_base_id, slug):
|
||||||
|
return {
|
||||||
|
"id": f"{knowledge_base_id}:{slug}",
|
||||||
|
"slug": slug,
|
||||||
|
"title": "安装",
|
||||||
|
"summary": "Wiki 摘要",
|
||||||
|
"content": "# 安装\n这是处理后的 Wiki 正文。",
|
||||||
|
"status": "published",
|
||||||
|
"category_path": ["指南"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search(self, query, knowledge_base_ids):
|
||||||
|
self.raw_search_called = True
|
||||||
|
raise AssertionError("raw knowledge search must never be called")
|
||||||
|
|
||||||
|
|
||||||
|
class _VectorSearch:
|
||||||
|
def __init__(self):
|
||||||
|
self.embedding = SimpleNamespace(fingerprint="fp-current")
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def search(self, query, mappings, **kwargs):
|
||||||
|
self.calls.append([mapping["id"] for mapping in mappings])
|
||||||
|
mapping = mappings[0]
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"kind": "wiki_page",
|
||||||
|
"id": "local-page",
|
||||||
|
"wiki_page_id": "local-page",
|
||||||
|
"wiki_slug": "local/page",
|
||||||
|
"knowledge_base_id": mapping["id"],
|
||||||
|
"title": "本地页面",
|
||||||
|
"content": "本地 Wiki",
|
||||||
|
"matched_sections": [],
|
||||||
|
"score": 0.95,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_state(mapping_id: str):
|
||||||
|
return {
|
||||||
|
"knowledge_base_mapping_id": mapping_id,
|
||||||
|
"state": "idle",
|
||||||
|
"last_success_at": "2026-08-24T00:00:00Z",
|
||||||
|
"embedding_fingerprint": "fp-current",
|
||||||
|
"local_page_count": 1,
|
||||||
|
"ready_page_count": 1,
|
||||||
|
"failed_page_count": 0,
|
||||||
|
"vector_count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fully_vectorized_requires_completed_full_consistent_scan() -> None:
|
||||||
|
state = _ready_state("kb")
|
||||||
|
assert is_fully_vectorized(state, "fp-current")
|
||||||
|
assert not is_fully_vectorized({**state, "state": "partial"}, "fp-current")
|
||||||
|
assert not is_fully_vectorized({**state, "ready_page_count": 0}, "fp-current")
|
||||||
|
assert not is_fully_vectorized(state, "different-fingerprint")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unvectorized_library_uses_only_wiki_page_search_api() -> None:
|
||||||
|
client = _WikiClient()
|
||||||
|
service = WikiRetrievalService(_Store([]), vector_search=_VectorSearch(), client=client)
|
||||||
|
mapping = {"id": "local-kb", "weknora_id": "remote-kb", "name": "测试库"}
|
||||||
|
|
||||||
|
result = await service.search("安装", [mapping])
|
||||||
|
|
||||||
|
assert result["mode"] == "wiki_api"
|
||||||
|
assert result["fallback_knowledge_base_ids"] == ["local-kb"]
|
||||||
|
assert result["results"][0]["kind"] == "wiki_page"
|
||||||
|
assert result["results"][0]["content"] == "# 安装\n这是处理后的 Wiki 正文。"
|
||||||
|
assert "chunk_id" not in result["results"][0]
|
||||||
|
assert client.list_calls == [("remote-kb", "安装")]
|
||||||
|
assert not client.raw_search_called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mixed_search_vectors_complete_library_and_falls_back_incomplete_library() -> None:
|
||||||
|
client = _WikiClient()
|
||||||
|
vector = _VectorSearch()
|
||||||
|
service = WikiRetrievalService(_Store([_ready_state("ready")]), vector_search=vector, client=client)
|
||||||
|
mappings = [
|
||||||
|
{"id": "ready", "weknora_id": "remote-ready", "name": "已完成"},
|
||||||
|
{"id": "pending", "weknora_id": "remote-pending", "name": "未完成"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await service.search("安装", mappings, top_k_pages=8)
|
||||||
|
|
||||||
|
assert result["mode"] == "mixed"
|
||||||
|
assert vector.calls == [["ready"]]
|
||||||
|
assert client.list_calls == [("remote-pending", "安装")]
|
||||||
|
assert {item["knowledge_base_id"] for item in result["results"]} == {"ready", "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_export_contains_current_directory_and_every_parent_directory() -> None:
|
||||||
|
long_content = "正文" * 20_000
|
||||||
|
pages = [
|
||||||
|
{"id": "1", "slug": "root", "title": "根专题", "content": "根", "status": "published"},
|
||||||
|
{"id": "2", "slug": "root/child", "title": "子专题", "parent_slug": "root", "content": "子", "status": "published"},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"slug": "root/child/article",
|
||||||
|
"title": "文章",
|
||||||
|
"parent_slug": "root/child",
|
||||||
|
"content": long_content,
|
||||||
|
"status": "published",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
workbook = load_workbook(_build_wiki_excel("知识库", pages), read_only=True)
|
||||||
|
sheet = workbook["Wiki 全量导出"]
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
headings = list(rows[0])
|
||||||
|
article = dict(zip(headings, rows[3], strict=True))
|
||||||
|
|
||||||
|
assert article["目录完整路径"] == "根专题 / 子专题"
|
||||||
|
assert article["所在目录"] == "子专题"
|
||||||
|
assert article["父级目录完整路径"] == "根专题"
|
||||||
|
assert article["第1级目录"] == "根专题"
|
||||||
|
assert article["第2级目录"] == "子专题"
|
||||||
|
assert article["正文"] + article["正文(续1)"] == long_content
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
|
||||||
|
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
|
||||||
|
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
|
||||||
|
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedding:
|
||||||
|
dimensions = 2
|
||||||
|
fingerprint = "fp"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
self.calls += 1
|
||||||
|
return [[1.0, 0.0] for _ in texts]
|
||||||
|
|
||||||
|
|
||||||
|
class FailingEmbedding(FakeEmbedding):
|
||||||
|
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
raise RuntimeError("embedding unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
class NewFingerprintEmbedding(FakeEmbedding):
|
||||||
|
fingerprint = "fp-new"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWeKnora:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pages = {
|
||||||
|
"one": {
|
||||||
|
"id": "p1",
|
||||||
|
"slug": "one",
|
||||||
|
"title": "One",
|
||||||
|
"summary": "A long enough summary for indexing and retrieval.",
|
||||||
|
"content": "# Intro\nA long enough Wiki paragraph for indexing and semantic retrieval.",
|
||||||
|
"status": "published",
|
||||||
|
"version": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_knowledge_base(self, knowledge_base_id: str):
|
||||||
|
return {"id": knowledge_base_id, "indexing_strategy": {"wiki_enabled": True}}
|
||||||
|
|
||||||
|
async def list_wiki_pages(self, knowledge_base_id: str, *, page: int, page_size: int):
|
||||||
|
return {"pages": [{"slug": item["slug"], "title": item["title"]} for item in self.pages.values()], "total_pages": 1}
|
||||||
|
|
||||||
|
async def get_wiki_page(self, knowledge_base_id: str, slug: str):
|
||||||
|
return dict(self.pages[slug])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_is_incremental_and_full_scan_deletes_missing_pages() -> None:
|
||||||
|
config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0)
|
||||||
|
store = MemoryLlmWikiIndexStore()
|
||||||
|
embedding = FakeEmbedding()
|
||||||
|
client = FakeWeKnora()
|
||||||
|
service = WikiSyncService(config, store, embedding, WikiVectorCache(store), client)
|
||||||
|
mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"}
|
||||||
|
|
||||||
|
first = await service.sync_mapping(mapping)
|
||||||
|
assert first["changed_pages"] == 1
|
||||||
|
assert embedding.calls == 1
|
||||||
|
second = await service.sync_mapping(mapping)
|
||||||
|
assert second["changed_pages"] == 0
|
||||||
|
assert embedding.calls == 1
|
||||||
|
|
||||||
|
client.pages.clear()
|
||||||
|
deleted = await service.sync_mapping(mapping)
|
||||||
|
assert deleted["deleted_pages"] == 1
|
||||||
|
assert next(iter(store.pages.values()))["is_remote_deleted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_page_failure_keeps_previous_generation_and_marks_partial() -> None:
|
||||||
|
config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0)
|
||||||
|
store = MemoryLlmWikiIndexStore()
|
||||||
|
client = FakeWeKnora()
|
||||||
|
mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"}
|
||||||
|
await WikiSyncService(config, store, FakeEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping)
|
||||||
|
page = next(iter(store.pages.values()))
|
||||||
|
old_hash = page["content_hash"]
|
||||||
|
old_vectors = list(store.vectors[page["id"]])
|
||||||
|
client.pages["one"]["content"] += " Changed."
|
||||||
|
|
||||||
|
result = await WikiSyncService(config, store, FailingEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping)
|
||||||
|
|
||||||
|
assert result["status"] == "partial"
|
||||||
|
assert store.states["kb"]["state"] == "partial"
|
||||||
|
assert store.states["kb"]["failed_page_count"] == 1
|
||||||
|
assert store.pages[page["id"]]["content_hash"] == old_hash
|
||||||
|
assert store.pages[page["id"]]["index_status"] == "ready"
|
||||||
|
assert store.vectors[page["id"]] == old_vectors
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> None:
|
||||||
|
config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0)
|
||||||
|
store = MemoryLlmWikiIndexStore()
|
||||||
|
client = FakeWeKnora()
|
||||||
|
mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"}
|
||||||
|
await WikiSyncService(config, store, FakeEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping)
|
||||||
|
|
||||||
|
embedding = NewFingerprintEmbedding()
|
||||||
|
result = await WikiSyncService(config, store, embedding, WikiVectorCache(store), client).sync_mapping(mapping)
|
||||||
|
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert result["changed_pages"] == 1
|
||||||
|
assert embedding.calls == 1
|
||||||
|
assert store.states["kb"]["state"] == "idle"
|
||||||
|
assert store.states["kb"]["embedding_fingerprint"] == "fp-new"
|
||||||
|
assert {vector["embedding_fingerprint"] for vectors in store.vectors.values() for vector in vectors} == {"fp-new"}
|
||||||
Reference in new issue
Block a user