feat: preserve Wiki vectors across knowledge transfers and add local BGE-M3
Unify Wiki reading, preserve metadata and graph links, add asynchronous file-backed transfers, and reuse compatible vectors. Includes regression coverage and live acceptance notes.
This commit is contained in:
1 parent
49d9ced04f
commit
60e2c72514
38 files changed
+2536
-581
No files matched your search
@@ -1109,6 +1109,8 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
### Conditional WeKnora LLMWiki
|
||||
|
||||
Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`.
|
||||
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar. Do not add an HTML `sandbox` attribute to this trusted, permission-filtered detail iframe: a sandboxed ancestor causes Chromium's built-in Blob PDF viewer to render the browser-blocked page. The Gateway follows upstream redirects only for `/knowledge/{id}/preview` and `/knowledge-bases/{id}/files`, keeping PDF bytes and protected Wiki images inside the signed proxy instead of sending the browser to WeKnora object storage.
|
||||
@@ -1745,6 +1747,36 @@ imports or arbitrary local-file imports to assistant knowledge. Importing into a
|
||||
custom assistant base must also mirror the package into the global base for
|
||||
cross-source entity alignment.
|
||||
|
||||
### Wiki package and local encoder invariants (2026-09)
|
||||
|
||||
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
||||
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
||||
watcher or automatic remote-write scheduler as a side effect.
|
||||
- `wiki_packages` exposes file-backed assistant exports and restores into empty
|
||||
ordinary Wiki libraries. Validate vectors before any remote mutation; recreate
|
||||
folder IDs from category paths using provider APIs (a raw category_path is
|
||||
discarded by WeKnora without a matching folder_id). Preserve page metadata and
|
||||
rebuild backlinks after restoring all pages.
|
||||
- `PackageArchive` is a replayable, disk-backed JSONL staging index. Always close
|
||||
it in finally. Runtime imports must not use the legacy in-memory read_package.
|
||||
- Preserve normalized float32 base64 bytes exactly. Same dimension alone is not
|
||||
model compatibility. Assistant vector retrieval is fingerprint-gated cosine
|
||||
search, and any keyword fallback must be labeled as such.
|
||||
Preserve a valid zero section_index. Export/search vectors belonging to the
|
||||
current Wiki revision's import job, not stale contributions from other sources.
|
||||
- The `local` embedding provider uses FastEmbed with official BAAI/bge-m3 ONNX,
|
||||
1024 dimensions, bounded CPU batches and a per-client async lock. Run inference
|
||||
off the event loop. Existing page vectors are reusable; zero-vector nonempty
|
||||
pages need backfill, not a forced rebuild of every page.
|
||||
- Provider graph nodes bind to their original wiki_slug. They must not create
|
||||
duplicate synthetic entity/relation articles. Store original Wiki metadata
|
||||
separately from assistant workflow status. Count Wiki pages separately from
|
||||
raw document chunks.
|
||||
- Migration `20260903_01` adds nullable page metadata and merges the pre-existing
|
||||
knowledge/workflow Alembic heads. Regression coverage is in
|
||||
`tests/test_wiki_package_roundtrip.py`, `test_llmwiki_wiki_sync.py` and
|
||||
`test_assistant_knowledge_repository.py`.
|
||||
|
||||
## openGauss / GaussDB persistence invariant
|
||||
|
||||
`database.backend=gauss` is an application-ORM backend, parallel to `mysql`.
|
||||
|
||||
@@ -900,6 +900,12 @@ without deleting the audit row. DeerFlow stores only the artifact reference
|
||||
never the Markdown body and never a task-system delivery. Continue to preview
|
||||
the file through the existing thread-artifact endpoint.
|
||||
|
||||
## 普通知识库管理
|
||||
|
||||
在「普通知识库」的「我的知识库」或「公共 LLMWiki」中,知识库所有者和管理员可以点击卡片上的「编辑」,修改名称和描述(描述可清空)。保存后同步更新知识服务和本地记录,并刷新列表;系统「对话沉淀」库不提供编辑入口。
|
||||
|
||||
远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。
|
||||
|
||||
## DeerFlow-local LLMWiki Wiki vector index
|
||||
|
||||
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
||||
@@ -1113,6 +1119,51 @@ assistant base also mirrors the same package into the global base, where
|
||||
canonical entities and relations are aligned across all imported sources.
|
||||
The database schema is introduced by Alembic revision `20260901_01`.
|
||||
|
||||
### Wiki 流转与本地小批量编码
|
||||
|
||||
技能归纳现在上传清理后的知识正文,等待 WeKnora 完成 Wiki 分析,再建立本地
|
||||
Wiki 向量并通过落盘导出包自动沉淀到「知识梳理」。内置技能没有数据库所有权记录
|
||||
也可以归纳。若解析结束却没有 Wiki,会提示检查生成模型和资料;文档解析完成
|
||||
不等于 Wiki 生成成功,例如生成服务余额不足仍可能表现为文档已完成。
|
||||
|
||||
管理员可以在普通知识库直接生成向量数据包,每次操作有独立后台记录和下载入口。
|
||||
助手知识库支持上传包、异步导入和导出;新建空的普通 Wiki 库可上传助手导出包。
|
||||
包保留正文、别名/标签、目录分类、双向链接、实体关系及 float32 向量。
|
||||
回流时通过 WeKnora API 重建目录/页面,向量进入 DeerFlow 本地 Wiki 索引,
|
||||
**不直接写 WeKnora 的底层向量或图数据库**。普通与助手 Wiki 使用同一个阅读组件。
|
||||
|
||||
小批量 CPU 编码可配置如下,无需额外部署 HTTP 编码服务:
|
||||
|
||||
```bash
|
||||
uv sync --extra local-embedding
|
||||
```
|
||||
|
||||
```yaml
|
||||
llmwiki:
|
||||
local_wiki_index:
|
||||
enabled: true
|
||||
auto_sync: false
|
||||
embedding:
|
||||
provider: local
|
||||
model: bge-embedding-m3
|
||||
dimensions: 1024
|
||||
batch_size: 2
|
||||
local_threads: 2
|
||||
# 离线部署时可指定已下载的 BAAI/bge-m3 模型目录:
|
||||
# local_model_path: /opt/models/bge-m3
|
||||
```
|
||||
|
||||
首次使用需要预下载官方 ONNX 权重(约 2.3 GB),默认持久缓存位于
|
||||
`~/.cache/deerflow/embeddings`,也可设置 `local_cache_dir`。权重不进入业务源码仓库。
|
||||
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
|
||||
数据包导入不重新编码,模型指纹或维度不一致时明确拒绝,不能只按维度混用模型。
|
||||
问答编码查询本身仍需可用的同一模型。WeKnora 的原始文档向量不能当作生成后
|
||||
Wiki 正文的向量使用,两种文本需要分别建立各自的首次索引。
|
||||
|
||||
大包采用流式 JSONL 落盘、同盘临时 SQLite 分阶段读取及异步导入,避免一次性将
|
||||
整个包加载进内存。部署需预留包、暂存索引和业务数据库空间,并配置代理上传大小
|
||||
与超时。当前不宣称断点续传或 GB 级压力验收已完成。
|
||||
|
||||
## openGauss / GaussDB application database
|
||||
|
||||
The application ORM can use a PG-compatible openGauss/GaussDB database through
|
||||
|
||||
@@ -1586,6 +1586,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
app.include_router(skill_knowledge.router)
|
||||
app.include_router(assistant_knowledge.router)
|
||||
app.include_router(assistant_knowledge.export_router)
|
||||
from app.gateway.routers import wiki_packages
|
||||
|
||||
app.include_router(wiki_packages.router)
|
||||
|
||||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router)
|
||||
|
||||
@@ -381,7 +381,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
embedding_config = local_index_config.embedding
|
||||
embedding_configured = bool(
|
||||
embedding_config.enabled
|
||||
and embedding_config.base_url
|
||||
and (embedding_config.base_url or embedding_config.provider == "local")
|
||||
and embedding_config.model.strip()
|
||||
)
|
||||
if embedding_configured:
|
||||
@@ -404,13 +404,19 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
app.state.llmwiki_vector_cache,
|
||||
)
|
||||
if weknora_client is not None:
|
||||
app.state.llmwiki_sync_service = WikiSyncService(
|
||||
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,
|
||||
)
|
||||
weknora_client,
|
||||
)
|
||||
from app.gateway.knowledge_transfer import sync_to_global
|
||||
|
||||
async def on_wiki_vectorized(mapping):
|
||||
return await sync_to_global(app, mapping)
|
||||
|
||||
app.state.llmwiki_sync_service.on_completed = on_wiki_vectorized
|
||||
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
|
||||
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Wiki package flow orchestration; all remote writes use WeKnora APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
async def sync_to_global(app, mapping):
|
||||
from app.gateway.routers.assistant_knowledge import _export_weknora_then_import_for_job
|
||||
|
||||
if mapping.get("is_conversation_deposit") or mapping.get("owner_user_id") == "system":
|
||||
return {"status": "excluded"}
|
||||
store = app.state.assistant_knowledge_store
|
||||
revision, fingerprint, _ = await app.state.llmwiki_index_store.get_index_revision(str(mapping["id"]))
|
||||
base = await store.initialize(created_by=None)
|
||||
for source in await store.list_sources(base_id=base["id"]):
|
||||
meta = source.get("metadata") or {}
|
||||
if source.get("source_key") == mapping["id"] and meta.get("index_revision") == revision:
|
||||
job = await store.get_job(source.get("last_import_job_id")) if source.get("last_import_job_id") else None
|
||||
if job and job.get("status") == "completed":
|
||||
return {"status": "unchanged", "job_id": job["id"], "base_id": base["id"]}
|
||||
source = {"provider": "weknora", "type": "weknora", "knowledge_base_id": mapping["id"], "knowledge_base_name": mapping["name"], "index_revision": revision, "embedding_fingerprint": fingerprint}
|
||||
job = await store.create_queued_import_job(source_type="weknora", source_key=mapping["id"], source_name=mapping["name"], trigger="vectorization_completed", created_by=None, metadata=source, mapping_id=mapping["id"], base_id=base["id"])
|
||||
await _export_weknora_then_import_for_job(request=Request({"type": "http", "app": app}), job_id=job["id"], mapping=mapping, remote_id=mapping["weknora_id"], source=source, actor=None, index_fresh=True)
|
||||
result = await store.get_job(job["id"])
|
||||
if not result or result.get("status") != "completed":
|
||||
raise RuntimeError(f"向量化已完成,但知识梳理同步失败:{(result or {}).get('last_error_message') or job['id']}")
|
||||
return {"status": "completed", "job_id": job["id"], "base_id": base["id"], "counts": result.get("counts")}
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,8 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.assistant_knowledge.package_io import PACKAGE_VERSION, package_path, read_package, write_jsonl_record
|
||||
from deerflow.assistant_knowledge.archive import PackageArchive
|
||||
from deerflow.assistant_knowledge.package_io import PACKAGE_VERSION, package_path, write_jsonl_record
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
from deerflow.integrations.weknora.client import WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
@@ -114,7 +116,9 @@ def _write_export_manifest(
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = _normalize_export_manifest(payload)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
temp = path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
temp.replace(path)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -227,10 +231,11 @@ async def list_wiki_pages(
|
||||
q: str | None = Query(default=None),
|
||||
page_type: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
await _actor(request)
|
||||
pages = await _store(request).list_pages(base_id=base_id, query=q, page_type=page_type, limit=limit)
|
||||
return {"pages": pages, "total": len(pages)}
|
||||
pages = await _store(request).list_pages(base_id=base_id, query=q, page_type=page_type, limit=limit + 1, offset=offset)
|
||||
return {"pages": pages[:limit], "total": min(len(pages), limit), "next_offset": offset + limit if len(pages) > limit else None}
|
||||
|
||||
|
||||
@router.get("/wiki-pages/{slug:path}/revisions/compare")
|
||||
@@ -279,8 +284,11 @@ async def search(
|
||||
page_type: str | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
await _actor(request)
|
||||
rows = await _store(request).list_pages(base_id=base_id, query=q, page_type=page_type, limit=100)
|
||||
return {"results": rows, "total": len(rows)}
|
||||
store = _store(request)
|
||||
base = await store.get_base(base_id)
|
||||
embedding = getattr(request.app.state, "llmwiki_embedding", None)
|
||||
rows = await store.search_pages(base_ids=[base["id"]] if base else [], query=q, limit=20, include_chunks=False, embedding_client=embedding)
|
||||
return {"results": rows, "total": len(rows), "retrieval_mode": rows[0].get("retrieval_mode", "keyword") if rows else "empty"}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
@@ -399,7 +407,10 @@ async def import_weknora_package_file(
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
handle.write(chunk)
|
||||
await asyncio.to_thread(handle.write, chunk)
|
||||
except Exception as exc:
|
||||
await _store(request).update_import_job(str(job["id"]), status="failed", phase="upload_failed", error=str(exc), completed=True)
|
||||
raise
|
||||
finally:
|
||||
await file.close()
|
||||
if size <= 0:
|
||||
@@ -454,6 +465,7 @@ async def _write_weknora_export_package(
|
||||
"source",
|
||||
{
|
||||
**source,
|
||||
"provider": "weknora",
|
||||
"package_version": PACKAGE_VERSION,
|
||||
"export_format": "assistant-knowledge-jsonl",
|
||||
},
|
||||
@@ -462,6 +474,8 @@ async def _write_weknora_export_package(
|
||||
# Wiki pages first: these are the human-readable pages that the
|
||||
# assistant knowledge base presents in wiki form.
|
||||
page_no = 1
|
||||
graph_nodes = []
|
||||
graph_edges = set()
|
||||
while True:
|
||||
page = await client.list_wiki_pages(remote_id, page=page_no, page_size=100)
|
||||
batch = page.get("pages") or []
|
||||
@@ -475,14 +489,16 @@ async def _write_weknora_export_package(
|
||||
if isinstance(detail, dict):
|
||||
payload = {**row, **detail}
|
||||
except WeKnoraError:
|
||||
logger.warning(
|
||||
"Could not fetch full WeKnora wiki page during export: kb=%s slug=%s",
|
||||
remote_id,
|
||||
slug,
|
||||
exc_info=True,
|
||||
)
|
||||
# A list summary is not a replacement for the full
|
||||
# article. An incomplete package must fail visibly.
|
||||
raise
|
||||
write_jsonl_record(handle, "wiki_page", payload)
|
||||
counts["wiki_page"] += 1
|
||||
graph_nodes.append({"slug": slug, "title": payload.get("title"), "page_type": payload.get("page_type"), "aliases": payload.get("aliases") or []})
|
||||
for target in payload.get("out_links") or []:
|
||||
graph_edges.add((slug, str(target)))
|
||||
for match in re.finditer(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", str(payload.get("content") or "")):
|
||||
graph_edges.add((slug, match.group(1).strip()))
|
||||
if counts["wiki_page"] >= int(page.get("total") or counts["wiki_page"]) or not batch:
|
||||
break
|
||||
page_no += 1
|
||||
@@ -526,23 +542,25 @@ async def _write_weknora_export_package(
|
||||
write_jsonl_record(handle, "vector", row)
|
||||
counts["vector"] += 1
|
||||
|
||||
try:
|
||||
graph = await client.get_wiki_graph(remote_id, limit=10000)
|
||||
except WeKnoraError:
|
||||
graph = {"nodes": [], "edges": [], "capability_warning": "graph API unavailable"}
|
||||
# Build the complete link graph from the paginated full pages. The
|
||||
# provider's overview graph endpoint is capped and can omit nodes.
|
||||
known_slugs = {row["slug"] for row in graph_nodes}
|
||||
graph = {"nodes": graph_nodes, "edges": [{"source": a, "target": b} for a, b in sorted(graph_edges) if a in known_slugs and b in known_slugs]}
|
||||
nodes = [row for row in graph.get("nodes") or [] if isinstance(row, dict)]
|
||||
edges = [row for row in graph.get("edges") or [] if isinstance(row, dict)]
|
||||
write_jsonl_record(handle, "graph", {**graph, "nodes": nodes, "edges": edges})
|
||||
counts["graph"] += 1
|
||||
for row in nodes:
|
||||
if row.get("id") and (row.get("title") or row.get("name")):
|
||||
if (row.get("slug") or row.get("id")) and (row.get("title") or row.get("name")):
|
||||
write_jsonl_record(
|
||||
handle,
|
||||
"entity",
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"id": row.get("slug") or row.get("id"),
|
||||
"name": row.get("title") or row.get("name"),
|
||||
"type": row.get("type") or "concept",
|
||||
"type": row.get("page_type") or row.get("type") or "concept",
|
||||
"source_wiki_slug": row.get("slug") or row.get("id"),
|
||||
"aliases": row.get("aliases") or [],
|
||||
"confidence": 1.0,
|
||||
},
|
||||
)
|
||||
@@ -555,6 +573,7 @@ async def _write_weknora_export_package(
|
||||
"source_entity_id": row.get("source"),
|
||||
"target_entity_id": row.get("target"),
|
||||
"predicate": row.get("predicate") or "references",
|
||||
"source_wiki_slug": row.get("source"),
|
||||
"confidence": 1.0,
|
||||
"evidence": [{"source": "weknora_graph"}],
|
||||
},
|
||||
@@ -627,8 +646,9 @@ async def _iter_local_wiki_vectors_for_export(
|
||||
payload = _local_wiki_vector_export_payload(row, model=model, fingerprint=fingerprint)
|
||||
if payload is not None:
|
||||
yield payload
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("No local wiki vector snapshot available for %s", mapping_id, exc_info=True)
|
||||
except Exception:
|
||||
logger.exception("Wiki vector export failed for %s", mapping_id)
|
||||
raise
|
||||
|
||||
|
||||
async def _has_local_wiki_vectors_for_export(request: Request, mapping_id: str) -> bool:
|
||||
@@ -646,12 +666,11 @@ async def _ensure_local_wiki_vectors_for_export(
|
||||
mapping: dict[str, Any],
|
||||
) -> None:
|
||||
mapping_id = str(mapping.get("id") or mapping.get("knowledge_base_id") or "")
|
||||
if not mapping_id or await _has_local_wiki_vectors_for_export(request, mapping_id):
|
||||
return
|
||||
|
||||
service = getattr(request.app.state, "llmwiki_sync_service", None)
|
||||
if service is None or not mapping_id:
|
||||
return
|
||||
raise RuntimeError("请先配置 Wiki 编码模型并启用本地向量索引,再生成向量数据包")
|
||||
# Refresh remote metadata/content hashes even when the previous generation
|
||||
# was complete. Unchanged articles reuse their existing vectors.
|
||||
sync_mapping = dict(mapping)
|
||||
sync_mapping.setdefault("id", mapping_id)
|
||||
sync_mapping.setdefault(
|
||||
@@ -660,15 +679,9 @@ async def _ensure_local_wiki_vectors_for_export(
|
||||
)
|
||||
if not sync_mapping.get("weknora_id"):
|
||||
return
|
||||
try:
|
||||
logger.info("Building local wiki vectors before export: mapping=%s", mapping_id)
|
||||
await service.sync_mapping(sync_mapping, force=False)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Could not build local wiki vectors before export: mapping=%s",
|
||||
mapping_id,
|
||||
exc_info=True,
|
||||
)
|
||||
result = await service.sync_mapping(sync_mapping, force=False, notify=False)
|
||||
if result.get("status") != "completed":
|
||||
raise RuntimeError("Wiki 向量化尚未全部完成,请查看向量化进度后重试")
|
||||
|
||||
|
||||
async def _import_package_file_for_job(
|
||||
@@ -679,9 +692,10 @@ async def _import_package_file_for_job(
|
||||
actor: str | None,
|
||||
) -> None:
|
||||
store = _store(request)
|
||||
package = None
|
||||
try:
|
||||
await store.update_import_job(job_id, status="running", phase="loading_package")
|
||||
package = await asyncio.to_thread(read_package, path)
|
||||
package = await asyncio.to_thread(PackageArchive, path)
|
||||
await store.update_import_job(job_id, status="running", phase="importing")
|
||||
await store.import_package_for_job(
|
||||
job_id,
|
||||
@@ -698,6 +712,9 @@ async def _import_package_file_for_job(
|
||||
error=str(exc),
|
||||
completed=True,
|
||||
)
|
||||
finally:
|
||||
if package is not None:
|
||||
await asyncio.to_thread(package.close)
|
||||
|
||||
|
||||
async def _export_weknora_then_import_for_job(
|
||||
@@ -708,6 +725,7 @@ async def _export_weknora_then_import_for_job(
|
||||
remote_id: str,
|
||||
source: dict[str, Any],
|
||||
actor: str | None,
|
||||
index_fresh: bool = False,
|
||||
) -> None:
|
||||
store = _store(request)
|
||||
path = package_path(_package_root(), job_id)
|
||||
@@ -717,7 +735,8 @@ async def _export_weknora_then_import_for_job(
|
||||
if not runtime.weknora_enabled:
|
||||
raise RuntimeError("未配置普通知识库服务")
|
||||
mapping_id = str(mapping.get("id") or mapping.get("knowledge_base_id") or "")
|
||||
await _ensure_local_wiki_vectors_for_export(request, mapping)
|
||||
if not index_fresh:
|
||||
await _ensure_local_wiki_vectors_for_export(request, mapping)
|
||||
counts = await _write_weknora_export_package(
|
||||
build_weknora_client(runtime),
|
||||
remote_id,
|
||||
|
||||
@@ -2041,6 +2041,7 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not slug and not title and not content and not summary:
|
||||
return None
|
||||
return {
|
||||
"page_metadata": page.get("page_metadata") if isinstance(page.get("page_metadata"), dict) else {},
|
||||
"id": str(page.get("id") or ""),
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
@@ -2987,12 +2988,11 @@ async def list_knowledge_bases(
|
||||
knowledge_bases: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
remote_item = remote.get(str(row["weknora_id"]))
|
||||
row, remote_item = await _sync_conversation_deposit_view(_store(request), client, row, remote_item)
|
||||
# Owners need to see stale personal mappings so they can clean them up.
|
||||
# Public/selector views must never expose a resource that no longer exists
|
||||
# in WeKnora.
|
||||
if remote_item is None and scope != "personal":
|
||||
# A successful remote list is authoritative for every scope. Keep the
|
||||
# mapping for existing references, but never display a deleted base.
|
||||
if remote_item is None:
|
||||
continue
|
||||
row, remote_item = await _sync_conversation_deposit_view(_store(request), client, row, remote_item)
|
||||
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
|
||||
return {"knowledge_bases": knowledge_bases}
|
||||
|
||||
@@ -3077,13 +3077,19 @@ async def get_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any
|
||||
async def update_knowledge_base(request: Request, mapping_id: str, body: KnowledgeBaseUpdate) -> dict[str, Any]:
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=True)
|
||||
changes = body.model_dump(exclude_none=True)
|
||||
if "name" in changes:
|
||||
changes["name"] = changes["name"].strip()
|
||||
if not changes["name"]:
|
||||
raise HTTPException(status_code=422, detail="知识库名称不能为空")
|
||||
if changes["name"] == CONVERSATION_DEPOSIT_KB_NAME:
|
||||
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
|
||||
if not changes:
|
||||
return _mapping_view(row, None, actor_user_id=user_id, is_admin=is_admin)
|
||||
try:
|
||||
remote = await _client_or_503().update_knowledge_base(str(row["weknora_id"]), changes)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
updated = await _store(request).update_mapping(mapping_id, name=body.name, description=body.description)
|
||||
updated = await _store(request).update_mapping(mapping_id, name=changes.get("name"), description=changes.get("description"))
|
||||
return _mapping_view(updated or row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||||
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[st
|
||||
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))
|
||||
spawn_local_wiki_index_job(request.app, _run_one_sync(request.app, mapping, force=False))
|
||||
return {"job_id": job_id, "status": "accepted", "knowledge_base_id": mapping_id}
|
||||
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ class SyncTarget(BaseModel):
|
||||
|
||||
class CreateSyncJobRequest(BaseModel):
|
||||
skill_names: list[str] = Field(min_length=1, max_length=200)
|
||||
targets: list[SyncTarget] = Field(min_length=1, max_length=50)
|
||||
targets: list[SyncTarget] = Field(min_length=1, max_length=1)
|
||||
force: bool = False
|
||||
review_mode: Literal["required", "auto_high_confidence", "off"] = "auto_high_confidence"
|
||||
review_mode: Literal["required", "auto_high_confidence", "off"] = "off"
|
||||
|
||||
@field_validator("skill_names")
|
||||
@classmethod
|
||||
@@ -165,8 +165,14 @@ async def disable_binding_auto_sync(request: Request, binding_id: str) -> dict[s
|
||||
@router.post("/sync-jobs", status_code=202)
|
||||
async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[str, Any]:
|
||||
actor = await _require_admin(request)
|
||||
skill_store = request.app.state.skill_store
|
||||
missing = [name for name in body.skill_names if await skill_store.get_any(name) is None]
|
||||
# Built-in and legacy skills need not have an ownership row. Validate
|
||||
# against the same running filesystem used by the skills list/executor.
|
||||
missing = []
|
||||
for name in body.skill_names:
|
||||
try:
|
||||
await asyncio.to_thread(_skill_directory, request.app.state.config, name)
|
||||
except (FileNotFoundError, ValueError):
|
||||
missing.append(name)
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"技能不存在:{', '.join(missing[:10])}")
|
||||
targets: list[dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Asynchronous, file-backed assistant export and ordinary Wiki restore."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||
from app.gateway.routers.assistant_knowledge import _actor, _list_export_manifests, _package_root, _read_export_manifest, _store, _write_export_manifest
|
||||
from deerflow.assistant_knowledge.archive import PackageArchive
|
||||
from deerflow.assistant_knowledge.package_io import package_path, write_jsonl_record
|
||||
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.assistant_knowledge.store import _vector_blob_from_payload
|
||||
|
||||
router = APIRouter(tags=["wiki-packages"])
|
||||
|
||||
|
||||
def job_view(row):
|
||||
if row["mapping_id"].startswith("assistant:"):
|
||||
base_id = row["mapping_id"].split(":", 1)[1]
|
||||
row["package_download_url"] = f"/api/assistant-knowledge/bases/{base_id}/export-package/{row['id']}/download"
|
||||
elif row["mapping_id"].startswith("restore:"):
|
||||
row["package_download_url"] = None
|
||||
return row
|
||||
|
||||
|
||||
async def export_assistant(app, base_id, job_id):
|
||||
scope = f"assistant:{base_id}"
|
||||
path = package_path(_package_root(), job_id)
|
||||
temp = path.with_suffix(".tmp")
|
||||
counts = {}
|
||||
try:
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="running", phase="exporting")
|
||||
with temp.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
async for kind, payload in app.state.assistant_knowledge_store.iter_export_records(base_id):
|
||||
await asyncio.to_thread(write_jsonl_record, handle, kind, payload)
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
if counts.get("wiki_page") and not counts.get("vector"):
|
||||
raise ValueError("该助手知识库尚无可导出的 Wiki 向量,请先从已向量化的普通知识库同步")
|
||||
await asyncio.to_thread(temp.replace, path)
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="completed", phase="package_ready", counts=counts)
|
||||
except Exception as exc:
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="failed", phase="failed", counts=counts, error=str(exc))
|
||||
|
||||
|
||||
@router.post("/api/assistant-knowledge/bases/{base_id}/export-package", status_code=202)
|
||||
async def create_export(request: Request, base_id: str):
|
||||
await _actor(request, admin=True)
|
||||
if not await _store(request).get_base(base_id):
|
||||
raise HTTPException(404, "助手知识库不存在")
|
||||
job_id = str(uuid4())
|
||||
row = _write_export_manifest(job_id=job_id, mapping_id=f"assistant:{base_id}", status="queued", phase="queued")
|
||||
spawn_local_wiki_index_job(request.app, export_assistant(request.app, base_id, job_id))
|
||||
return job_view(row)
|
||||
|
||||
|
||||
@router.get("/api/assistant-knowledge/bases/{base_id}/export-package")
|
||||
async def list_exports(request: Request, base_id: str):
|
||||
await _actor(request, admin=True)
|
||||
return {"jobs": [job_view(row) for row in await asyncio.to_thread(_list_export_manifests, f"assistant:{base_id}", limit=100)]}
|
||||
|
||||
|
||||
@router.get("/api/assistant-knowledge/bases/{base_id}/export-package/{job_id}/download")
|
||||
async def download_export(request: Request, base_id: str, job_id: str):
|
||||
await _actor(request, admin=True)
|
||||
row = _read_export_manifest(job_id)
|
||||
if not row or row["mapping_id"] != f"assistant:{base_id}" or row["status"] != "completed" or not row["file_exists"]:
|
||||
raise HTTPException(404, "导出包尚未就绪")
|
||||
return FileResponse(package_path(_package_root(), job_id), media_type="application/x-ndjson", filename=f"wiki-vector-{job_id}.jsonl")
|
||||
|
||||
|
||||
async def restore_package(app, mapping, job_id, path: Path):
|
||||
scope = f"restore:{mapping['id']}"
|
||||
archive = None
|
||||
counts = {"wiki_page": 0, "vector": 0}
|
||||
store = app.state.llmwiki_index_store
|
||||
owner = f"package:{job_id}"
|
||||
leased = False
|
||||
try:
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="running", phase="validating")
|
||||
archive = await asyncio.to_thread(PackageArchive, path)
|
||||
embedding = app.state.llmwiki_embedding
|
||||
if embedding is None:
|
||||
raise ValueError("请配置与数据包相同的 Wiki 编码模型,以便复用向量进行检索")
|
||||
if embedding.dimensions is None:
|
||||
await embedding.embed_query("Wiki embedding dimension probe")
|
||||
# Validate the complete package before the first remote mutation.
|
||||
slugs = {p["slug"] for p in archive["wiki_pages"]}
|
||||
vector_slugs = set()
|
||||
for raw in archive["vectors"]:
|
||||
slug = str(raw.get("wiki_slug") or raw.get("page_slug") or raw.get("slug") or "")
|
||||
if slug not in slugs or _vector_blob_from_payload(raw) is None:
|
||||
raise ValueError(f"向量数据或 Wiki 引用无效:{slug}")
|
||||
if raw.get("embedding_fingerprint") != embedding.fingerprint or raw.get("embedding_dimensions") != embedding.dimensions:
|
||||
raise ValueError("数据包编码模型指纹与当前配置不一致;请使用同一模型配置,禁止静默重新编码")
|
||||
vector_slugs.add(slug)
|
||||
if not vector_slugs:
|
||||
raise ValueError("数据包中没有 Wiki 向量")
|
||||
for page in archive["wiki_pages"]:
|
||||
if page["slug"] not in vector_slugs and page.get("page_type") not in {"index", "directory"} and (page.get("content") or "").strip():
|
||||
raise ValueError(f"Wiki 缺少向量:{page['slug']}")
|
||||
leased = await store.try_acquire_sync_lease(mapping["id"], owner=owner, sync_id=job_id, lease_seconds=900, fingerprint=embedding.fingerprint)
|
||||
if not leased:
|
||||
raise ValueError("目标知识库正在同步,请稍后重试")
|
||||
client = build_weknora_client(get_resolved_llmwiki_runtime(app.state.config))
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="running", phase="restoring")
|
||||
folders = {(): ""}
|
||||
for raw in archive["wiki_pages"]:
|
||||
if not await store.renew_sync_lease(mapping["id"], owner=owner, lease_seconds=900):
|
||||
raise RuntimeError("导入租约已失效,请重新发起")
|
||||
# Restore the provider's Wiki schema, never raw documents; raw
|
||||
# uploads would trigger generation and embedding a second time.
|
||||
categories = raw.get("category_path") or []
|
||||
if isinstance(categories, str):
|
||||
categories = [part for part in categories.split("/") if part]
|
||||
parent = ()
|
||||
for name in categories:
|
||||
path_key = (*parent, name)
|
||||
if path_key not in folders:
|
||||
folder = await client.create_wiki_folder(mapping["weknora_id"], name=name, parent_id=folders[parent])
|
||||
folders[path_key] = folder["id"]
|
||||
parent = path_key
|
||||
payload = {key: value for key, value in raw.items() if key not in {"id", "tenant_id", "knowledge_base_id", "folder_id", "origins"}}
|
||||
payload["folder_id"] = folders[parent]
|
||||
if raw.get("tags"):
|
||||
payload["page_metadata"] = {**(raw.get("page_metadata") or {}), "tags": raw["tags"]}
|
||||
remote = await client.create_wiki_page(mapping["weknora_id"], payload)
|
||||
page = normalize_wiki_page(mapping["id"], {**raw, **remote})
|
||||
vectors = []
|
||||
for value in archive.vectors_for(raw["slug"]):
|
||||
blob, dimensions = _vector_blob_from_payload(value)
|
||||
vectors.append({**value, "vector_blob": blob, "embedding_dimensions": dimensions})
|
||||
await store.replace_page_vectors(page=page, vectors=vectors, fingerprint=embedding.fingerprint, sync_id=job_id)
|
||||
counts["wiki_page"] += 1
|
||||
counts["vector"] += len(vectors)
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="running", phase="restoring", counts=counts)
|
||||
await client.rebuild_wiki_links(mapping["weknora_id"])
|
||||
await store.finish_sync(mapping["id"], owner=owner, sync_id=job_id, success=True, remote_page_count=counts["wiki_page"])
|
||||
leased = False
|
||||
if app.state.llmwiki_vector_cache:
|
||||
app.state.llmwiki_vector_cache.invalidate(mapping["id"])
|
||||
from app.gateway.knowledge_transfer import sync_to_global
|
||||
|
||||
await sync_to_global(app, mapping)
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="completed", phase="completed", counts=counts)
|
||||
except Exception as exc:
|
||||
if leased:
|
||||
await store.finish_sync(mapping["id"], owner=owner, sync_id=job_id, success=False, remote_page_count=counts["wiki_page"], error=str(exc))
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="failed", phase="failed", counts=counts, error=str(exc))
|
||||
finally:
|
||||
if archive is not None:
|
||||
await asyncio.to_thread(archive.close)
|
||||
|
||||
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/import-package", status_code=202)
|
||||
async def upload_restore(request: Request, mapping_id: str, file: UploadFile = File(...)):
|
||||
actor = await _actor(request, admin=True)
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, actor or "system", write=True, is_admin=True)
|
||||
if not mapping or mapping.get("is_conversation_deposit") or mapping.get("owner_user_id") == "system":
|
||||
raise HTTPException(404, "目标普通知识库不存在或不可导入")
|
||||
client = build_weknora_client(get_resolved_llmwiki_runtime(request.app.state.config))
|
||||
remote = await client.get_knowledge_base(mapping["weknora_id"])
|
||||
if not (remote.get("indexing_strategy") or {}).get("wiki_enabled"):
|
||||
raise HTTPException(422, "请先开启目标知识库的 Wiki")
|
||||
existing = await client.list_wiki_pages(mapping["weknora_id"], page_size=1)
|
||||
if existing.get("total"):
|
||||
raise HTTPException(409, "请导入到新建的空 Wiki 知识库,避免覆盖已有页面")
|
||||
job_id = str(uuid4())
|
||||
scope = f"restore:{mapping_id}"
|
||||
row = _write_export_manifest(job_id=job_id, mapping_id=scope, status="queued", phase="uploading")
|
||||
path = package_path(_package_root(), job_id)
|
||||
try:
|
||||
with path.open("wb") as handle:
|
||||
while data := await file.read(1024 * 1024):
|
||||
await asyncio.to_thread(handle.write, data)
|
||||
except Exception as exc:
|
||||
_write_export_manifest(job_id=job_id, mapping_id=scope, status="failed", phase="upload_failed", error=str(exc))
|
||||
raise
|
||||
finally:
|
||||
await file.close()
|
||||
spawn_local_wiki_index_job(request.app, restore_package(request.app, mapping, job_id, path))
|
||||
return job_view(row)
|
||||
|
||||
|
||||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/import-package")
|
||||
async def list_restores(request: Request, mapping_id: str):
|
||||
await _actor(request, admin=True)
|
||||
return {"jobs": [job_view(row) for row in await asyncio.to_thread(_list_export_manifests, f"restore:{mapping_id}", limit=100)]}
|
||||
@@ -27,7 +27,7 @@ def _skill_directory(app_config: Any, skill_name: str) -> Path:
|
||||
root = storage.get_skills_root_path().resolve()
|
||||
for category in SkillCategory:
|
||||
direct = root / category.value / skill_name
|
||||
if (direct / "SKILL.md").is_file():
|
||||
if direct.resolve().is_relative_to((root / category.value).resolve()) and (direct / "SKILL.md").is_file():
|
||||
return direct
|
||||
for category in SkillCategory:
|
||||
category_root = root / category.value
|
||||
@@ -235,6 +235,9 @@ class SkillKnowledgeJobExecutor:
|
||||
scan = await asyncio.to_thread(scan_skill, str(item["skill_name"]), root)
|
||||
scan = await enrich_converted_documents(scan)
|
||||
await self._assert_source_unchanged(item, expected_digest)
|
||||
if item["target_type"] == "weknora" and item["target_mode"] == "wiki":
|
||||
await self._generate_vectorize_and_deposit(item, snapshot_id, ir, scan)
|
||||
return
|
||||
await self.store.update_item(str(item["id"]), phase="writing_wiki", progress=65)
|
||||
target_type = str(item["target_type"])
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
@@ -401,3 +404,63 @@ class SkillKnowledgeJobExecutor:
|
||||
await self._assert_source_unchanged(item, expected_digest)
|
||||
await self.store.update_item(str(item["id"]), phase="activating", progress=90)
|
||||
await self.store.stage_and_activate(item_id=str(item["id"]), snapshot_id=snapshot_id, ir=ir, artifacts=artifacts, status=binding_status)
|
||||
|
||||
async def _generate_vectorize_and_deposit(self, item, snapshot_id, ir, scan):
|
||||
mapping = await self.app.state.llmwiki_store.get_authorized(str(item["target_id"]), "system", write=True, is_admin=True)
|
||||
if mapping is None:
|
||||
raise ValueError("目标普通知识库不存在")
|
||||
sync = getattr(self.app.state, "llmwiki_sync_service", None)
|
||||
if sync is None:
|
||||
raise ValueError("请先配置 Wiki 编码模型并启用本地向量索引;归纳需要完成向量化后同步知识梳理")
|
||||
client = build_weknora_client(get_resolved_llmwiki_runtime(self.app.state.config))
|
||||
remote_id = mapping["weknora_id"]
|
||||
remote = await client.get_knowledge_base(remote_id)
|
||||
if not (remote.get("indexing_strategy") or {}).get("wiki_enabled"):
|
||||
raise ValueError("目标普通知识库尚未开启 Wiki")
|
||||
artifacts = []
|
||||
document_ids = []
|
||||
await self.store.update_item(str(item["id"]), phase="writing_sources", progress=55)
|
||||
for file in scan.files:
|
||||
if await self._cancel_if_requested(item):
|
||||
return
|
||||
if not file.text:
|
||||
continue
|
||||
document = await client.create_manual_document(remote_id, title=f"{ir['skill'].get('display_name') or item['skill_name']} · {file.path}", content=file.text)
|
||||
doc_id = str(document.get("id") or document.get("knowledge_id") or "")
|
||||
if not doc_id:
|
||||
raise ValueError("资料上传未返回文档标识")
|
||||
document_ids.append(doc_id)
|
||||
artifacts.append({"kind": "raw_source", "slug": file.path, "remote_id": doc_id})
|
||||
if not document_ids:
|
||||
raise ValueError("该技能没有可归纳的知识正文,代码、脚本和代码块不参与归纳")
|
||||
await self.store.update_item(str(item["id"]), phase="generating_wiki", progress=65)
|
||||
# WeKnora performs model extraction, categorization and linking. Wait
|
||||
# for both parsing and Wiki's debounced finalize before vectorization.
|
||||
stable = 0
|
||||
for _ in range(720):
|
||||
if await self._cancel_if_requested(item):
|
||||
return
|
||||
docs = [await client.get_document(doc_id) for doc_id in document_ids]
|
||||
if any(doc.get("parse_status") in {"failed", "error"} for doc in docs):
|
||||
raise RuntimeError("WeKnora 资料解析失败,请查看普通知识库中的文档状态")
|
||||
stats = await client.get_wiki_stats(remote_id)
|
||||
done = all(doc.get("parse_status") == "completed" for doc in docs) and not stats.get("is_active") and not stats.get("pending_tasks")
|
||||
stable = stable + 1 if done else 0
|
||||
if stable >= 3:
|
||||
if not int(stats.get("total_pages") or 0):
|
||||
raise RuntimeError("WeKnora 解析已结束,但未生成 Wiki;请检查 Wiki 生成模型的调用错误、余额及资料是否含实质知识,再重试")
|
||||
break
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
raise TimeoutError("Wiki 分析尚未完成;请检查普通知识库的生成任务后重新归纳")
|
||||
await self.store.update_item(str(item["id"]), phase="vectorizing_wiki", progress=80)
|
||||
result = await sync.sync_mapping(mapping, force=False, notify=False)
|
||||
if result.get("status") != "completed":
|
||||
raise RuntimeError("Wiki 向量化未全部完成,请检查向量化进度")
|
||||
await self.store.update_item(str(item["id"]), phase="syncing_assistant", progress=92)
|
||||
from app.gateway.knowledge_transfer import sync_to_global
|
||||
|
||||
deposited = await sync_to_global(self.app, mapping)
|
||||
artifacts.append({"kind": "assistant_deposit", "slug": "knowledge-summary", "metadata": deposited})
|
||||
await self._assert_source_unchanged(item, str(ir["skill"]["source_digest"]))
|
||||
await self.store.stage_and_activate(item_id=str(item["id"]), snapshot_id=snapshot_id, ir=ir, artifacts=artifacts, status="synced")
|
||||
@@ -589,16 +589,20 @@ llmwiki:
|
||||
timeout_seconds: 30
|
||||
# 本地 Wiki 向量化:用于普通知识库“下载向量化数据包”和助手知识库向量检索。
|
||||
# 已有相同内容哈希 + 相同 embedding fingerprint 的 Wiki 向量会直接复用,不会重复编码。
|
||||
# 如需导出时自动补建向量,把 enabled 改为 true,并填写下面 embedding 服务地址。
|
||||
# local 模式使用 CPU BGE-M3,需安装 local-embedding 依赖并预下载官方 ONNX 权重。
|
||||
# auto_sync=true 才会后台自动增量同步;服务启动时只启动定时器,不会立刻全量向量化。
|
||||
local_wiki_index:
|
||||
enabled: false
|
||||
enabled: true
|
||||
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
|
||||
frontend_base_url: "http://127.0.0.1:5174"
|
||||
auto_sync: false
|
||||
database_batch_size: 50
|
||||
embedding:
|
||||
provider: local
|
||||
model: bge-embedding-m3
|
||||
dimensions: 1024
|
||||
batch_size: 2
|
||||
local_threads: 2
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"""Disk-backed, replayable package records. Memory is bounded by one JSONL record.
|
||||
|
||||
The staging index is local SQLite, never a remote provider's database. It also
|
||||
allows restoration to fetch only the vectors belonging to the current page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Records:
|
||||
def __init__(self, path: Path, kind: str, slug: str | None = None):
|
||||
self.path, self.kind, self.slug = path, kind, slug
|
||||
|
||||
def _where(self):
|
||||
return ("kind=?", (self.kind,)) if self.slug is None else ("kind=? AND slug=?", (self.kind, self.slug))
|
||||
|
||||
def __iter__(self) -> Iterator[dict[str, Any]]:
|
||||
where, args = self._where()
|
||||
with closing(sqlite3.connect(self.path)) as db:
|
||||
for (payload,) in db.execute(f"SELECT payload FROM records WHERE {where} ORDER BY seq", args):
|
||||
yield json.loads(payload)
|
||||
|
||||
def __len__(self) -> int:
|
||||
where, args = self._where()
|
||||
with closing(sqlite3.connect(self.path)) as db:
|
||||
return db.execute(f"SELECT count(*) FROM records WHERE {where}", args).fetchone()[0]
|
||||
|
||||
|
||||
class PackageArchive(dict):
|
||||
def __init__(self, path: Path):
|
||||
super().__init__()
|
||||
temporary = tempfile.NamedTemporaryFile(prefix="wiki-package-", suffix=".sqlite", dir=path.parent, delete=False)
|
||||
self.path = Path(temporary.name)
|
||||
temporary.close()
|
||||
try:
|
||||
with closing(sqlite3.connect(self.path)) as db, path.open(encoding="utf-8-sig") as handle:
|
||||
db.execute("CREATE TABLE records(seq INTEGER PRIMARY KEY, kind TEXT, slug TEXT, payload TEXT)")
|
||||
for line_no, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
kind, payload = record.get("record_type"), record.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"导出包第 {line_no} 行格式错误")
|
||||
if kind == "source":
|
||||
self["source"] = {**self.get("source", {}), **payload}
|
||||
elif kind == "graph":
|
||||
for key, subkind in (("nodes", "graph_node"), ("edges", "graph_edge")):
|
||||
db.executemany("INSERT INTO records(kind, payload) VALUES (?, ?)", [(subkind, json.dumps(v, ensure_ascii=False)) for v in payload.get(key) or []])
|
||||
elif kind in {"wiki_page", "document", "chunk", "vector", "entity", "relation"}:
|
||||
slug = str(payload.get("wiki_slug") or payload.get("page_slug") or payload.get("slug") or "").strip("/")
|
||||
db.execute("INSERT INTO records(kind, slug, payload) VALUES (?, ?, ?)", (kind, slug, json.dumps(payload, ensure_ascii=False)))
|
||||
else:
|
||||
raise ValueError(f"不支持的导出记录类型:{kind}")
|
||||
db.execute("CREATE INDEX by_kind_slug ON records(kind, slug)")
|
||||
db.commit()
|
||||
source = self.get("source", {})
|
||||
if source.get("package_version") != 1 or (source.get("provider") or source.get("type")) not in {"weknora", "assistant"}:
|
||||
raise ValueError("仅接受 WeKnora / 助手知识库生成的兼容 Wiki 向量包(v1)")
|
||||
for key, kind in (("wiki_pages", "wiki_page"), ("documents", "document"), ("chunks", "chunk"), ("vectors", "vector"), ("entities", "entity"), ("relations", "relation")):
|
||||
self[key] = Records(self.path, kind)
|
||||
self["graph"] = {"nodes": Records(self.path, "graph_node"), "edges": Records(self.path, "graph_edge")}
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def vectors_for(self, slug: str) -> Records:
|
||||
return Records(self.path, "vector", slug)
|
||||
|
||||
def close(self):
|
||||
self.path.unlink(missing_ok=True)
|
||||
@@ -76,6 +76,9 @@ class LocalWikiEmbeddingConfig(BaseModel):
|
||||
model: str = "bge-embedding-m3"
|
||||
base_url: str = ""
|
||||
api_key: str = Field(default="", repr=False)
|
||||
local_model_path: str = ""
|
||||
local_cache_dir: str = ""
|
||||
local_threads: int = Field(default=2, ge=1, le=32)
|
||||
dimensions: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
@@ -91,8 +94,8 @@ class LocalWikiEmbeddingConfig(BaseModel):
|
||||
@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")
|
||||
if normalized not in {"openai", "openai_compatible", "local"}:
|
||||
raise ValueError("Wiki embedding provider must be openai, openai_compatible or local")
|
||||
return normalized
|
||||
|
||||
@field_validator("base_url")
|
||||
@@ -198,7 +201,7 @@ class LocalWikiIndexConfig(BaseModel):
|
||||
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()):
|
||||
if self.enabled and ((not self.embedding.base_url and self.embedding.provider != "local") or not self.embedding.model.strip()):
|
||||
raise ValueError("local Wiki index embedding base_url and model are required when enabled")
|
||||
return self
|
||||
|
||||
|
||||
+15
@@ -501,6 +501,21 @@ class WeKnoraClient:
|
||||
raise WeKnoraError("WeKnora returned an invalid wiki graph")
|
||||
return dict(value)
|
||||
|
||||
async def get_wiki_stats(self, knowledge_base_id: str) -> dict[str, Any]:
|
||||
value = _data(await self._request("GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/stats"))
|
||||
if not isinstance(value, dict):
|
||||
raise WeKnoraError("Invalid Wiki generation status")
|
||||
return value
|
||||
|
||||
async def create_wiki_folder(self, knowledge_base_id: str, *, name: str, parent_id: str = "") -> dict[str, Any]:
|
||||
value = _data(await self._request("POST", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/folders", json={"name": name, "parent_id": parent_id}))
|
||||
if not isinstance(value, dict) or not value.get("id"):
|
||||
raise WeKnoraError("Invalid Wiki folder response")
|
||||
return value
|
||||
|
||||
async def rebuild_wiki_links(self, knowledge_base_id: str) -> None:
|
||||
await self._request("POST", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/rebuild-links")
|
||||
|
||||
async def list_wiki_pages(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
|
||||
+8
@@ -105,4 +105,12 @@ def chunk_wiki_page(
|
||||
context.append(f"章节:{heading}")
|
||||
embedding_text = "\n".join([*context, "", chunk]).strip()
|
||||
result.append(WikiSection(len(result), heading, chunk, embedding_text, digest))
|
||||
# A short article with a short summary must not disappear merely because
|
||||
# both sections fall below min_chars. Keep one combined, useful vector.
|
||||
if not result and (content or summary):
|
||||
fallback = "\n\n".join(dict.fromkeys(text for text in (summary, content) if text))
|
||||
normalized = re.sub(r"\s+", " ", fallback).strip()
|
||||
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
prefix = prefix_lines if title_prefix_enabled else []
|
||||
result.append(WikiSection(0, None, fallback, "\n".join([*prefix, "", fallback]).strip(), digest))
|
||||
return result
|
||||
+47
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
import httpx
|
||||
@@ -43,6 +44,8 @@ class StrictWikiEmbeddingClient:
|
||||
self._client = http_client
|
||||
self._owns_client = http_client is None
|
||||
self._resolved_dimensions = config.dimensions
|
||||
self._local_model = None
|
||||
self._local_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int | None:
|
||||
@@ -64,6 +67,11 @@ class StrictWikiEmbeddingClient:
|
||||
return self._client
|
||||
|
||||
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||
if self.config.provider == "local":
|
||||
if not texts:
|
||||
return []
|
||||
async with self._local_lock:
|
||||
return await asyncio.to_thread(self._embed_local, texts)
|
||||
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:
|
||||
@@ -76,6 +84,45 @@ class StrictWikiEmbeddingClient:
|
||||
raise WikiEmbeddingError("Embedding response count does not match request", category="response", retryable=False)
|
||||
return vectors
|
||||
|
||||
def _embed_local(self, texts: list[str]) -> list[list[float]]:
|
||||
if not self.config.enabled:
|
||||
raise WikiEmbeddingError("本地编码已关闭", retryable=False)
|
||||
if self._local_model is None:
|
||||
try:
|
||||
from fastembed import TextEmbedding
|
||||
except ImportError as exc:
|
||||
raise WikiEmbeddingError("请安装本地编码依赖:uv sync --extra local-embedding", retryable=False) from exc
|
||||
name = "BAAI/bge-m3" if self.config.model.lower() in {"bge-m3", "bge-embedding-m3"} else self.config.model
|
||||
if name == "BAAI/bge-m3" and not any(m["model"] == name for m in TextEmbedding.list_supported_models()):
|
||||
from fastembed.common.model_description import ModelSource, PoolingType
|
||||
|
||||
TextEmbedding.add_custom_model(
|
||||
model=name,
|
||||
pooling=PoolingType.CLS,
|
||||
normalization=True,
|
||||
dim=1024,
|
||||
sources=ModelSource(hf="BAAI/bge-m3"),
|
||||
model_file="onnx/model.onnx",
|
||||
additional_files=["onnx/model.onnx_data", "onnx/Constant_7_attr__value"],
|
||||
license="mit",
|
||||
size_in_gb=2.29,
|
||||
)
|
||||
cache_dir = Path(self.config.local_cache_dir).expanduser() if self.config.local_cache_dir else Path.home() / ".cache" / "deerflow" / "embeddings"
|
||||
options = {"model_name": name, "threads": self.config.local_threads, "cache_dir": str(cache_dir), "providers": ["CPUExecutionProvider"]}
|
||||
if self.config.local_model_path:
|
||||
options["specific_model_path"] = str(Path(self.config.local_model_path).expanduser())
|
||||
options["local_files_only"] = True
|
||||
self._local_model = TextEmbedding(**options)
|
||||
values = []
|
||||
for vector in self._local_model.embed(texts, batch_size=min(self.config.batch_size, 4)):
|
||||
normalized = normalize_vector(vector, dimensions=self._resolved_dimensions)
|
||||
if self._resolved_dimensions is None:
|
||||
self._resolved_dimensions = int(normalized.size)
|
||||
values.append(normalized.tolist())
|
||||
if len(values) != len(texts):
|
||||
raise WikiEmbeddingError("本地编码返回数量不符", retryable=False)
|
||||
return values
|
||||
|
||||
async def _embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
url = self.config.base_url.rstrip("/") + "/embeddings"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
+7
-2
@@ -36,8 +36,9 @@ class WikiSyncService:
|
||||
self.cache = cache
|
||||
self.client = client
|
||||
self.owner = f"{platform.node()}:{uuid4()}"
|
||||
self.on_completed = None
|
||||
|
||||
async def sync_mapping(self, mapping: dict[str, Any], *, force: bool = False) -> dict[str, Any]:
|
||||
async def sync_mapping(self, mapping: dict[str, Any], *, force: bool = False, notify: bool = True) -> dict[str, Any]:
|
||||
mapping_id = str(mapping["id"])
|
||||
sync_id = str(uuid4())
|
||||
if self.embedding.dimensions is None:
|
||||
@@ -111,6 +112,7 @@ class WikiSyncService:
|
||||
and existing.get("content_hash") == page["content_hash"]
|
||||
and existing.get("indexed_fingerprint") == active_fingerprint
|
||||
and existing.get("index_status") == "ready"
|
||||
and (existing.get("vector_count", 1) > 0 or page["status"] == "archived" or not (page["content_md"] or page["summary"]))
|
||||
and not existing.get("is_remote_deleted")
|
||||
):
|
||||
await self.store.touch_page(str(existing["id"]), sync_id)
|
||||
@@ -160,7 +162,10 @@ class WikiSyncService:
|
||||
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}
|
||||
result = {"mapping_id": mapping_id, "status": "partial" if failed else "completed", "remote_pages": remote_count, "changed_pages": changed, "deleted_pages": deleted, "failed_pages": failed}
|
||||
if not failed and remote_count and notify and self.on_completed:
|
||||
result["assistant_sync"] = await self.on_completed(mapping)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await self.store.finish_sync(
|
||||
mapping_id,
|
||||
|
||||
+1
@@ -65,6 +65,7 @@ class AssistantKnowledgeImportJobRow(Base):
|
||||
|
||||
class AssistantKnowledgeWikiPageRow(Base):
|
||||
__tablename__ = "assistant_knowledge_wiki_pages"
|
||||
metadata_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict, nullable=True)
|
||||
__table_args__ = (UniqueConstraint("base_id", "slug", name="uq_assistant_wiki_slug"),)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
base_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
|
||||
+136
-61
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import difflib
|
||||
@@ -60,6 +61,12 @@ def _dict(row: Any) -> dict[str, Any]:
|
||||
data["base_kind"] = data.get("base_kind") or "custom"
|
||||
data["is_global"] = data["base_kind"] == "global"
|
||||
data["description"] = data.get("description") or ""
|
||||
if isinstance(row, AssistantKnowledgeWikiPageRow):
|
||||
metadata = data.get("metadata") or {}
|
||||
for key in ("aliases", "tags", "category_path", "parent_slug", "wiki_path", "in_links", "out_links", "source_refs", "origins"):
|
||||
data[key] = metadata.get(key, [] if key not in {"parent_slug", "wiki_path"} else None)
|
||||
data["tags"] = metadata.get("tags") or (metadata.get("page_metadata") or {}).get("tags") or []
|
||||
data["content"] = data.get("content_markdown", "")
|
||||
return data
|
||||
|
||||
|
||||
@@ -95,7 +102,11 @@ def _vector_blob_from_payload(raw: dict[str, Any]) -> tuple[bytes, int] | None:
|
||||
try:
|
||||
decoded = base64.b64decode(str(blob64), validate=True)
|
||||
vector = decode_vector(decoded, int(dimensions))
|
||||
return encode_vector(vector)
|
||||
# Our packages contain normalized float32 bytes. Preserve those
|
||||
# bytes exactly: another normalization causes round-trip drift.
|
||||
if not np.isclose(np.linalg.norm(vector), 1.0, rtol=1e-5, atol=1e-6):
|
||||
return None
|
||||
return decoded, int(dimensions)
|
||||
except (ValueError, TypeError, binascii.Error):
|
||||
return None
|
||||
value = _first_vector_value(raw)
|
||||
@@ -115,6 +126,66 @@ class AssistantKnowledgeRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def iter_export_records(self, base_id: str):
|
||||
"""Export current Wiki articles and their original vectors, without encoding."""
|
||||
base = await self.get_base(base_id)
|
||||
if not base:
|
||||
raise ValueError("助手知识库不存在")
|
||||
yield "source", {"package_version": 1, "provider": "assistant", "type": "assistant", "knowledge_base_id": base_id, "knowledge_base_name": base["name"], "export_format": "assistant-knowledge-jsonl"}
|
||||
async with self._sf() as session:
|
||||
pages = select(AssistantKnowledgeWikiPageRow).where(AssistantKnowledgeWikiPageRow.base_id == base_id, AssistantKnowledgeWikiPageRow.status == "active", AssistantKnowledgeWikiPageRow.page_type != "chunk")
|
||||
stream = await session.stream_scalars(pages.execution_options(yield_per=100))
|
||||
async for page in stream:
|
||||
data = _dict(page)
|
||||
yield "wiki_page", {**(page.metadata_json or {}), **{k: data[k] for k in ("slug", "title", "page_type", "summary", "content")}, "status": "published"}
|
||||
vectors = (
|
||||
select(AssistantKnowledgeVectorRow, AssistantKnowledgeWikiPageRow.slug)
|
||||
.join(AssistantKnowledgeWikiPageRow, AssistantKnowledgeWikiPageRow.id == AssistantKnowledgeVectorRow.page_id)
|
||||
.join(AssistantKnowledgeWikiRevisionRow, AssistantKnowledgeWikiRevisionRow.id == AssistantKnowledgeWikiPageRow.current_revision_id)
|
||||
.where(
|
||||
AssistantKnowledgeVectorRow.base_id == base_id,
|
||||
AssistantKnowledgeWikiPageRow.status == "active",
|
||||
AssistantKnowledgeWikiPageRow.page_type != "chunk",
|
||||
AssistantKnowledgeVectorRow.import_job_id == AssistantKnowledgeWikiRevisionRow.import_job_id,
|
||||
)
|
||||
)
|
||||
stream = await session.stream(vectors.execution_options(yield_per=100))
|
||||
async for vector, slug in stream:
|
||||
yield (
|
||||
"vector",
|
||||
{
|
||||
"wiki_slug": slug,
|
||||
"section_index": vector.section_index,
|
||||
"heading": vector.heading,
|
||||
"section_content": vector.section_content,
|
||||
"content_hash": vector.content_hash,
|
||||
"embedding_model": vector.embedding_model,
|
||||
"embedding_fingerprint": vector.embedding_fingerprint,
|
||||
"embedding_dimensions": vector.embedding_dimensions,
|
||||
"vector_blob_base64": base64.b64encode(vector.vector_blob).decode("ascii"),
|
||||
},
|
||||
)
|
||||
stream = await session.stream(
|
||||
select(AssistantKnowledgeEntityRow, AssistantKnowledgeWikiPageRow.slug)
|
||||
.outerjoin(AssistantKnowledgeWikiPageRow, AssistantKnowledgeWikiPageRow.id == AssistantKnowledgeEntityRow.wiki_page_id)
|
||||
.where(AssistantKnowledgeEntityRow.base_id == base_id, AssistantKnowledgeEntityRow.status == "active")
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
async for entity, slug in stream:
|
||||
yield "entity", {"id": entity.id, "name": entity.display_name, "type": entity.entity_type, "aliases": entity.aliases_json or [], "attributes": entity.attributes_json or {}, "source_wiki_slug": slug}
|
||||
stream = await session.stream(
|
||||
select(AssistantKnowledgeRelationRow, AssistantKnowledgeWikiPageRow.slug)
|
||||
.join(AssistantKnowledgeEntityRow, AssistantKnowledgeEntityRow.id == AssistantKnowledgeRelationRow.source_entity_id)
|
||||
.outerjoin(AssistantKnowledgeWikiPageRow, AssistantKnowledgeWikiPageRow.id == AssistantKnowledgeEntityRow.wiki_page_id)
|
||||
.where(AssistantKnowledgeRelationRow.base_id == base_id, AssistantKnowledgeRelationRow.status == "active")
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
async for relation, slug in stream:
|
||||
yield (
|
||||
"relation",
|
||||
{"source_entity_id": relation.source_entity_id, "target_entity_id": relation.target_entity_id, "predicate": relation.predicate, "description": relation.description, "source_wiki_slug": slug},
|
||||
)
|
||||
|
||||
async def _base_with_stats(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -138,6 +209,7 @@ class AssistantKnowledgeRepository:
|
||||
select(func.count(AssistantKnowledgeWikiPageRow.id)).where(
|
||||
AssistantKnowledgeWikiPageRow.base_id == row.id,
|
||||
AssistantKnowledgeWikiPageRow.status == "active",
|
||||
AssistantKnowledgeWikiPageRow.page_type != "chunk",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -195,14 +267,7 @@ class AssistantKnowledgeRepository:
|
||||
# Backward compatibility: earlier builds had a single active row
|
||||
# and no ``base_kind``. Promote the first surviving base into the
|
||||
# global full-library aggregation base.
|
||||
row = (
|
||||
await session.execute(
|
||||
select(AssistantKnowledgeBaseRow)
|
||||
.where(AssistantKnowledgeBaseRow.status != "deleted")
|
||||
.order_by(AssistantKnowledgeBaseRow.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
row = (await session.execute(select(AssistantKnowledgeBaseRow).where(AssistantKnowledgeBaseRow.status != "deleted").order_by(AssistantKnowledgeBaseRow.created_at.asc()).limit(1))).scalar_one_or_none()
|
||||
if row is not None:
|
||||
row.base_kind = "global"
|
||||
if not row.description:
|
||||
@@ -655,7 +720,7 @@ class AssistantKnowledgeRepository:
|
||||
|
||||
entity_map: dict[str, str] = {}
|
||||
entity_rows: dict[str, tuple[AssistantKnowledgeEntityRow, dict[str, Any]]] = {}
|
||||
raw_entities = list(package.get("entities") or [])
|
||||
raw_entities = package.get("entities") or []
|
||||
for raw in raw_entities:
|
||||
display_name = str(raw.get("name") or raw.get("display_name") or "").strip()
|
||||
entity_type = str(raw.get("type") or raw.get("entity_type") or "concept")
|
||||
@@ -698,7 +763,7 @@ class AssistantKnowledgeRepository:
|
||||
)
|
||||
)
|
||||
|
||||
raw_relations = list(package.get("relations") or [])
|
||||
raw_relations = package.get("relations") or []
|
||||
relation_rows: dict[str, tuple[AssistantKnowledgeRelationRow, dict[str, Any]]] = {}
|
||||
for raw in raw_relations:
|
||||
source_entity_id = entity_map.get(str(raw.get("source_entity_id") or raw.get("source_local_id") or raw.get("source")))
|
||||
@@ -741,6 +806,10 @@ class AssistantKnowledgeRepository:
|
||||
)
|
||||
|
||||
for entity, raw in entity_rows.values():
|
||||
# Provider Wiki graph nodes already have real articles. Do not
|
||||
# turn each node into a second, empty 'entity' article.
|
||||
if raw.get("source_wiki_slug"):
|
||||
continue
|
||||
aliases = list(dict.fromkeys([*list(entity.aliases_json or []), *list(raw.get("aliases") or [])]))
|
||||
entity.aliases_json = aliases
|
||||
content = "\n".join(
|
||||
@@ -775,6 +844,8 @@ class AssistantKnowledgeRepository:
|
||||
entity.wiki_page_id = entity_page.id
|
||||
|
||||
for relation, raw in relation_rows.values():
|
||||
if raw.get("source_wiki_slug"):
|
||||
continue
|
||||
source_entity = await session.get(AssistantKnowledgeEntityRow, relation.source_entity_id)
|
||||
target_entity = await session.get(AssistantKnowledgeEntityRow, relation.target_entity_id)
|
||||
source_name_display = source_entity.display_name if source_entity else relation.source_entity_id
|
||||
@@ -796,7 +867,7 @@ class AssistantKnowledgeRepository:
|
||||
)
|
||||
relation.wiki_page_id = relation_page.id
|
||||
|
||||
pages = list(package.get("wiki_pages") or [])
|
||||
pages = package.get("wiki_pages") or []
|
||||
if not pages and package.get("wiki"):
|
||||
wiki = package["wiki"]
|
||||
page = wiki.get("skill_page") if isinstance(wiki, dict) else None
|
||||
@@ -807,7 +878,7 @@ class AssistantKnowledgeRepository:
|
||||
continue
|
||||
slug = str(raw.get("slug") or f"assistant/sources/{source.id}").strip("/")
|
||||
title = str(raw.get("title") or source_name)
|
||||
content = str(raw.get("content") or raw.get("content_markdown") or "")
|
||||
content = str(raw.get("content") or raw.get("content_md") or raw.get("content_markdown") or "")
|
||||
summary = str(raw.get("summary") or "")
|
||||
stmt = select(AssistantKnowledgeWikiPageRow).where(AssistantKnowledgeWikiPageRow.base_id == base["id"], AssistantKnowledgeWikiPageRow.slug == slug)
|
||||
page = (await session.execute(stmt)).scalar_one_or_none()
|
||||
@@ -850,32 +921,35 @@ class AssistantKnowledgeRepository:
|
||||
page.current_revision_id = revision.id
|
||||
page.status = "active"
|
||||
page.updated_at = now
|
||||
metadata = {key: raw[key] for key in ("aliases", "tags", "page_metadata", "category_path", "parent_slug", "wiki_path", "in_links", "out_links", "source_refs", "chunk_refs", "version") if key in raw}
|
||||
source_meta = package.get("source") or {}
|
||||
origin_mapping = mapping_id
|
||||
if not origin_mapping and (source_meta.get("provider") or source_meta.get("type")) == "weknora":
|
||||
origin_mapping = source_meta.get("knowledge_base_id")
|
||||
origins = [*((page.metadata_json or {}).get("origins") or []), *(raw.get("origins") or [])]
|
||||
origin = {"mapping_id": origin_mapping, "source_key": source_key, "source_name": source_name, "slug": slug}
|
||||
origins = [value for value in origins if isinstance(value, dict) and value.get("source_key") != source_key]
|
||||
metadata["origins"] = list({(v.get("mapping_id"), v.get("source_key"), v.get("slug")): v for v in [*origins, origin]}.values())
|
||||
page.metadata_json = metadata
|
||||
page_ids_by_slug[slug] = page.id
|
||||
for entity, raw_entity in entity_rows.values():
|
||||
if raw_entity.get("source_wiki_slug") == slug:
|
||||
entity.wiki_page_id = page.id
|
||||
if len(page_ids_by_slug) % 50 == 0:
|
||||
await session.flush()
|
||||
|
||||
chunks = list(package.get("chunks") or [])
|
||||
chunks = package.get("chunks") or []
|
||||
chunk_pages = 0
|
||||
imported_vectors = 0
|
||||
for index, raw in enumerate(chunks):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
content = str(
|
||||
raw.get("content")
|
||||
or raw.get("section_content")
|
||||
or raw.get("text")
|
||||
or raw.get("chunk_content")
|
||||
or ""
|
||||
).strip()
|
||||
content = str(raw.get("content") or raw.get("section_content") or raw.get("text") or raw.get("chunk_content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
raw_chunk_id = str(raw.get("id") or raw.get("chunk_id") or raw.get("remote_id") or f"{source.id}:{index}")
|
||||
document_id = str(raw.get("source_document_id") or raw.get("knowledge_id") or raw.get("document_id") or "")
|
||||
title = str(
|
||||
raw.get("title")
|
||||
or raw.get("knowledge_title")
|
||||
or raw.get("filename")
|
||||
or raw.get("knowledge_filename")
|
||||
or f"{source_name} 片段 {index + 1}"
|
||||
)
|
||||
title = str(raw.get("title") or raw.get("knowledge_title") or raw.get("filename") or raw.get("knowledge_filename") or f"{source_name} 片段 {index + 1}")
|
||||
heading = str(raw.get("heading") or raw.get("section_title") or "")
|
||||
summary = content[:240]
|
||||
page_content = "\n".join(
|
||||
@@ -919,7 +993,7 @@ class AssistantKnowledgeRepository:
|
||||
import_job_id=job.id,
|
||||
source_vector_id=str(raw.get("vector_id") or raw.get("embedding_id") or raw_chunk_id),
|
||||
source_chunk_id=raw_chunk_id,
|
||||
section_index=int(raw.get("section_index") or raw.get("chunk_index") or index),
|
||||
section_index=int(next((raw[key] for key in ("section_index", "chunk_index") if raw.get(key) is not None), index)),
|
||||
heading=heading or None,
|
||||
section_content=content,
|
||||
content_hash=str(raw.get("content_hash") or _content_hash(content)),
|
||||
@@ -933,19 +1007,19 @@ class AssistantKnowledgeRepository:
|
||||
imported_vectors += 1
|
||||
chunk_pages += 1
|
||||
|
||||
for index, raw in enumerate(list(package.get("vectors") or [])):
|
||||
for index, raw in enumerate(package.get("vectors") or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
raise ValueError("向量记录必须为对象")
|
||||
slug = str(raw.get("wiki_slug") or raw.get("page_slug") or raw.get("slug") or "").strip("/")
|
||||
page_id = page_ids_by_slug.get(slug)
|
||||
if not page_id:
|
||||
continue
|
||||
raise ValueError(f"向量引用不存在的 Wiki:{slug}")
|
||||
content = str(raw.get("section_content") or raw.get("content") or raw.get("text") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
vector_blob = _vector_blob_from_payload(raw)
|
||||
if vector_blob is None:
|
||||
continue
|
||||
raise ValueError(f"Wiki 向量格式无效:{slug}")
|
||||
blob, dimensions = vector_blob
|
||||
raw_chunk_id = str(raw.get("source_chunk_id") or raw.get("chunk_id") or "")
|
||||
session.add(
|
||||
@@ -957,7 +1031,7 @@ class AssistantKnowledgeRepository:
|
||||
import_job_id=job.id,
|
||||
source_vector_id=str(raw.get("id") or raw.get("vector_id") or f"{slug}:{index}"),
|
||||
source_chunk_id=raw_chunk_id or None,
|
||||
section_index=int(raw.get("section_index") or index),
|
||||
section_index=int(raw["section_index"]) if raw.get("section_index") is not None else index,
|
||||
heading=str(raw.get("heading") or "") or None,
|
||||
section_content=content,
|
||||
content_hash=str(raw.get("content_hash") or _content_hash(content)),
|
||||
@@ -969,9 +1043,11 @@ class AssistantKnowledgeRepository:
|
||||
)
|
||||
)
|
||||
imported_vectors += 1
|
||||
if imported_vectors % 50 == 0:
|
||||
await session.flush()
|
||||
|
||||
counts = {
|
||||
"wiki_pages": len(pages) + len(entity_rows) + len(relation_rows),
|
||||
"wiki_pages": len(pages) + sum(not raw.get("source_wiki_slug") for _, raw in entity_rows.values()) + sum(not raw.get("source_wiki_slug") for _, raw in relation_rows.values()),
|
||||
"documents": len(package.get("documents") or []),
|
||||
"chunks": len(chunks),
|
||||
"chunk_pages": chunk_pages,
|
||||
@@ -1008,6 +1084,7 @@ class AssistantKnowledgeRepository:
|
||||
query: str | None = None,
|
||||
page_type: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include_chunks: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
base = await self.get_base(base_id)
|
||||
@@ -1024,7 +1101,7 @@ class AssistantKnowledgeRepository:
|
||||
stmt = stmt.where(or_(AssistantKnowledgeWikiPageRow.title.ilike(needle), AssistantKnowledgeWikiPageRow.search_text.ilike(needle)))
|
||||
if page_type:
|
||||
stmt = stmt.where(AssistantKnowledgeWikiPageRow.page_type == page_type)
|
||||
stmt = stmt.order_by(AssistantKnowledgeWikiPageRow.updated_at.desc()).limit(limit)
|
||||
stmt = stmt.order_by(AssistantKnowledgeWikiPageRow.slug, AssistantKnowledgeWikiPageRow.id).offset(offset).limit(limit)
|
||||
async with self._sf() as session:
|
||||
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
|
||||
|
||||
@@ -1070,10 +1147,7 @@ class AssistantKnowledgeRepository:
|
||||
or_(
|
||||
AssistantKnowledgeWikiPageRow.title.ilike(needle),
|
||||
AssistantKnowledgeWikiPageRow.search_text.ilike(needle),
|
||||
*[
|
||||
AssistantKnowledgeWikiPageRow.search_text.ilike(f"%{token}%")
|
||||
for token in tokens[:8]
|
||||
],
|
||||
*[AssistantKnowledgeWikiPageRow.search_text.ilike(f"%{token}%") for token in tokens[:8]],
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(AssistantKnowledgeWikiPageRow.updated_at.desc()).limit(max(limit * 8, limit))
|
||||
@@ -1098,6 +1172,7 @@ class AssistantKnowledgeRepository:
|
||||
results = rows[:limit]
|
||||
for row in results:
|
||||
row["score"] = score(row)
|
||||
row["retrieval_mode"] = "keyword"
|
||||
return results
|
||||
|
||||
async def _search_pages_by_vector(
|
||||
@@ -1119,31 +1194,36 @@ class AssistantKnowledgeRepository:
|
||||
AssistantKnowledgeWikiPageRow,
|
||||
AssistantKnowledgeWikiPageRow.id == AssistantKnowledgeVectorRow.page_id,
|
||||
)
|
||||
.join(AssistantKnowledgeWikiRevisionRow, AssistantKnowledgeWikiRevisionRow.id == AssistantKnowledgeWikiPageRow.current_revision_id)
|
||||
.where(
|
||||
AssistantKnowledgeVectorRow.base_id.in_(base_ids),
|
||||
AssistantKnowledgeVectorRow.import_job_id == AssistantKnowledgeWikiRevisionRow.import_job_id,
|
||||
AssistantKnowledgeVectorRow.embedding_dimensions == dimensions,
|
||||
AssistantKnowledgeVectorRow.embedding_fingerprint == embedding_client.fingerprint,
|
||||
AssistantKnowledgeWikiPageRow.status == "active",
|
||||
)
|
||||
.order_by(AssistantKnowledgeVectorRow.created_at.desc())
|
||||
)
|
||||
if not include_chunks:
|
||||
stmt = stmt.where(AssistantKnowledgeWikiPageRow.page_type != "chunk")
|
||||
async with self._sf() as session:
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
if not rows:
|
||||
return []
|
||||
tokens = [token for token in query.strip().lower().split() if token]
|
||||
ranked: list[tuple[float, AssistantKnowledgeVectorRow, AssistantKnowledgeWikiPageRow]] = []
|
||||
for vector, page in rows:
|
||||
try:
|
||||
stored = decode_vector(vector.vector_blob, int(vector.embedding_dimensions))
|
||||
except ValueError:
|
||||
continue
|
||||
score = float(np.dot(query_vector, stored))
|
||||
if tokens:
|
||||
haystack = f"{page.title}\n{page.summary}\n{vector.heading or ''}\n{vector.section_content}".lower()
|
||||
score += min(sum(1 for token in tokens if token in haystack), 4) * 0.02
|
||||
ranked.append((score, vector, page))
|
||||
# Bounded scan: keep only the best section per page, and at most limit
|
||||
# candidates. Never load a multi-GB vector library into one matrix.
|
||||
async with self._sf() as session:
|
||||
stream = await session.stream(stmt.execution_options(yield_per=256))
|
||||
async for batch in stream.partitions(256):
|
||||
|
||||
def score_batch():
|
||||
values = []
|
||||
for vector, page in batch:
|
||||
values.append((float(np.dot(query_vector, decode_vector(vector.vector_blob, dimensions))), vector, page))
|
||||
return values
|
||||
|
||||
candidates = ranked + await asyncio.to_thread(score_batch)
|
||||
best = {}
|
||||
for item in sorted(candidates, key=lambda item: item[0], reverse=True):
|
||||
best.setdefault(item[2].id, item)
|
||||
ranked = list(best.values())[:limit]
|
||||
ranked.sort(key=lambda item: item[0], reverse=True)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
@@ -1320,12 +1400,7 @@ class AssistantKnowledgeRepository:
|
||||
if base is None:
|
||||
return []
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(AssistantKnowledgeImportJobRow)
|
||||
.where(AssistantKnowledgeImportJobRow.base_id == base["id"])
|
||||
.order_by(AssistantKnowledgeImportJobRow.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = select(AssistantKnowledgeImportJobRow).where(AssistantKnowledgeImportJobRow.base_id == base["id"]).order_by(AssistantKnowledgeImportJobRow.created_at.desc()).limit(limit)
|
||||
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
|
||||
|
||||
async def get_job(self, job_id: str) -> dict[str, Any] | None:
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ class MemoryLlmWikiIndexStore(LlmWikiIndexStore):
|
||||
|
||||
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
|
||||
return {**row, "vector_count": len(self.vectors.get(row["id"], []))} 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)
|
||||
|
||||
+4
-3
@@ -41,10 +41,11 @@ class SqlLlmWikiIndexStore(LlmWikiIndexStore):
|
||||
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)
|
||||
count = select(func.count(LlmWikiWikiVectorRow.id)).where(LlmWikiWikiVectorRow.page_id == LlmWikiWikiPageRow.id).correlate(LlmWikiWikiPageRow).scalar_subquery()
|
||||
stmt = select(LlmWikiWikiPageRow, count).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
|
||||
result = (await session.execute(stmt)).one_or_none()
|
||||
return {**_dict(result[0]), "vector_count": result[1]} if result 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)
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""Preserve source Wiki metadata during package round trips."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from deerflow.persistence.types import PortableJSON
|
||||
|
||||
revision = "20260903_01"
|
||||
down_revision = ("20260902_01", "20260831_01")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
columns = {col["name"] for col in sa.inspect(op.get_bind()).get_columns("assistant_knowledge_wiki_pages")}
|
||||
if "metadata_json" not in columns:
|
||||
op.add_column("assistant_knowledge_wiki_pages", sa.Column("metadata_json", PortableJSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("assistant_knowledge_wiki_pages", "metadata_json")
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
postgres = ["deerflow-harness[postgres]"]
|
||||
mysql = ["deerflow-harness[mysql]"]
|
||||
gauss = ["deerflow-harness[gauss]"]
|
||||
local-embedding = ["fastembed>=0.7,<0.9"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -13,6 +13,7 @@ from deerflow.persistence.base import Base
|
||||
|
||||
class FakeEmbeddingClient:
|
||||
dimensions = 3
|
||||
fingerprint = "test-bge-m3"
|
||||
|
||||
async def embed_query(self, _query: str) -> list[float]:
|
||||
return [1.0, 0.0, 0.0]
|
||||
@@ -154,6 +155,7 @@ async def test_imported_vectors_are_reused_for_assistant_search(
|
||||
"section_index": 0,
|
||||
"section_content": "张三负责向量化检索。",
|
||||
"embedding_model": "bge-embedding-m3",
|
||||
"embedding_fingerprint": "test-bge-m3",
|
||||
"embedding_dimensions": 3,
|
||||
"vector": [1.0, 0.0, 0.0],
|
||||
},
|
||||
@@ -162,6 +164,7 @@ async def test_imported_vectors_are_reused_for_assistant_search(
|
||||
"section_index": 1,
|
||||
"section_content": "无关片段。",
|
||||
"embedding_model": "bge-embedding-m3",
|
||||
"embedding_fingerprint": "test-bge-m3",
|
||||
"embedding_dimensions": 3,
|
||||
"vector": [0.0, 1.0, 0.0],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Ordinary knowledge-base editing and remote-deletion reconciliation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient
|
||||
from deerflow.persistence.llmwiki import MemoryLlmWikiStore
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def knowledge_api(monkeypatch: pytest.MonkeyPatch):
|
||||
from app.gateway.routers import llmwiki
|
||||
|
||||
store = MemoryLlmWikiStore()
|
||||
row = await store.create_mapping(weknora_id="remote-one", owner_user_id="owner", name="原名称", description="原描述", kb_type="document")
|
||||
await store.request_publish(row["id"], "owner", is_admin=False)
|
||||
state = SimpleNamespace(
|
||||
store=store,
|
||||
mapping_id=row["id"],
|
||||
remote={"remote-one": {"id": "remote-one", "name": "原名称", "description": "原描述", "knowledge_count": 3}},
|
||||
user_id="owner",
|
||||
is_admin=False,
|
||||
upstream_status=200,
|
||||
writes=[],
|
||||
)
|
||||
|
||||
async def upstream(request: httpx.Request) -> httpx.Response:
|
||||
if state.upstream_status != 200:
|
||||
return httpx.Response(state.upstream_status, json={"message": "知识服务暂不可用"})
|
||||
if request.url.path == "/api/v1/knowledge-bases":
|
||||
assert request.method == "GET"
|
||||
return httpx.Response(200, json={"data": list(state.remote.values())})
|
||||
remote_id = request.url.path.rsplit("/", 1)[-1]
|
||||
if remote_id not in state.remote:
|
||||
return httpx.Response(404)
|
||||
if request.method == "PUT":
|
||||
changes = json.loads(request.content)
|
||||
state.writes.append(changes)
|
||||
state.remote[remote_id].update(changes)
|
||||
else:
|
||||
assert request.method == "GET"
|
||||
return httpx.Response(200, json={"data": state.remote[remote_id]})
|
||||
|
||||
async def actor(_: object) -> tuple[str, bool]:
|
||||
return state.user_id, state.is_admin
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(llmwiki.router)
|
||||
monkeypatch.setattr(llmwiki, "_actor", actor)
|
||||
monkeypatch.setattr(llmwiki, "_store", lambda _: store)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream)) as remote_client:
|
||||
client = WeKnoraClient(base_url="http://weknora.test", http_client=remote_client)
|
||||
monkeypatch.setattr(llmwiki, "_client_or_503", lambda: client)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://gateway.test") as api:
|
||||
state.api = api
|
||||
yield state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", ["personal", "public", "all"])
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
async def test_lists_hide_remote_deleted_bases_without_deleting_mappings(knowledge_api, scope: str, is_admin: bool) -> None:
|
||||
state = knowledge_api
|
||||
state.is_admin = is_admin
|
||||
live = await state.store.create_mapping(weknora_id="remote-live", owner_user_id="owner", name="保留知识库", description="", kb_type="document")
|
||||
await state.store.request_publish(live["id"], "owner", is_admin=False)
|
||||
state.remote["remote-live"] = {"id": "remote-live", "name": "保留知识库"}
|
||||
deleted = state.remote.pop("remote-one")
|
||||
|
||||
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [item["id"] for item in response.json()["knowledge_bases"]] == [live["id"]]
|
||||
assert await state.store.get_by_weknora_id("remote-one") is not None
|
||||
|
||||
state.remote.clear()
|
||||
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
||||
assert response.json()["knowledge_bases"] == []
|
||||
|
||||
state.remote["remote-one"] = deleted
|
||||
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": scope})
|
||||
assert [item["id"] for item in response.json()["knowledge_bases"]] == [state.mapping_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [502, 504])
|
||||
async def test_list_upstream_failure_is_not_treated_as_remote_deletion(knowledge_api, status: int) -> None:
|
||||
state = knowledge_api
|
||||
state.upstream_status = status
|
||||
|
||||
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
||||
|
||||
assert response.status_code >= 500
|
||||
assert await state.store.get_by_weknora_id("remote-one") is not None
|
||||
state.upstream_status = 200
|
||||
response = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
||||
assert [item["id"] for item in response.json()["knowledge_bases"]] == [state.mapping_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("changes", "expected_name", "expected_description"),
|
||||
[
|
||||
({"name": " 新名称 ", "description": "新描述"}, "新名称", "新描述"),
|
||||
({"name": "仅修改名称"}, "仅修改名称", "原描述"),
|
||||
({"description": "仅修改描述"}, "原名称", "仅修改描述"),
|
||||
({"name": "清空描述", "description": ""}, "清空描述", ""),
|
||||
],
|
||||
)
|
||||
async def test_edit_synchronizes_remote_and_local_metadata(knowledge_api, changes, expected_name: str, expected_description: str) -> None:
|
||||
state = knowledge_api
|
||||
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json=changes)
|
||||
|
||||
assert response.status_code == 200
|
||||
item = response.json()
|
||||
assert (item["name"], item["description"]) == (expected_name, expected_description)
|
||||
assert item["remote_status"] == "available"
|
||||
assert item["knowledge_count"] == 3
|
||||
assert item["publication_status"] == "published"
|
||||
local = await state.store.get_by_weknora_id("remote-one")
|
||||
assert (local["name"], local["description"]) == (expected_name, expected_description)
|
||||
assert state.writes == [{**changes, "name": expected_name}]
|
||||
listing = await state.api.get("/api/llmwiki/knowledge-bases", params={"scope": "personal"})
|
||||
assert listing.json()["knowledge_bases"][0]["name"] == expected_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name", ["", " \t ", "x" * 256, "对话沉淀", " 对话沉淀 "])
|
||||
async def test_edit_rejects_invalid_or_reserved_names(knowledge_api, name: str) -> None:
|
||||
state = knowledge_api
|
||||
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": name})
|
||||
|
||||
assert response.status_code in {409, 422}
|
||||
assert state.writes == []
|
||||
assert (await state.store.get_by_weknora_id("remote-one"))["name"] == "原名称"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
async def test_only_owner_or_admin_can_edit_a_public_base(knowledge_api, is_admin: bool) -> None:
|
||||
state = knowledge_api
|
||||
state.user_id = "other-user"
|
||||
state.is_admin = is_admin
|
||||
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称"})
|
||||
|
||||
assert response.status_code == (200 if is_admin else 404)
|
||||
assert len(state.writes) == (1 if is_admin else 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_edit_system_conversation_deposit(knowledge_api) -> None:
|
||||
state = knowledge_api
|
||||
state.is_admin = True
|
||||
await state.store.adopt_conversation_deposit_mapping(state.mapping_id, name="对话沉淀", description="系统知识库")
|
||||
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert state.writes == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status", [404, 502, 504])
|
||||
async def test_failed_remote_edit_leaves_local_metadata_unchanged(knowledge_api, status: int) -> None:
|
||||
state = knowledge_api
|
||||
state.upstream_status = status
|
||||
response = await state.api.patch(f"/api/llmwiki/knowledge-bases/{state.mapping_id}", json={"name": "新名称", "description": "新描述"})
|
||||
|
||||
if status == 404:
|
||||
assert response.status_code == 404
|
||||
else:
|
||||
assert response.status_code >= 500
|
||||
local = await state.store.get_by_weknora_id("remote-one")
|
||||
assert (local["name"], local["description"]) == ("原名称", "原描述")
|
||||
@@ -0,0 +1,207 @@
|
||||
import base64
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.routers import wiki_packages
|
||||
from deerflow.assistant_knowledge.archive import PackageArchive
|
||||
from deerflow.assistant_knowledge.package_io import write_package
|
||||
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig
|
||||
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_codec import encode_vector
|
||||
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
store = AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
blob, _ = encode_vector([0.3, 0.5, 0.7])
|
||||
pages = [
|
||||
{
|
||||
"slug": "concept/cooling",
|
||||
"title": "散热",
|
||||
"page_type": "concept",
|
||||
"content": "散热管理用于降低设备温度。参见 [[entity/fan]]",
|
||||
"summary": "设备散热",
|
||||
"tags": ["设备", "维护"],
|
||||
"category_path": ["运维", "硬件"],
|
||||
"aliases": ["降温"],
|
||||
"out_links": ["entity/fan"],
|
||||
"in_links": [],
|
||||
},
|
||||
{"slug": "entity/fan", "title": "风扇", "page_type": "entity", "content": "风扇通过气流帮助散热,参见 [[concept/cooling]]。", "aliases": [], "out_links": ["concept/cooling"], "in_links": ["concept/cooling"]},
|
||||
]
|
||||
vectors = [
|
||||
{
|
||||
"wiki_slug": p["slug"],
|
||||
"section_index": 0,
|
||||
"section_content": p["content"],
|
||||
"content_hash": "test",
|
||||
"embedding_model": "test-model",
|
||||
"embedding_fingerprint": "test-fp",
|
||||
"embedding_dimensions": 3,
|
||||
"vector_blob_base64": base64.b64encode(blob).decode(),
|
||||
}
|
||||
for p in pages
|
||||
]
|
||||
original = tmp_path / "original.jsonl"
|
||||
entities = [{"id": p["slug"], "name": p["title"], "type": p["page_type"], "source_wiki_slug": p["slug"]} for p in pages]
|
||||
relations = [{"source_entity_id": pages[0]["slug"], "target_entity_id": pages[1]["slug"], "source_wiki_slug": pages[0]["slug"], "predicate": "references"}]
|
||||
write_package(original, [
|
||||
("source", {"provider": "weknora", "package_version": 1}),
|
||||
*[("wiki_page", p) for p in pages],
|
||||
*[("vector", v) for v in vectors],
|
||||
*[("entity", e) for e in entities],
|
||||
*[("relation", r) for r in relations],
|
||||
("chunk", {"id": "raw-chunk", "content": "原始文档分段不是 Wiki 页面"}),
|
||||
])
|
||||
archive = PackageArchive(original)
|
||||
base = await store.initialize(created_by="admin")
|
||||
job = await store.import_package(package=archive, source_type="weknora", source_key="source", source_name="来源", trigger="test", created_by="admin")
|
||||
assert job["counts"]["vectors"] == 2
|
||||
assert job["counts"]["wiki_pages"] == 2
|
||||
assert job["counts"]["chunk_pages"] == 1
|
||||
imported = await store.get_page(pages[0]["slug"])
|
||||
for field in ("category_path", "tags", "aliases", "out_links"):
|
||||
assert imported[field] == pages[0][field]
|
||||
assert imported["content_markdown"] == pages[0]["content"]
|
||||
# The global base may receive the same Wiki through multiple libraries.
|
||||
# Only the current article's import supplies vectors; older contributions
|
||||
# remain auditable but must not duplicate/stale the restored index.
|
||||
await store.import_package(package=archive, source_type="weknora", source_key="second-source", source_name="第二来源", trigger="test", created_by="admin")
|
||||
exported = tmp_path / "export.jsonl"
|
||||
write_package(exported, [record async for record in store.iter_export_records(base["id"])])
|
||||
restored_archive = PackageArchive(exported)
|
||||
assert {v["vector_blob_base64"] for v in restored_archive["vectors"]} == {base64.b64encode(blob).decode()}
|
||||
assert sorted((v["wiki_slug"], v["section_index"]) for v in restored_archive["vectors"]) == sorted((v["wiki_slug"], v["section_index"]) for v in vectors)
|
||||
assert all(r["source_wiki_slug"] in {p["slug"] for p in pages} for r in restored_archive["relations"])
|
||||
index = MemoryLlmWikiIndexStore()
|
||||
received = []
|
||||
|
||||
class Remote:
|
||||
async def create_wiki_folder(self, kb, *, name, parent_id=""):
|
||||
return {"id": f"{parent_id}/{name}"}
|
||||
|
||||
async def create_wiki_page(self, kb, page):
|
||||
received.append(page)
|
||||
return page
|
||||
|
||||
async def rebuild_wiki_links(self, kb):
|
||||
pass
|
||||
|
||||
class Embedding:
|
||||
dimensions = 3
|
||||
fingerprint = "test-fp"
|
||||
|
||||
async def embed_query(self, query):
|
||||
raise AssertionError("Existing package must not be encoded")
|
||||
|
||||
app = FastAPI()
|
||||
app.state.llmwiki_index_store = index
|
||||
app.state.llmwiki_embedding = Embedding()
|
||||
app.state.llmwiki_vector_cache = None
|
||||
app.state.config = None
|
||||
monkeypatch.setattr(wiki_packages, "get_resolved_llmwiki_runtime", lambda c: None)
|
||||
monkeypatch.setattr(wiki_packages, "build_weknora_client", lambda c: Remote())
|
||||
monkeypatch.setattr(wiki_packages, "_package_root", lambda: tmp_path)
|
||||
from app.gateway import knowledge_transfer
|
||||
|
||||
async def sync(app, mapping):
|
||||
return {"status": "completed"}
|
||||
|
||||
monkeypatch.setattr(knowledge_transfer, "sync_to_global", sync)
|
||||
await wiki_packages.restore_package(app, {"id": "target", "weknora_id": "remote"}, "job-test", exported)
|
||||
for raw in received:
|
||||
assert raw["content"] == next(p["content"] for p in pages if p["slug"] == raw["slug"])
|
||||
local = await index.get_page("target", raw["slug"])
|
||||
assert local["content_hash"] == normalize_wiki_page("target", raw)["content_hash"]
|
||||
snapshot = await index.load_vector_snapshot("target", "test-fp")
|
||||
assert len(snapshot["rows"]) == 2
|
||||
assert all(r["vector_blob"] == blob for r in snapshot["rows"])
|
||||
|
||||
class SearchEmbedding:
|
||||
dimensions = 3
|
||||
fingerprint = "test-fp"
|
||||
|
||||
async def embed_query(self, query):
|
||||
return [1, 0, 0]
|
||||
|
||||
found = await store.search_pages(base_ids=[base["id"]], query="没有关键词重合的查询", embedding_client=SearchEmbedding())
|
||||
assert found and all(r["retrieval_mode"] == "vector" for r in found)
|
||||
SearchEmbedding.fingerprint = "different-model-same-dimension"
|
||||
assert await store.search_pages(base_ids=[base["id"]], query="没有关键词重合的查询", embedding_client=SearchEmbedding()) == []
|
||||
archive.close()
|
||||
restored_archive.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch):
|
||||
observed = {}
|
||||
|
||||
class LocalModel:
|
||||
@staticmethod
|
||||
def list_supported_models():
|
||||
return [{"model": "BAAI/bge-m3"}]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
observed.update(kwargs)
|
||||
|
||||
def embed(self, texts, *, batch_size):
|
||||
observed["batch_size"] = batch_size
|
||||
return [[1.0, *([0.0] * 1023)] for _ in texts]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "fastembed", SimpleNamespace(TextEmbedding=LocalModel))
|
||||
config = LocalWikiEmbeddingConfig(provider="local", model="bge-embedding-m3", dimensions=1024, batch_size=2, local_threads=2)
|
||||
client = StrictWikiEmbeddingClient(config)
|
||||
assert len(await client.embed_query("本地查询")) == 1024
|
||||
assert observed["model_name"] == "BAAI/bge-m3"
|
||||
assert observed["providers"] == ["CPUExecutionProvider"]
|
||||
assert observed["batch_size"] == observed["threads"] == 2
|
||||
assert client._client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_skill_without_ownership_record_can_be_distilled(monkeypatch, tmp_path):
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.routers import skill_knowledge
|
||||
|
||||
async def admin(_):
|
||||
return "admin"
|
||||
|
||||
class Mappings:
|
||||
async def get_authorized(self, *args, **kwargs):
|
||||
return {"name": "测试库", "weknora_id": "remote"}
|
||||
|
||||
class Jobs:
|
||||
async def create_job(self, **kwargs):
|
||||
return kwargs
|
||||
|
||||
app = FastAPI()
|
||||
app.state.config = None
|
||||
app.state.llmwiki_store = Mappings()
|
||||
app.state.skill_knowledge_store = Jobs()
|
||||
# Deliberately no skill_store ownership row: built-ins live on disk.
|
||||
monkeypatch.setattr(skill_knowledge, "_require_admin", admin)
|
||||
monkeypatch.setattr(skill_knowledge, "_skill_directory", lambda *args: tmp_path)
|
||||
body = skill_knowledge.CreateSyncJobRequest(skill_names=["builtin"], targets=[{"target_type": "weknora", "target_mode": "wiki", "target_id": "mapping"}])
|
||||
job = await skill_knowledge.create_sync_job(Request({"type": "http", "app": app}), body)
|
||||
assert job["skill_names"] == ["builtin"]
|
||||
assert job["review_mode"] == "off"
|
||||
|
||||
|
||||
def test_short_wiki_summary_and_body_still_have_a_vector_section():
|
||||
sections = chunk_wiki_page({"title": "风扇", "summary": "通过气流帮助散热。", "content_md": "# 风扇\n用于降低设备温度。"}, max_chars=1000, overlap=100, min_chars=80, title_prefix_enabled=True)
|
||||
assert len(sections) == 1
|
||||
assert "降低设备温度" in sections[0].embedding_text
|
||||
Reference in new issue
Block a user