feat: improve knowledge sync and entity alignment
This commit is contained in:
1 parent
7dbac9cc56
commit
dea4697ea0
38 files changed
+3742
-634
No files matched your search
@@ -1550,10 +1550,12 @@ detail. Never call `/knowledge-search` from internal search, RAG, or the
|
||||
`deerflow.integrations.weknora.local_index` and
|
||||
`deerflow.persistence.llmwiki_index`; preserve the harness → app import
|
||||
firewall. Gateway composition injects the store/search/sync services through
|
||||
`local_index.runtime`. `enabled` turns on local vector search and manual
|
||||
vectorization; `auto_sync` is the separate opt-in for the lease-protected
|
||||
scheduler. The scheduler sleeps for one full configured interval before its
|
||||
first scan, so application startup must never trigger immediate vectorization.
|
||||
`local_index.runtime`. `enabled` turns on local vector search and `auto_sync`
|
||||
defaults to the lease-protected background scheduler. The scheduler performs
|
||||
an immediate incremental scan at startup, then rescans every configured
|
||||
interval so new WeKnora libraries and asynchronously generated Wiki pages are
|
||||
picked up without a manual vectorization click. Unchanged content must reuse
|
||||
existing vectors.
|
||||
|
||||
Vectors are normalized little-endian float32 BLOBs. A page mirror and all of
|
||||
its vectors are replaced in one transaction only after strict batched
|
||||
@@ -1735,7 +1737,9 @@ snapshot remains authoritative on failure. A binding may have only one active
|
||||
item (`active_dedupe_key` plus atomic lease claims). Skill distillation targets
|
||||
only writable WeKnora Wiki knowledge bases in this product version; reject direct
|
||||
assistant-base targets, non-Wiki/FAQ/conversation/readonly targets, and direct
|
||||
Neo4j writes. Detach/delete operates on source contributions, not canonical rows,
|
||||
Neo4j writes. The create/list/resync/retry/detach flow is available to ordinary
|
||||
users for their own visible skills, writable knowledge bases, jobs and bindings;
|
||||
administrator-only auto-sync/review APIs remain privileged. Detach/delete operates on source contributions, not canonical rows,
|
||||
and shared entities/relations are retired only when their final contribution is
|
||||
gone.
|
||||
|
||||
@@ -1745,7 +1749,10 @@ administrators own all mutations. Assistant bases only accept WeKnora export
|
||||
packages or imports triggered from a WeKnora mapping. Do not add direct skill
|
||||
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.
|
||||
cross-source entity alignment. Admins may rename any assistant base and delete
|
||||
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
||||
relations, import audit rows, and package files; the global base is never
|
||||
deletable because it owns full-library aggregation.
|
||||
|
||||
### Wiki package and local encoder invariants (2026-09)
|
||||
|
||||
@@ -1762,16 +1769,42 @@ cross-source entity alignment.
|
||||
- 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.
|
||||
Preserve a valid zero section_index. Export/search vectors belonging to each
|
||||
current source snapshot (`source.last_import_job_id`), not merely the page's
|
||||
current revision: one aligned page may intentionally contain current vectors
|
||||
from several 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.
|
||||
FastEmbed is a standard backend dependency. Encoder configuration, directory
|
||||
upload and warmup live only in the administrator global settings dialog.
|
||||
A runtime `system_settings.json` path override wins over config.yaml; uploaded
|
||||
files are streamed into `.deer-flow/models/bge-m3`, never the source tree.
|
||||
Encoder warmup is explicitly started from the settings UI/API and must never block application startup
|
||||
or turn a model-load failure into a DeerFlow service failure. Local provider
|
||||
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
|
||||
never let the application download model weights. Keep
|
||||
`local_process_isolation=true` by default: inference runs in the bundled JSONL
|
||||
worker subprocess, and shutdown must terminate that worker without waiting on
|
||||
model teardown.
|
||||
- Every successful mapping snapshot is mirrored to the global assistant base,
|
||||
including empty snapshots so deleted source pages are retired. Re-import is
|
||||
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
|
||||
remain visible merely because they existed in an older source snapshot.
|
||||
- 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.
|
||||
- The global base aligns entities by normalized display name and aliases across
|
||||
source records, and aligns content Wiki pages by normalized title. Do not use
|
||||
source slug as the global canonical identity. Merge unique knowledge blocks
|
||||
while retaining source contributions/origins. Uploaded packages must rebind
|
||||
the temporary upload source to the stable WeKnora knowledge-base id in the
|
||||
package, so re-upload is source-replacement rather than append. Retrieval
|
||||
candidates exclude summary/index/directory pages; keyword matching, vector
|
||||
snippets, citations and prompts must use knowledge body/sections, never page
|
||||
summary as a substitute.
|
||||
- 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
|
||||
|
||||
@@ -912,13 +912,12 @@ LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
|
||||
processed Wiki content. A knowledge base uses exact NumPy cosine similarity
|
||||
only after its whole Wiki has completed one coherent vectorization pass; until
|
||||
then internal search calls WeKnora's Wiki-page search/list API and reads the
|
||||
matched Wiki pages. It never falls back to raw documents or chunks. The local
|
||||
vector feature is disabled by default. Configure the three
|
||||
`llmwiki.local_wiki_index.embedding` values (`model`, `base_url`, and
|
||||
`api_key`), then change only `enabled` to `true` in `config.yaml`. Manual
|
||||
vectorization is the default. Set `auto_sync: true` only when periodic
|
||||
background synchronization is wanted; the scheduler waits one full interval
|
||||
after startup before its first scan, so boot does not trigger a rebuild.
|
||||
matched Wiki pages. It never falls back to raw documents or chunks. Configure
|
||||
`llmwiki.local_wiki_index.embedding`, then enable the local index in
|
||||
`config.yaml`. `auto_sync` defaults to true: the scheduler performs one
|
||||
immediate incremental scan at startup and then scans every configured interval
|
||||
(30 seconds by default). Wiki changes are content-hash checked, so unchanged
|
||||
pages reuse their vectors instead of being encoded again.
|
||||
`database_batch_size` caps each vector insert statement (default `50`) while
|
||||
the page replacement remains atomic, preventing a large Wiki from flooding
|
||||
the SQL connection in one statement.
|
||||
@@ -1121,45 +1120,67 @@ The database schema is introduced by Alembic revision `20260901_01`.
|
||||
|
||||
### Wiki 流转与本地小批量编码
|
||||
|
||||
技能归纳现在上传清理后的知识正文,等待 WeKnora 完成 Wiki 分析,再建立本地
|
||||
所有登录用户都可在「技能 → 归纳到知识库」中多选自己可见的技能,并归纳到一个
|
||||
自己可写的 WeKnora Wiki 知识库;用户只能看到和维护自己发起的归纳记录,管理员
|
||||
接口仍可查看全量记录。技能归纳上传清理后的知识正文,等待 WeKnora 完成 Wiki 分析,再建立本地
|
||||
Wiki 向量并通过落盘导出包自动沉淀到「知识梳理」。内置技能没有数据库所有权记录
|
||||
也可以归纳。若解析结束却没有 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
|
||||
auto_sync: true
|
||||
sync_interval_seconds: 30
|
||||
embedding:
|
||||
provider: local
|
||||
model: bge-embedding-m3
|
||||
dimensions: 1024
|
||||
batch_size: 2
|
||||
local_threads: 2
|
||||
local_process_isolation: true
|
||||
# 离线部署时可指定已下载的 BAAI/bge-m3 模型目录:
|
||||
# local_model_path: /opt/models/bge-m3
|
||||
```
|
||||
|
||||
首次使用需要预下载官方 ONNX 权重(约 2.3 GB),默认持久缓存位于
|
||||
`~/.cache/deerflow/embeddings`,也可设置 `local_cache_dir`。权重不进入业务源码仓库。
|
||||
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
|
||||
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
|
||||
「设置和更多 → 设置 → 本地编码器」中选择服务器目录,或从浏览器上传完整模型目录;
|
||||
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
|
||||
`.deer-flow/models/bge-m3`,均不进入源码仓库。系统强制 `local_files_only`,
|
||||
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
|
||||
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
|
||||
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
|
||||
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
|
||||
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
|
||||
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
|
||||
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
|
||||
数据包导入不重新编码,模型指纹或维度不一致时明确拒绝,不能只按维度混用模型。
|
||||
问答编码查询本身仍需可用的同一模型。WeKnora 的原始文档向量不能当作生成后
|
||||
Wiki 正文的向量使用,两种文本需要分别建立各自的首次索引。
|
||||
|
||||
Wiki 页面新增、编辑、删除及文档上传/重解析都会立即触发一次增量检查;WeKnora
|
||||
后台稍后才完成的 Wiki 抽取由 30 秒周期补扫接管。每次成功向量化后自动把该普通
|
||||
知识库的最新完整快照同步到「知识梳理总库」,包括服务重启前已有库和新建库;来源
|
||||
页面被删除时也会在总库中退役,不保留失效 Wiki。
|
||||
|
||||
「知识梳理总库」不是按来源简单堆叠:导入时会规范化全/半角、空白、标点及常见实体
|
||||
类型,并利用实体别名对齐同一人物、组织、产品或概念;不同来源中同标题的实体 Wiki
|
||||
只保留一个规范页面,正文按去重后的知识块合并,来源与实体/关系贡献仍分别留痕。
|
||||
重复上传同一个 WeKnora 导出包时,以包内稳定知识库 ID 识别同一来源并执行快照替换,
|
||||
不会再产生一批新的片段副本。已有历史重复数据会在总库首次读取、检索、导出或下次
|
||||
同步时自动归并。助手知识检索排除目录、索引和摘要页;关键词只查标题与知识正文,
|
||||
向量检索返回实际命中的正文片段,问答上下文和检索卡片均不再用摘要代替知识。
|
||||
|
||||
大包采用流式 JSONL 落盘、同盘临时 SQLite 分阶段读取及异步导入,避免一次性将
|
||||
整个包加载进内存。部署需预留包、暂存索引和业务数据库空间,并配置代理上传大小
|
||||
与超时。当前不宣称断点续传或 GB 级压力验收已完成。
|
||||
|
||||
@@ -379,6 +379,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
||||
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
|
||||
embedding_config = local_index_config.embedding
|
||||
# The administrator may select or upload the offline encoder from the
|
||||
# UI. This runtime-owned value deliberately overrides config.yaml and
|
||||
# survives application restarts without modifying release assets.
|
||||
if embedding_config.provider == "local":
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
|
||||
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
|
||||
if runtime_model_path:
|
||||
embedding_config = embedding_config.model_copy(
|
||||
update={"local_model_path": runtime_model_path}
|
||||
)
|
||||
embedding_configured = bool(
|
||||
embedding_config.enabled
|
||||
and (embedding_config.base_url or embedding_config.provider == "local")
|
||||
|
||||
@@ -49,9 +49,14 @@ async def _sync_once(app: FastAPI) -> None:
|
||||
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
|
||||
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
|
||||
logger.info(
|
||||
"Local Wiki auto-sync scheduler started; first scan delayed by %s seconds",
|
||||
"Local Wiki auto-sync scheduler started; first scan is immediate, interval=%s seconds",
|
||||
interval,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
await _sync_once(app)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
|
||||
await asyncio.sleep(interval)
|
||||
await _sync_once(app)
|
||||
@@ -170,13 +170,14 @@ def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index:
|
||||
if result.get("kind") == "wiki_page":
|
||||
sections = list(result.get("matched_sections") or [])
|
||||
content = "\n\n".join(str(section.get("content") or "") for section in sections if section.get("content"))
|
||||
content = content or str(result.get("content") or result.get("content_markdown") or "")
|
||||
raw = {**result, "source": mapping.get("name") or "LLMWiki"}
|
||||
return {
|
||||
"index": index,
|
||||
"id": result.get("wiki_page_id") or result.get("id"),
|
||||
"title": result.get("title") or mapping.get("name") or f"Wiki 页面 {index}",
|
||||
"type": "llmwiki_wiki",
|
||||
"snippet": _truncate(content or str(result.get("summary") or ""), 360),
|
||||
"snippet": _truncate(content, 360),
|
||||
"content": content,
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"score": float(result.get("score") or 0),
|
||||
@@ -222,7 +223,9 @@ def _source_from_assistant_result(result: dict[str, Any], index: int) -> dict[st
|
||||
or result.get("summary")
|
||||
or ""
|
||||
)
|
||||
snippet = str(result.get("summary") or content)
|
||||
# The retrieval citation must expose the matched knowledge itself. A page
|
||||
# summary is display metadata and can omit the fact that answered the query.
|
||||
snippet = content
|
||||
raw = {
|
||||
**result,
|
||||
"source": "助手知识库",
|
||||
@@ -348,7 +351,10 @@ async def build_llmwiki_rag_context(
|
||||
mapping = mapping_by_id.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
||||
source = _source_from_result(result, mapping, len(sources) + 1)
|
||||
if not str(source.get("content") or "").strip():
|
||||
continue
|
||||
sources.append(source)
|
||||
if len(sources) >= _MAX_SOURCES:
|
||||
break
|
||||
|
||||
|
||||
@@ -150,6 +150,31 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
||||
"""Delete package artifacts owned by one assistant base only."""
|
||||
root = _package_root()
|
||||
if not root.is_dir():
|
||||
return
|
||||
job_ids = {str(value) for value in import_job_ids if value}
|
||||
scope = f"assistant:{base_id}"
|
||||
for manifest_path in root.glob("*.manifest.json"):
|
||||
try:
|
||||
value = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(value, dict) or str(value.get("mapping_id") or "") != scope:
|
||||
continue
|
||||
job_id = str(value.get("id") or "")
|
||||
if job_id:
|
||||
job_ids.add(job_id)
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
for job_id in job_ids:
|
||||
path = package_path(root, job_id)
|
||||
path.unlink(missing_ok=True)
|
||||
path.with_suffix(".tmp").unlink(missing_ok=True)
|
||||
path.with_suffix(".manifest.json").unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _actor(request: Request, *, admin: bool = False) -> str | None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
@@ -175,6 +200,11 @@ class BaseCreateRequest(BaseModel):
|
||||
description: str = Field(default="", max_length=2048)
|
||||
|
||||
|
||||
class BaseUpdateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str = Field(default="", max_length=2048)
|
||||
|
||||
|
||||
class SkillImportRequest(BaseModel):
|
||||
skill_names: list[str] = Field(min_length=1, max_length=200)
|
||||
force: bool = False
|
||||
@@ -224,6 +254,51 @@ async def get_base_by_id(request: Request, base_id: str) -> dict[str, Any]:
|
||||
return {"base": base}
|
||||
|
||||
|
||||
@router.patch("/bases/{base_id}")
|
||||
async def update_base(request: Request, base_id: str, body: BaseUpdateRequest) -> dict[str, Any]:
|
||||
await _actor(request, admin=True)
|
||||
base = await _store(request).update_base(
|
||||
base_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
)
|
||||
if base is None:
|
||||
raise HTTPException(status_code=404, detail="助手知识库不存在")
|
||||
return {"base": base}
|
||||
|
||||
|
||||
@router.delete("/bases/{base_id}", status_code=204)
|
||||
async def delete_base(request: Request, base_id: str) -> None:
|
||||
await _actor(request, admin=True)
|
||||
store = _store(request)
|
||||
base = await store.get_base(base_id)
|
||||
if base is None:
|
||||
raise HTTPException(status_code=404, detail="助手知识库不存在")
|
||||
if base.get("is_global"):
|
||||
raise HTTPException(status_code=409, detail="知识梳理总库承担全库沉淀,不能删除")
|
||||
jobs = await store.list_jobs(limit=500, base_id=base_id)
|
||||
if any(job.get("status") in {"queued", "running"} for job in jobs):
|
||||
raise HTTPException(status_code=409, detail="知识库正在导入数据,请等待任务完成后再删除")
|
||||
exports = await asyncio.to_thread(
|
||||
_list_export_manifests,
|
||||
f"assistant:{base_id}",
|
||||
limit=10_000,
|
||||
)
|
||||
if any(job.get("status") in {"queued", "running"} for job in exports):
|
||||
raise HTTPException(status_code=409, detail="知识库正在生成导出包,请等待任务完成后再删除")
|
||||
try:
|
||||
deleted = await store.delete_base(base_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="助手知识库不存在")
|
||||
await asyncio.to_thread(
|
||||
_delete_base_package_files,
|
||||
base_id,
|
||||
[str(job.get("id") or "") for job in jobs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wiki-pages")
|
||||
async def list_wiki_pages(
|
||||
request: Request,
|
||||
@@ -287,7 +362,13 @@ async def search(
|
||||
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)
|
||||
rows = await store.search_pages(
|
||||
base_ids=[base["id"]] if base else [],
|
||||
query=q,
|
||||
limit=20,
|
||||
include_chunks=True,
|
||||
embedding_client=embedding,
|
||||
)
|
||||
return {"results": rows, "total": len(rows), "retrieval_mode": rows[0].get("retrieval_mode", "keyword") if rows else "empty"}
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,10 @@ def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None
|
||||
if service is not None and config.enabled and config.auto_sync and mapping.get("wiki_index_enabled", True):
|
||||
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||
|
||||
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=True))
|
||||
# Event-driven refresh is incremental. Content hash + embedding
|
||||
# fingerprint decide which pages need encoding; unchanged Wiki pages
|
||||
# must retain and reuse their existing vectors.
|
||||
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=False))
|
||||
|
||||
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
@@ -3058,6 +3061,7 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
|
||||
except Exception:
|
||||
logger.exception("Failed to compensate orphan WeKnora knowledge base")
|
||||
raise HTTPException(status_code=500, detail="Failed to register the knowledge base") from None
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||||
|
||||
|
||||
@@ -3345,12 +3349,14 @@ async def upload_document(request: Request, mapping_id: str, file: UploadFile =
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=422, detail="File name is required")
|
||||
try:
|
||||
return await _client_or_503().upload_document(
|
||||
result = await _client_or_503().upload_document(
|
||||
str(row["weknora_id"]),
|
||||
filename=file.filename,
|
||||
content=content,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
)
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
return result
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -3362,7 +3368,9 @@ async def import_document_url(request: Request, mapping_id: str, body: Knowledge
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=422, detail="URL must start with http:// or https://")
|
||||
try:
|
||||
return await _client_or_503().import_document_url(str(row["weknora_id"]), url=url)
|
||||
result = await _client_or_503().import_document_url(str(row["weknora_id"]), url=url)
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
return result
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -3371,11 +3379,13 @@ async def import_document_url(request: Request, mapping_id: str, body: Knowledge
|
||||
async def create_manual_document(request: Request, mapping_id: str, body: ManualKnowledgeCreate) -> dict[str, Any]:
|
||||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||||
try:
|
||||
return await _client_or_503().create_manual_document(
|
||||
result = await _client_or_503().create_manual_document(
|
||||
str(row["weknora_id"]),
|
||||
title=body.title.strip(),
|
||||
content=body.content,
|
||||
)
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
return result
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -3387,6 +3397,7 @@ async def delete_document(request: Request, mapping_id: str, document_id: str) -
|
||||
try:
|
||||
await _verify_document(client, row, document_id)
|
||||
await client.delete_document(document_id)
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
@@ -3397,7 +3408,9 @@ async def reprocess_document(request: Request, mapping_id: str, document_id: str
|
||||
client = _client_or_503()
|
||||
try:
|
||||
await _verify_document(client, row, document_id)
|
||||
return await client.reprocess_document(document_id)
|
||||
result = await client.reprocess_document(document_id)
|
||||
_schedule_local_wiki_sync(request, row)
|
||||
return result
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||
from deerflow.config.runtime_paths import resolve_path, runtime_home
|
||||
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
||||
from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized
|
||||
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||
|
||||
@@ -37,6 +42,10 @@ class WikiIndexSettingsRequest(BaseModel):
|
||||
external_search_enabled: bool | None = None
|
||||
|
||||
|
||||
class LocalEncoderConfigRequest(BaseModel):
|
||||
model_path: str = Field(min_length=1, max_length=4096)
|
||||
|
||||
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
@@ -55,6 +64,149 @@ def _services(request: Request):
|
||||
return config, search, index_store
|
||||
|
||||
|
||||
def _local_encoder(request: Request):
|
||||
config = request.app.state.config.llmwiki.local_wiki_index.embedding
|
||||
embedding = getattr(request.app.state, "llmwiki_embedding", None)
|
||||
if config.provider != "local":
|
||||
raise HTTPException(status_code=409, detail={"code": "EMBEDDING_NOT_LOCAL", "message": "当前 embedding provider 不是 local"})
|
||||
if embedding is None:
|
||||
raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器未配置或未启用"})
|
||||
return embedding
|
||||
|
||||
|
||||
def _validated_encoder_path(raw_path: str) -> Path:
|
||||
path = resolve_path(raw_path.strip())
|
||||
if not path.is_dir():
|
||||
raise HTTPException(status_code=422, detail="编码器目录不存在")
|
||||
if not ((path / "onnx" / "model.onnx").is_file() or (path / "model.onnx").is_file()):
|
||||
raise HTTPException(status_code=422, detail="编码器目录缺少 onnx/model.onnx 或 model.onnx")
|
||||
return path
|
||||
|
||||
|
||||
def _safe_relative_upload_path(value: str) -> Path:
|
||||
normalized = str(value or "").replace("\\", "/").strip("/")
|
||||
candidate = PurePosixPath(normalized)
|
||||
if not normalized or candidate.is_absolute() or ".." in candidate.parts:
|
||||
raise HTTPException(status_code=422, detail=f"非法模型文件路径:{value}")
|
||||
if len(normalized) > 512:
|
||||
raise HTTPException(status_code=422, detail="模型文件路径过长")
|
||||
return Path(*candidate.parts)
|
||||
|
||||
|
||||
def _copy_encoder_uploads(files: list[UploadFile], paths: list[str], staging: Path) -> int:
|
||||
total = 0
|
||||
for upload, raw_path in zip(files, paths, strict=True):
|
||||
relative = _safe_relative_upload_path(raw_path)
|
||||
destination = staging / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
upload.file.seek(0)
|
||||
with destination.open("wb") as output:
|
||||
while chunk := upload.file.read(4 * 1024 * 1024):
|
||||
output.write(chunk)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
def _activate_uploaded_encoder(staging: Path) -> Path:
|
||||
model_root = (runtime_home() / "models").resolve()
|
||||
model_root.mkdir(parents=True, exist_ok=True)
|
||||
active = (model_root / "bge-m3").resolve()
|
||||
if active.parent != model_root:
|
||||
raise RuntimeError("编码器目标目录越界")
|
||||
backup = (model_root / f".bge-m3-backup-{uuid4().hex}").resolve()
|
||||
if active.exists():
|
||||
active.replace(backup)
|
||||
try:
|
||||
staging.replace(active)
|
||||
except Exception:
|
||||
if backup.exists() and not active.exists():
|
||||
backup.replace(active)
|
||||
raise
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
return active
|
||||
|
||||
|
||||
async def _save_encoder_path(request: Request, path: Path) -> dict[str, object]:
|
||||
embedding = _local_encoder(request)
|
||||
current = load_system_settings()
|
||||
encoder_settings = current.local_wiki_encoder.model_copy(update={"model_path": str(path)})
|
||||
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
|
||||
await embedding.reset_local_encoder(model_path=str(path))
|
||||
return embedding.local_encoder_status()
|
||||
|
||||
|
||||
@router.get("/local-encoder/status")
|
||||
async def local_encoder_status(request: Request) -> dict[str, object]:
|
||||
_, is_admin = await _actor(request)
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||
return _local_encoder(request).local_encoder_status()
|
||||
|
||||
|
||||
@router.post("/local-encoder/start", status_code=202)
|
||||
async def start_local_encoder(request: Request) -> dict[str, object]:
|
||||
_, is_admin = await _actor(request)
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||
try:
|
||||
return _local_encoder(request).start_local_encoder()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail={"code": "LOCAL_ENCODER_START_REJECTED", "message": str(exc)}) from None
|
||||
|
||||
|
||||
@router.put("/local-encoder/config")
|
||||
async def configure_local_encoder(request: Request, body: LocalEncoderConfigRequest) -> dict[str, object]:
|
||||
_, is_admin = await _actor(request)
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||
return await _save_encoder_path(request, _validated_encoder_path(body.model_path))
|
||||
|
||||
|
||||
@router.post("/local-encoder/upload")
|
||||
async def upload_local_encoder(
|
||||
request: Request,
|
||||
files: list[UploadFile] = File(...),
|
||||
paths: list[str] = Form(...),
|
||||
) -> dict[str, object]:
|
||||
"""Stream an offline model directory into runtime storage and activate it.
|
||||
|
||||
Starlette spools multipart files to disk, and the copy loop is chunked, so
|
||||
multi-gigabyte ONNX models do not need to be loaded into Gateway memory.
|
||||
"""
|
||||
|
||||
_, is_admin = await _actor(request)
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Administrator access required")
|
||||
if not files or len(files) != len(paths):
|
||||
raise HTTPException(status_code=422, detail="模型文件与相对路径不匹配")
|
||||
if len(files) > 500:
|
||||
raise HTTPException(status_code=422, detail="模型目录文件数不能超过 500")
|
||||
|
||||
staging_root = (runtime_home() / "model-uploads").resolve()
|
||||
staging = (staging_root / uuid4().hex).resolve()
|
||||
if staging.parent != staging_root:
|
||||
raise HTTPException(status_code=500, detail="模型暂存目录越界")
|
||||
staging.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
uploaded_bytes = await asyncio.to_thread(_copy_encoder_uploads, files, paths, staging)
|
||||
_validated_encoder_path(str(staging))
|
||||
await _local_encoder(request).reset_local_encoder()
|
||||
active = await asyncio.to_thread(_activate_uploaded_encoder, staging)
|
||||
status = await _save_encoder_path(request, active)
|
||||
return {**status, "uploaded_files": len(files), "uploaded_bytes": uploaded_bytes}
|
||||
except HTTPException:
|
||||
raise
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to store uploaded local encoder")
|
||||
raise HTTPException(status_code=507, detail=f"编码器文件保存失败:{exc}") from None
|
||||
finally:
|
||||
for upload in files:
|
||||
await upload.close()
|
||||
if staging.exists():
|
||||
await asyncio.to_thread(shutil.rmtree, staging, True)
|
||||
|
||||
|
||||
async def _authorized_mappings(request: Request, mapping_ids: list[str]) -> tuple[list[dict[str, Any]], str, bool]:
|
||||
user_id, is_admin = await _actor(request)
|
||||
mapping_store = request.app.state.llmwiki_store
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Administrator APIs for skill knowledge bindings and matrix jobs."""
|
||||
"""User-facing skill distillation APIs plus administrator maintenance APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,17 +21,28 @@ from deerflow.integrations.weknora.runtime import (
|
||||
get_resolved_llmwiki_runtime,
|
||||
)
|
||||
from deerflow.skill_knowledge import scan_skill
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
router = APIRouter(prefix="/api/skill-knowledge", tags=["skill-knowledge"])
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> str | None:
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return None
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
return "system", True
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> str:
|
||||
actor, is_admin = await _actor(request)
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="技能知识归纳仅管理员可操作")
|
||||
return str(user.id)
|
||||
return actor
|
||||
|
||||
|
||||
def _owned_by_actor(row: dict[str, Any] | None, actor: str, is_admin: bool) -> bool:
|
||||
return bool(row and (is_admin or str(row.get("created_by") or "") == actor))
|
||||
|
||||
|
||||
def _store(request: Request):
|
||||
@@ -98,9 +109,15 @@ async def list_bindings(
|
||||
skill_names: list[str] | None = Query(default=None),
|
||||
target_id: str | None = Query(default=None),
|
||||
include_detached: bool = Query(default=False),
|
||||
mine_only: bool = Query(default=False),
|
||||
) -> dict[str, Any]:
|
||||
await _require_admin(request)
|
||||
rows = await _store(request).list_bindings(skill_names=skill_names, target_id=target_id, include_detached=include_detached)
|
||||
actor, is_admin = await _actor(request)
|
||||
rows = await _store(request).list_bindings(
|
||||
skill_names=skill_names,
|
||||
target_id=target_id,
|
||||
include_detached=include_detached,
|
||||
created_by=actor if mine_only or not is_admin else None,
|
||||
)
|
||||
return {"bindings": rows}
|
||||
|
||||
|
||||
@@ -164,13 +181,26 @@ 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)
|
||||
actor, is_admin = await _actor(request)
|
||||
# Built-in and legacy skills need not have an ownership row. Validate
|
||||
# against the same running filesystem used by the skills list/executor.
|
||||
missing = []
|
||||
favorite_names: set[str] = set()
|
||||
custom_root: Any = None
|
||||
if not is_admin:
|
||||
favorite_names = set(await request.app.state.skill_store.list_favorite_skill_names(actor))
|
||||
storage_root = get_or_new_skill_storage(
|
||||
app_config=request.app.state.config
|
||||
).get_skills_root_path().resolve()
|
||||
custom_root = (storage_root / SkillCategory.CUSTOM.value).resolve()
|
||||
for name in body.skill_names:
|
||||
try:
|
||||
await asyncio.to_thread(_skill_directory, request.app.state.config, name)
|
||||
skill_path = await asyncio.to_thread(_skill_directory, request.app.state.config, name)
|
||||
if not is_admin:
|
||||
visible = await request.app.state.skill_store.get_visible(name, actor)
|
||||
is_public_filesystem_skill = not skill_path.resolve().is_relative_to(custom_root)
|
||||
if visible is None and name not in favorite_names and not is_public_filesystem_skill:
|
||||
missing.append(name)
|
||||
except (FileNotFoundError, ValueError):
|
||||
missing.append(name)
|
||||
if missing:
|
||||
@@ -182,9 +212,14 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
|
||||
raise HTTPException(status_code=422, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库")
|
||||
if target.target_mode != "wiki":
|
||||
raise HTTPException(status_code=422, detail="技能归纳目标仅支持 WeKnora Wiki 知识库")
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(target.target_id, actor or "system", write=True, is_admin=True)
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(
|
||||
target.target_id,
|
||||
actor,
|
||||
write=True,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if mapping is None:
|
||||
raise HTTPException(status_code=404, detail=f"普通知识库不存在:{target.target_id}")
|
||||
raise HTTPException(status_code=404, detail=f"普通知识库不存在或当前用户不可写:{target.target_id}")
|
||||
data["target_name"] = target.target_name or str(mapping.get("name") or target.target_id)
|
||||
data["remote_business_key"] = target.remote_business_key or f"weknora:kb:{mapping['weknora_id']}"
|
||||
targets.append(data)
|
||||
@@ -193,7 +228,7 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
|
||||
targets=targets,
|
||||
created_by=actor,
|
||||
force=body.force,
|
||||
review_mode=body.review_mode,
|
||||
review_mode=body.review_mode if is_admin else "off",
|
||||
)
|
||||
dispatcher = getattr(request.app.state, "skill_knowledge_dispatcher", None)
|
||||
if dispatcher is not None:
|
||||
@@ -202,23 +237,35 @@ async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[
|
||||
|
||||
|
||||
@router.get("/sync-jobs")
|
||||
async def list_sync_jobs(request: Request, limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]:
|
||||
await _require_admin(request)
|
||||
return {"jobs": await _store(request).list_jobs(limit=limit)}
|
||||
async def list_sync_jobs(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
mine_only: bool = Query(default=False),
|
||||
) -> dict[str, Any]:
|
||||
actor, is_admin = await _actor(request)
|
||||
return {
|
||||
"jobs": await _store(request).list_jobs(
|
||||
limit=limit,
|
||||
created_by=actor if mine_only or not is_admin else None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sync-jobs/{job_id}")
|
||||
async def get_sync_job(request: Request, job_id: str) -> dict[str, Any]:
|
||||
await _require_admin(request)
|
||||
actor, is_admin = await _actor(request)
|
||||
row = await _store(request).get_job(job_id)
|
||||
if row is None:
|
||||
if not _owned_by_actor(row, actor, is_admin):
|
||||
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/sync-jobs/{job_id}/cancel")
|
||||
async def cancel_sync_job(request: Request, job_id: str) -> dict[str, bool]:
|
||||
await _require_admin(request)
|
||||
actor, is_admin = await _actor(request)
|
||||
row = await _store(request).get_job(job_id)
|
||||
if not _owned_by_actor(row, actor, is_admin):
|
||||
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
||||
if not await _store(request).cancel_job(job_id):
|
||||
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
||||
return {"canceled": True}
|
||||
@@ -226,7 +273,20 @@ async def cancel_sync_job(request: Request, job_id: str) -> dict[str, bool]:
|
||||
|
||||
@router.post("/sync-items/{item_id}/retry", status_code=202)
|
||||
async def retry_sync_item(request: Request, item_id: str) -> dict[str, Any]:
|
||||
await _require_admin(request)
|
||||
actor, is_admin = await _actor(request)
|
||||
jobs = await _store(request).list_jobs(limit=200, created_by=None if is_admin else actor)
|
||||
item = next(
|
||||
(item for job in jobs for item in job.get("items", []) if item.get("id") == item_id),
|
||||
None,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="归纳任务项不存在")
|
||||
if item.get("target_type") == "weknora":
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(
|
||||
str(item["target_id"]), actor, write=True, is_admin=is_admin
|
||||
)
|
||||
if mapping is None:
|
||||
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
|
||||
row = await _store(request).requeue_item(item_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="归纳任务项不存在")
|
||||
@@ -236,10 +296,16 @@ async def retry_sync_item(request: Request, item_id: str) -> dict[str, Any]:
|
||||
|
||||
@router.post("/bindings/{binding_id}/resync", status_code=202)
|
||||
async def resync_binding(request: Request, binding_id: str) -> dict[str, Any]:
|
||||
actor = await _require_admin(request)
|
||||
actor, is_admin = await _actor(request)
|
||||
binding = await _store(request).get_binding(binding_id)
|
||||
if binding is None or not binding.get("active"):
|
||||
if not _owned_by_actor(binding, actor, is_admin) or not binding.get("active"):
|
||||
raise HTTPException(status_code=404, detail="绑定不存在")
|
||||
if binding.get("target_type") == "weknora":
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(
|
||||
str(binding["target_id"]), actor, write=True, is_admin=is_admin
|
||||
)
|
||||
if mapping is None:
|
||||
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
|
||||
target = {
|
||||
"target_type": binding["target_type"],
|
||||
"target_mode": binding["target_mode"],
|
||||
@@ -260,10 +326,10 @@ async def resync_binding(request: Request, binding_id: str) -> dict[str, Any]:
|
||||
|
||||
@router.post("/bindings/{binding_id}/detach")
|
||||
async def detach_binding(request: Request, binding_id: str, body: DetachRequest) -> dict[str, Any]:
|
||||
await _require_admin(request)
|
||||
actor, is_admin = await _actor(request)
|
||||
store = _store(request)
|
||||
binding = await store.get_binding(binding_id)
|
||||
if binding is None:
|
||||
if not _owned_by_actor(binding, actor, is_admin):
|
||||
raise HTTPException(status_code=404, detail="绑定不存在")
|
||||
if await store.has_active_item(binding_id):
|
||||
raise HTTPException(status_code=409, detail="该绑定仍有运行中的归纳项,请先取消任务")
|
||||
@@ -278,9 +344,9 @@ async def detach_binding(request: Request, binding_id: str, body: DetachRequest)
|
||||
elif binding["target_type"] == "weknora":
|
||||
mapping = await request.app.state.llmwiki_store.get_authorized(
|
||||
str(binding["target_id"]),
|
||||
"system",
|
||||
actor,
|
||||
write=True,
|
||||
is_admin=True,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
runtime = get_resolved_llmwiki_runtime(request.app.state.config)
|
||||
if mapping is None or not runtime.weknora_enabled:
|
||||
|
||||
@@ -589,13 +589,14 @@ llmwiki:
|
||||
timeout_seconds: 30
|
||||
# 本地 Wiki 向量化:用于普通知识库“下载向量化数据包”和助手知识库向量检索。
|
||||
# 已有相同内容哈希 + 相同 embedding fingerprint 的 Wiki 向量会直接复用,不会重复编码。
|
||||
# local 模式使用 CPU BGE-M3,需安装 local-embedding 依赖并预下载官方 ONNX 权重。
|
||||
# auto_sync=true 才会后台自动增量同步;服务启动时只启动定时器,不会立刻全量向量化。
|
||||
# local 模式使用 CPU BGE-M3;离线包必须包含 fastembed,并提前放置完整 ONNX 权重。
|
||||
# 启动后先补扫一次,此后按间隔自动增量向量化并沉淀到“知识梳理总库”。
|
||||
local_wiki_index:
|
||||
enabled: true
|
||||
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
|
||||
frontend_base_url: "http://127.0.0.1:5174"
|
||||
auto_sync: false
|
||||
auto_sync: true
|
||||
sync_interval_seconds: 30
|
||||
database_batch_size: 50
|
||||
embedding:
|
||||
provider: local
|
||||
@@ -603,6 +604,11 @@ llmwiki:
|
||||
dimensions: 1024
|
||||
batch_size: 2
|
||||
local_threads: 2
|
||||
# 独立子进程承载大模型内存;编码器异常退出不会带停 DeerFlow 主服务。
|
||||
local_process_isolation: true
|
||||
# 离线部署必须指向提前放置好的完整 BGE-M3 模型目录;也可设置
|
||||
# DEERFLOW_BGE_M3_MODEL_PATH。系统不会联网下载模型。
|
||||
local_model_path: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class LocalWikiEmbeddingConfig(BaseModel):
|
||||
api_key: str = Field(default="", repr=False)
|
||||
local_model_path: str = ""
|
||||
local_cache_dir: str = ""
|
||||
local_process_isolation: bool = True
|
||||
local_threads: int = Field(default=2, ge=1, le=32)
|
||||
dimensions: int | None = Field(
|
||||
default=None,
|
||||
@@ -133,8 +134,8 @@ class LocalWikiIndexConfig(BaseModel):
|
||||
default="http://127.0.0.1:5174",
|
||||
description="DeerFlow frontend base URL used to build directly usable Wiki links in public vector-search results.",
|
||||
)
|
||||
auto_sync: bool = False
|
||||
sync_interval_seconds: int = Field(default=120, ge=10, le=86400)
|
||||
auto_sync: bool = True
|
||||
sync_interval_seconds: int = Field(default=30, ge=10, le=86400)
|
||||
sync_page_size: int = Field(default=100, ge=1, le=500)
|
||||
sync_concurrency: int = Field(default=3, ge=1, le=32)
|
||||
sync_lease_seconds: int = Field(default=600, ge=30, le=86400)
|
||||
|
||||
@@ -505,6 +505,17 @@ class ModelConcurrencySettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class LocalWikiEncoderSettings(BaseModel):
|
||||
"""Runtime override for the administrator-managed offline Wiki encoder."""
|
||||
|
||||
model_path: str = Field(
|
||||
default="",
|
||||
max_length=4096,
|
||||
description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
@@ -520,6 +531,7 @@ class SystemSettings(BaseModel):
|
||||
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
|
||||
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
|
||||
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
|
||||
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
|
||||
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
|
||||
skill_knowledge_auto_sync: SkillKnowledgeAutoSyncSettings = Field(default_factory=SkillKnowledgeAutoSyncSettings)
|
||||
knowledge_base_product_version: Literal["v1", "v2"] = Field(
|
||||
|
||||
+299
-6
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
@@ -45,7 +50,13 @@ class StrictWikiEmbeddingClient:
|
||||
self._owns_client = http_client is None
|
||||
self._resolved_dimensions = config.dimensions
|
||||
self._local_model = None
|
||||
self._local_process: subprocess.Popen[str] | None = None
|
||||
self._local_lock = asyncio.Lock()
|
||||
self._local_state = "idle"
|
||||
self._local_error: str | None = None
|
||||
self._local_started_at: str | None = None
|
||||
self._local_ready_at: str | None = None
|
||||
self._local_warmup_task: asyncio.Task[None] | None = None
|
||||
|
||||
@property
|
||||
def dimensions(self) -> int | None:
|
||||
@@ -57,10 +68,35 @@ class StrictWikiEmbeddingClient:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.reset_local_encoder()
|
||||
if self._owns_client and self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def reset_local_encoder(self, *, model_path: str | None = None) -> None:
|
||||
"""Stop only the embedded worker and optionally switch its model path.
|
||||
|
||||
This is intentionally independent from the Gateway lifecycle: an
|
||||
administrator can replace the offline model without restarting or
|
||||
stopping the rest of DeerFlow.
|
||||
"""
|
||||
warmup = self._local_warmup_task
|
||||
if warmup is not None and not warmup.done():
|
||||
warmup.cancel()
|
||||
await asyncio.gather(warmup, return_exceptions=True)
|
||||
self._local_warmup_task = None
|
||||
process = self._local_process
|
||||
self._local_process = None
|
||||
if process is not None:
|
||||
await asyncio.to_thread(self._stop_local_process, process)
|
||||
self._local_model = None
|
||||
self._local_state = "idle"
|
||||
self._local_error = None
|
||||
self._local_started_at = None
|
||||
self._local_ready_at = None
|
||||
if model_path is not None:
|
||||
self.config = self.config.model_copy(update={"local_model_path": model_path})
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
|
||||
@@ -71,7 +107,18 @@ class StrictWikiEmbeddingClient:
|
||||
if not texts:
|
||||
return []
|
||||
async with self._local_lock:
|
||||
return await asyncio.to_thread(self._embed_local, texts)
|
||||
self._local_state = "starting"
|
||||
self._local_started_at = self._local_started_at or datetime.now(UTC).isoformat()
|
||||
try:
|
||||
values = await asyncio.to_thread(self._embed_local, texts)
|
||||
except Exception as exc:
|
||||
self._local_state = "failed"
|
||||
self._local_error = " ".join(str(exc).split())[:1000] or type(exc).__name__
|
||||
raise
|
||||
self._local_state = "ready"
|
||||
self._local_error = None
|
||||
self._local_ready_at = datetime.now(UTC).isoformat()
|
||||
return values
|
||||
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:
|
||||
@@ -87,11 +134,36 @@ class StrictWikiEmbeddingClient:
|
||||
def _embed_local(self, texts: list[str]) -> list[list[float]]:
|
||||
if not self.config.enabled:
|
||||
raise WikiEmbeddingError("本地编码已关闭", retryable=False)
|
||||
model_path = self._validated_local_model_path()
|
||||
if self.config.local_process_isolation:
|
||||
return self._embed_local_process(texts, model_path)
|
||||
return self._embed_local_in_process(texts, model_path)
|
||||
|
||||
def _validated_local_model_path(self) -> Path:
|
||||
model_path = self._local_model_path()
|
||||
if model_path is None:
|
||||
raise WikiEmbeddingError(
|
||||
"离线本地编码器目录未配置;请设置 local_model_path 或 DEERFLOW_BGE_M3_MODEL_PATH",
|
||||
category="configuration",
|
||||
retryable=False,
|
||||
)
|
||||
if not model_path.is_dir() or not (
|
||||
(model_path / "onnx" / "model.onnx").is_file()
|
||||
or (model_path / "model.onnx").is_file()
|
||||
):
|
||||
raise WikiEmbeddingError(
|
||||
f"离线本地编码器目录无效或缺少 ONNX 模型:{model_path}",
|
||||
category="configuration",
|
||||
retryable=False,
|
||||
)
|
||||
return model_path
|
||||
|
||||
def _embed_local_in_process(self, texts: list[str], model_path: Path) -> list[list[float]]:
|
||||
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
|
||||
raise WikiEmbeddingError("标准后端依赖缺少 fastembed,请检查离线 wheelhouse 和安装结果", 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
|
||||
@@ -108,10 +180,14 @@ class StrictWikiEmbeddingClient:
|
||||
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
|
||||
options = {
|
||||
"model_name": name,
|
||||
"threads": self.config.local_threads,
|
||||
"cache_dir": str(cache_dir),
|
||||
"providers": ["CPUExecutionProvider"],
|
||||
"specific_model_path": str(model_path),
|
||||
"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)):
|
||||
@@ -123,6 +199,146 @@ class StrictWikiEmbeddingClient:
|
||||
raise WikiEmbeddingError("本地编码返回数量不符", retryable=False)
|
||||
return values
|
||||
|
||||
def _embed_local_process(self, texts: list[str], model_path: Path) -> list[list[float]]:
|
||||
process = self._local_process
|
||||
if process is None or process.poll() is not None:
|
||||
process = self._start_local_process(model_path)
|
||||
self._local_process = process
|
||||
if process.stdin is None or process.stdout is None:
|
||||
raise WikiEmbeddingError("本地编码器子进程通信通道不可用", retryable=False)
|
||||
request_id = hashlib.sha256(f"{monotonic()}:{len(texts)}".encode()).hexdigest()[:16]
|
||||
try:
|
||||
process.stdin.write(
|
||||
json.dumps({"id": request_id, "texts": texts}, ensure_ascii=False) + "\n"
|
||||
)
|
||||
process.stdin.flush()
|
||||
response = self._read_local_process_message(process, request_id=request_id)
|
||||
except (BrokenPipeError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
self._stop_local_process(process)
|
||||
self._local_process = None
|
||||
raise WikiEmbeddingError(
|
||||
f"本地编码器子进程异常退出:{str(exc)[:500]}",
|
||||
category="local_process",
|
||||
retryable=True,
|
||||
) from exc
|
||||
if response.get("status") != "ok":
|
||||
error = str(response.get("error") or "本地编码失败")[:1000]
|
||||
if process.poll() is not None:
|
||||
self._local_process = None
|
||||
raise WikiEmbeddingError(error, category="local_process", retryable=False)
|
||||
raw_vectors = response.get("vectors")
|
||||
if not isinstance(raw_vectors, list) or len(raw_vectors) != len(texts):
|
||||
raise WikiEmbeddingError("本地编码返回数量不符", retryable=False)
|
||||
values: list[list[float]] = []
|
||||
for raw in raw_vectors:
|
||||
normalized = normalize_vector(raw, dimensions=self._resolved_dimensions)
|
||||
if self._resolved_dimensions is None:
|
||||
self._resolved_dimensions = int(normalized.size)
|
||||
values.append(normalized.tolist())
|
||||
return values
|
||||
|
||||
def _start_local_process(self, model_path: Path) -> subprocess.Popen[str]:
|
||||
name = "BAAI/bge-m3" if self.config.model.lower() in {"bge-m3", "bge-embedding-m3"} else self.config.model
|
||||
cache_dir = (
|
||||
Path(self.config.local_cache_dir).expanduser()
|
||||
if self.config.local_cache_dir
|
||||
else Path.home() / ".cache" / "deerflow" / "embeddings"
|
||||
)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"deerflow.integrations.weknora.local_index.embedding_worker",
|
||||
"--model-name",
|
||||
name,
|
||||
"--model-path",
|
||||
str(model_path),
|
||||
"--cache-dir",
|
||||
str(cache_dir),
|
||||
"--threads",
|
||||
str(self.config.local_threads),
|
||||
"--batch-size",
|
||||
str(min(self.config.batch_size, 4)),
|
||||
]
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"HF_HUB_OFFLINE": "1",
|
||||
"TRANSFORMERS_OFFLINE": "1",
|
||||
"HF_DATASETS_OFFLINE": "1",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1",
|
||||
}
|
||||
)
|
||||
creation_flags = 0
|
||||
if os.name == "nt":
|
||||
creation_flags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) | int(
|
||||
getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0)
|
||||
)
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
bufsize=1,
|
||||
env=environment,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
response = self._read_local_process_message(process, ready=True)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
if "process" in locals():
|
||||
self._stop_local_process(process)
|
||||
raise WikiEmbeddingError(
|
||||
f"无法启动本地编码器子进程:{str(exc)[:500]}",
|
||||
category="local_process",
|
||||
retryable=False,
|
||||
) from exc
|
||||
if response.get("status") != "ready":
|
||||
error = str(response.get("error") or "本地编码器初始化失败")[:1000]
|
||||
self._stop_local_process(process)
|
||||
raise WikiEmbeddingError(error, category="local_process", retryable=False)
|
||||
return process
|
||||
|
||||
@staticmethod
|
||||
def _read_local_process_message(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
ready: bool = False,
|
||||
) -> dict[str, object]:
|
||||
if process.stdout is None:
|
||||
raise ValueError("本地编码器没有输出通道")
|
||||
for _ in range(20):
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
raise ValueError(f"本地编码器已退出(exit={process.poll()})")
|
||||
payload = json.loads(line)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if ready and payload.get("status") in {"ready", "error"}:
|
||||
return payload
|
||||
if request_id is not None and payload.get("id") == request_id:
|
||||
return payload
|
||||
raise ValueError("本地编码器返回了无法识别的响应")
|
||||
|
||||
@staticmethod
|
||||
def _stop_local_process(process: subprocess.Popen[str]) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
if process.stdin is not None:
|
||||
process.stdin.write('{"command":"shutdown"}\n')
|
||||
process.stdin.flush()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
process.kill()
|
||||
|
||||
async def _embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
url = self.config.base_url.rstrip("/") + "/embeddings"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -185,3 +401,80 @@ class StrictWikiEmbeddingClient:
|
||||
async def embed_query(self, text: str) -> list[float]:
|
||||
result = await self.embed_texts([text])
|
||||
return result[0]
|
||||
|
||||
def local_encoder_status(self) -> dict[str, object]:
|
||||
running = self._local_warmup_task is not None and not self._local_warmup_task.done()
|
||||
process_ready = self._local_process is not None and self._local_process.poll() is None
|
||||
process_failed = self._local_process is not None and not process_ready
|
||||
state = (
|
||||
"starting"
|
||||
if running
|
||||
else "ready"
|
||||
if (self._local_model is not None or process_ready)
|
||||
else "failed"
|
||||
if process_failed
|
||||
else self._local_state
|
||||
)
|
||||
last_error = self._local_error
|
||||
if process_failed and not last_error:
|
||||
last_error = f"本地编码器子进程已退出(exit={self._local_process.poll()})"
|
||||
model_path = self._local_model_path()
|
||||
model_files_ready = bool(
|
||||
model_path
|
||||
and model_path.is_dir()
|
||||
and ((model_path / "onnx" / "model.onnx").is_file() or (model_path / "model.onnx").is_file())
|
||||
)
|
||||
return {
|
||||
"provider": self.config.provider,
|
||||
"model": self.config.model,
|
||||
"dimensions": self.dimensions,
|
||||
"state": state,
|
||||
"running": running,
|
||||
"last_error": last_error,
|
||||
"started_at": self._local_started_at,
|
||||
"ready_at": self._local_ready_at,
|
||||
"offline_only": self.config.provider == "local",
|
||||
"model_path_configured": model_path is not None,
|
||||
"model_path_exists": bool(model_path and model_path.is_dir()),
|
||||
"model_files_ready": model_files_ready,
|
||||
"process_isolation": self.config.local_process_isolation,
|
||||
"model_path": str(model_path) if model_path is not None else "",
|
||||
}
|
||||
|
||||
def _local_model_path(self) -> Path | None:
|
||||
raw = self.config.local_model_path.strip() or os.environ.get(
|
||||
"DEERFLOW_BGE_M3_MODEL_PATH", ""
|
||||
).strip()
|
||||
if not raw:
|
||||
return None
|
||||
path = Path(raw).expanduser()
|
||||
return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
|
||||
|
||||
def start_local_encoder(self) -> dict[str, object]:
|
||||
"""Warm the embedded CPU encoder without coupling it to app startup."""
|
||||
if self.config.provider != "local":
|
||||
raise ValueError("当前 embedding provider 不是 local")
|
||||
if not self.config.enabled:
|
||||
raise ValueError("本地编码器未启用")
|
||||
process_ready = self._local_process is not None and self._local_process.poll() is None
|
||||
if self._local_model is not None or process_ready:
|
||||
self._local_state = "ready"
|
||||
return self.local_encoder_status()
|
||||
if self._local_warmup_task is None or self._local_warmup_task.done():
|
||||
self._local_state = "starting"
|
||||
self._local_error = None
|
||||
self._local_started_at = datetime.now(UTC).isoformat()
|
||||
self._local_warmup_task = asyncio.create_task(
|
||||
self._warm_local_encoder(),
|
||||
name="llmwiki-local-encoder-warmup",
|
||||
)
|
||||
return self.local_encoder_status()
|
||||
|
||||
async def _warm_local_encoder(self) -> None:
|
||||
try:
|
||||
await self.embed_query("本地编码器启动检查")
|
||||
except asyncio.CancelledError:
|
||||
self._local_state = "idle"
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Local Wiki encoder warmup failed model=%s", self.config.model)
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"""Offline FastEmbed worker isolated from the DeerFlow gateway process.
|
||||
|
||||
The protocol is UTF-8 JSON Lines over stdin/stdout. Model weights must already
|
||||
exist on disk; every offline flag is enforced before FastEmbed is imported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write(payload: dict[str, object]) -> None:
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _build_model(args: argparse.Namespace):
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
from fastembed import TextEmbedding
|
||||
|
||||
if args.model_name == "BAAI/bge-m3" and not any(
|
||||
item["model"] == args.model_name for item in TextEmbedding.list_supported_models()
|
||||
):
|
||||
from fastembed.common.model_description import ModelSource, PoolingType
|
||||
|
||||
TextEmbedding.add_custom_model(
|
||||
model=args.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,
|
||||
)
|
||||
return TextEmbedding(
|
||||
model_name=args.model_name,
|
||||
threads=args.threads,
|
||||
cache_dir=args.cache_dir,
|
||||
providers=["CPUExecutionProvider"],
|
||||
specific_model_path=args.model_path,
|
||||
local_files_only=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model-name", required=True)
|
||||
parser.add_argument("--model-path", required=True)
|
||||
parser.add_argument("--cache-dir", required=True)
|
||||
parser.add_argument("--threads", type=int, default=2)
|
||||
parser.add_argument("--batch-size", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
model_path = Path(args.model_path)
|
||||
if not model_path.is_dir():
|
||||
_write({"status": "error", "error": f"离线模型目录不存在:{model_path}"})
|
||||
return 2
|
||||
try:
|
||||
model = _build_model(args)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_write({"status": "error", "error": f"离线模型加载失败:{type(exc).__name__}: {exc}"})
|
||||
return 2
|
||||
_write({"status": "ready"})
|
||||
for line in sys.stdin:
|
||||
request: dict[str, object] = {}
|
||||
try:
|
||||
request = json.loads(line)
|
||||
if request.get("command") == "shutdown":
|
||||
return 0
|
||||
request_id = str(request.get("id") or "")
|
||||
texts = request.get("texts")
|
||||
if not request_id or not isinstance(texts, list) or not all(isinstance(value, str) for value in texts):
|
||||
raise ValueError("编码请求格式无效")
|
||||
vectors = [
|
||||
value.tolist()
|
||||
for value in model.embed(texts, batch_size=max(1, min(args.batch_size, 4)))
|
||||
]
|
||||
_write({"id": request_id, "status": "ok", "vectors": vectors})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_write(
|
||||
{
|
||||
"id": str(request.get("id") or "") if isinstance(request, dict) else "",
|
||||
"status": "error",
|
||||
"error": f"本地编码失败:{type(exc).__name__}: {exc}",
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+4
-1
@@ -163,7 +163,10 @@ class WikiSyncService:
|
||||
error=f"{failed} page(s) failed" if failed else None,
|
||||
)
|
||||
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:
|
||||
# Notify even when the remote library is now empty. The global
|
||||
# assistant base must then remove stale contributions from this
|
||||
# source instead of retaining deleted Wiki pages forever.
|
||||
if not failed and notify and self.on_completed:
|
||||
result["assistant_sync"] = await self.on_completed(mapping)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+937
-32
File diff suppressed because it is too large.
Load diff
+40
-3
@@ -158,6 +158,11 @@ class SkillKnowledgeRepository:
|
||||
binding.remote_business_key = target.get("remote_business_key")
|
||||
binding.status = "queued"
|
||||
binding.active = True
|
||||
# The current manual submitter owns the relationship UI
|
||||
# from this point onward. Target write authorization is
|
||||
# enforced by the router before this method is called.
|
||||
if trigger == "manual":
|
||||
binding.created_by = created_by
|
||||
binding.updated_at = now
|
||||
item = SkillKnowledgeSyncItemRow(
|
||||
id=str(uuid4()),
|
||||
@@ -187,7 +192,14 @@ class SkillKnowledgeRepository:
|
||||
await session.commit()
|
||||
return result
|
||||
|
||||
async def list_bindings(self, *, skill_names: list[str] | None = None, target_id: str | None = None, include_detached: bool = False) -> list[dict[str, Any]]:
|
||||
async def list_bindings(
|
||||
self,
|
||||
*,
|
||||
skill_names: list[str] | None = None,
|
||||
target_id: str | None = None,
|
||||
include_detached: bool = False,
|
||||
created_by: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillKnowledgeBindingRow)
|
||||
if skill_names:
|
||||
stmt = stmt.where(SkillKnowledgeBindingRow.skill_name.in_(skill_names))
|
||||
@@ -195,6 +207,8 @@ class SkillKnowledgeRepository:
|
||||
stmt = stmt.where(SkillKnowledgeBindingRow.target_id == target_id)
|
||||
if not include_detached:
|
||||
stmt = stmt.where(SkillKnowledgeBindingRow.active.is_(True))
|
||||
if created_by is not None:
|
||||
stmt = stmt.where(SkillKnowledgeBindingRow.created_by == created_by)
|
||||
stmt = stmt.order_by(SkillKnowledgeBindingRow.updated_at.desc())
|
||||
async with self._sf() as session:
|
||||
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
|
||||
@@ -247,10 +261,33 @@ class SkillKnowledgeRepository:
|
||||
result["items"] = [_dict(item) for item in items]
|
||||
return result
|
||||
|
||||
async def list_jobs(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
async def list_jobs(self, *, limit: int = 50, created_by: str | None = None) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillKnowledgeSyncJobRow).order_by(SkillKnowledgeSyncJobRow.created_at.desc()).limit(limit)
|
||||
if created_by is not None:
|
||||
stmt = stmt.where(SkillKnowledgeSyncJobRow.created_by == created_by)
|
||||
async with self._sf() as session:
|
||||
return [_dict(row) for row in (await session.execute(stmt)).scalars()]
|
||||
jobs = list((await session.execute(stmt)).scalars())
|
||||
if not jobs:
|
||||
return []
|
||||
job_ids = [row.id for row in jobs]
|
||||
item_rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(SkillKnowledgeSyncItemRow)
|
||||
.where(SkillKnowledgeSyncItemRow.job_id.in_(job_ids))
|
||||
.order_by(SkillKnowledgeSyncItemRow.created_at)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
items_by_job: dict[str, list[dict[str, Any]]] = {job_id: [] for job_id in job_ids}
|
||||
for item in item_rows:
|
||||
items_by_job[item.job_id].append(_dict(item))
|
||||
result = []
|
||||
for job in jobs:
|
||||
data = _dict(job)
|
||||
data["items"] = items_by_job[job.id]
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
async def list_queued_items(self, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
now = _now()
|
||||
|
||||
@@ -25,13 +25,13 @@ dependencies = [
|
||||
"hindsight-client>=0.4.22",
|
||||
"asyncmy>=0.2.11",
|
||||
"openpyxl>=3.1.5",
|
||||
"fastembed>=0.7,<0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = ["deerflow-harness[postgres]"]
|
||||
mysql = ["deerflow-harness[mysql]"]
|
||||
gauss = ["deerflow-harness[gauss]"]
|
||||
local-embedding = ["fastembed>=0.7,<0.9"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.llmwiki_rag import _source_from_assistant_result, _source_from_result
|
||||
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
|
||||
from deerflow.persistence.assistant_knowledge.model import (
|
||||
AssistantKnowledgeEntityContributionRow,
|
||||
@@ -194,3 +195,264 @@ async def test_imported_vectors_are_reused_for_assistant_search(
|
||||
query="这个查询不靠关键词",
|
||||
embedding_client=FakeEmbeddingClient(),
|
||||
) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reimport_retires_pages_removed_from_source_snapshot(
|
||||
repository: AssistantKnowledgeRepository,
|
||||
) -> None:
|
||||
base = await repository.initialize(created_by="admin")
|
||||
common = {
|
||||
"source_type": "weknora",
|
||||
"source_key": "kb-refresh",
|
||||
"source_name": "自动同步普通库",
|
||||
"trigger": "vectorization_completed",
|
||||
"created_by": "admin",
|
||||
"mapping_id": "kb-refresh",
|
||||
}
|
||||
await repository.import_package(
|
||||
package={
|
||||
"source": {"type": "weknora", "knowledge_base_id": "kb-refresh"},
|
||||
"wiki_pages": [
|
||||
{"slug": "wiki/kept", "title": "保留页", "content": "保留内容"},
|
||||
{"slug": "wiki/removed", "title": "删除页", "content": "旧内容"},
|
||||
],
|
||||
},
|
||||
**common,
|
||||
)
|
||||
|
||||
await repository.import_package(
|
||||
package={
|
||||
"source": {"type": "weknora", "knowledge_base_id": "kb-refresh"},
|
||||
"wiki_pages": [
|
||||
{"slug": "wiki/kept", "title": "保留页", "content": "新内容"},
|
||||
],
|
||||
},
|
||||
**common,
|
||||
)
|
||||
|
||||
pages = await repository.list_pages(base_id=base["id"], include_chunks=True)
|
||||
assert [page["slug"] for page in pages] == ["wiki/kept"]
|
||||
assert pages[0]["content_markdown"] == "新内容"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_base_can_be_renamed_and_deleted_without_affecting_global_base(
|
||||
repository: AssistantKnowledgeRepository,
|
||||
) -> None:
|
||||
global_base = await repository.initialize(created_by="admin")
|
||||
custom_base = await repository.create_base(
|
||||
created_by="admin",
|
||||
name="待编辑助手库",
|
||||
description="旧描述",
|
||||
)
|
||||
await repository.import_package(
|
||||
package={
|
||||
"source": {"type": "weknora"},
|
||||
"wiki_pages": [
|
||||
{"slug": "wiki/delete-test", "title": "删除测试", "content": "保留在总库中的内容"}
|
||||
],
|
||||
"entities": [{"local_id": "e1", "type": "concept", "name": "删除测试实体"}],
|
||||
"relations": [],
|
||||
},
|
||||
source_type="weknora",
|
||||
source_key="kb-delete-test",
|
||||
source_name="删除测试普通库",
|
||||
trigger="manual_from_weknora",
|
||||
created_by="admin",
|
||||
mapping_id="kb-delete-test",
|
||||
base_id=custom_base["id"],
|
||||
)
|
||||
|
||||
updated = await repository.update_base(
|
||||
custom_base["id"],
|
||||
name="已编辑助手库",
|
||||
description="新描述",
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["name"] == "已编辑助手库"
|
||||
assert updated["description"] == "新描述"
|
||||
|
||||
assert await repository.delete_base(custom_base["id"])
|
||||
assert await repository.get_base(custom_base["id"]) is None
|
||||
assert all(base["id"] != custom_base["id"] for base in await repository.list_bases())
|
||||
assert await repository.list_pages(base_id=global_base["id"], query="保留在总库")
|
||||
with pytest.raises(ValueError, match="不能删除"):
|
||||
await repository.delete_base(global_base["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_base_aligns_same_entity_title_and_searches_knowledge_body(
|
||||
repository: AssistantKnowledgeRepository,
|
||||
) -> None:
|
||||
base = await repository.initialize(created_by="admin")
|
||||
first_package = {
|
||||
"source": {"provider": "weknora", "knowledge_base_id": "kb-one", "knowledge_base_name": "人员库"},
|
||||
"wiki_pages": [
|
||||
{
|
||||
"slug": "people/zhang-san",
|
||||
"title": "张 三",
|
||||
"page_type": "article",
|
||||
"content": "张三负责设备巡检。",
|
||||
"summary": "摘要专用词:月度综述。",
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"local_id": "one",
|
||||
"type": "人物",
|
||||
"name": "张 三",
|
||||
"aliases": ["Zhang San"],
|
||||
"source_wiki_slug": "people/zhang-san",
|
||||
}
|
||||
],
|
||||
}
|
||||
second_package = {
|
||||
"source": {"provider": "weknora", "knowledge_base_id": "kb-two", "knowledge_base_name": "职责库"},
|
||||
"wiki_pages": [
|
||||
{
|
||||
"slug": "entity/person-001",
|
||||
"title": "Zhang San",
|
||||
"page_type": "concept",
|
||||
"content": "张三负责系统故障处置。",
|
||||
"summary": "另一条摘要。",
|
||||
}
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"local_id": "two",
|
||||
"type": "person",
|
||||
"name": "张三",
|
||||
"aliases": ["Zhang San"],
|
||||
"source_wiki_slug": "entity/person-001",
|
||||
}
|
||||
],
|
||||
}
|
||||
for package, source_key in ((first_package, "kb-one"), (second_package, "kb-two")):
|
||||
await repository.import_package(
|
||||
package=package,
|
||||
source_type="weknora",
|
||||
source_key=source_key,
|
||||
source_name=source_key,
|
||||
trigger="vectorization_completed",
|
||||
created_by="admin",
|
||||
)
|
||||
|
||||
pages = await repository.list_pages(base_id=base["id"])
|
||||
assert len(pages) == 1
|
||||
assert "张三负责设备巡检。" in pages[0]["content_markdown"]
|
||||
assert "张三负责系统故障处置。" in pages[0]["content_markdown"]
|
||||
assert len(pages[0]["origins"]) == 2
|
||||
|
||||
assert await repository.search_pages(
|
||||
base_ids=[base["id"]],
|
||||
query="月度综述",
|
||||
embedding_client=None,
|
||||
) == []
|
||||
results = await repository.search_pages(
|
||||
base_ids=[base["id"]],
|
||||
query="系统故障处置",
|
||||
embedding_client=None,
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert "系统故障处置" in results[0]["matched_section"]["content"]
|
||||
assert "月度综述" not in results[0]["matched_section"]["content"]
|
||||
|
||||
async with repository._sf() as session:
|
||||
entities = (
|
||||
await session.execute(
|
||||
select(AssistantKnowledgeEntityRow).where(
|
||||
AssistantKnowledgeEntityRow.base_id == base["id"],
|
||||
AssistantKnowledgeEntityRow.status == "active",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(entities) == 1
|
||||
contributions = (
|
||||
await session.execute(
|
||||
select(AssistantKnowledgeEntityContributionRow).where(
|
||||
AssistantKnowledgeEntityContributionRow.entity_id == entities[0].id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(contributions) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uploaded_package_reuses_embedded_weknora_source_identity(
|
||||
repository: AssistantKnowledgeRepository,
|
||||
) -> None:
|
||||
base = await repository.initialize(created_by="admin")
|
||||
for upload_no, content in ((1, "旧知识"), (2, "新知识")):
|
||||
job = await repository.create_queued_import_job(
|
||||
source_type="weknora",
|
||||
source_key=f"upload:{upload_no}",
|
||||
source_name=f"upload-{upload_no}.jsonl",
|
||||
trigger="manual_package_upload",
|
||||
created_by="admin",
|
||||
base_id=base["id"],
|
||||
)
|
||||
await repository.import_package_for_job(
|
||||
job["id"],
|
||||
package={
|
||||
"source": {
|
||||
"provider": "weknora",
|
||||
"knowledge_base_id": "stable-kb-id",
|
||||
"knowledge_base_name": "同一个普通知识库",
|
||||
},
|
||||
"wiki_pages": [
|
||||
{
|
||||
"slug": "wiki/stable",
|
||||
"title": "稳定实体",
|
||||
"content": content,
|
||||
}
|
||||
],
|
||||
},
|
||||
created_by="admin",
|
||||
)
|
||||
|
||||
active_sources = await repository.list_sources(base_id=base["id"])
|
||||
assert len(active_sources) == 1
|
||||
assert active_sources[0]["source_key"] == "stable-kb-id"
|
||||
pages = await repository.list_pages(base_id=base["id"])
|
||||
assert len(pages) == 1
|
||||
assert pages[0]["content_markdown"] == "新知识"
|
||||
|
||||
|
||||
def test_assistant_citation_uses_matched_knowledge_instead_of_summary() -> None:
|
||||
source = _source_from_assistant_result(
|
||||
{
|
||||
"id": "page-1",
|
||||
"title": "设备知识",
|
||||
"summary": "只是一段摘要",
|
||||
"content_markdown": "完整页面正文",
|
||||
"matched_section": {
|
||||
"content": "实际命中的知识片段:设备过热时先断电。",
|
||||
},
|
||||
"score": 0.91,
|
||||
},
|
||||
1,
|
||||
)
|
||||
|
||||
assert source["content"] == "实际命中的知识片段:设备过热时先断电。"
|
||||
assert source["snippet"] == "实际命中的知识片段:设备过热时先断电。"
|
||||
assert "只是一段摘要" not in source["snippet"]
|
||||
|
||||
|
||||
def test_regular_wiki_citation_does_not_fall_back_to_summary() -> None:
|
||||
source = _source_from_result(
|
||||
{
|
||||
"kind": "wiki_page",
|
||||
"wiki_page_id": "wiki-1",
|
||||
"title": "设备知识",
|
||||
"summary": "只是一段摘要",
|
||||
"content": "实际知识正文",
|
||||
"matched_sections": [],
|
||||
},
|
||||
{"id": "kb-1", "name": "普通知识库"},
|
||||
1,
|
||||
)
|
||||
|
||||
assert source["content"] == "实际知识正文"
|
||||
assert source["snippet"] == "实际知识正文"
|
||||
assert "只是一段摘要" not in source["snippet"]
|
||||
@@ -199,7 +199,7 @@ async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> Non
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
|
||||
async def test_auto_sync_scheduler_scans_immediately_then_waits(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
@@ -208,6 +208,8 @@ async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
|
||||
|
||||
async def fake_sync_once(app) -> None:
|
||||
calls.append("sync")
|
||||
if calls.count("sync") > 1:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
|
||||
@@ -224,7 +226,14 @@ async def test_auto_sync_scheduler_waits_before_first_scan(monkeypatch) -> None:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
|
||||
|
||||
assert calls == ["sleep:120"]
|
||||
assert calls == ["sync", "sleep:120"]
|
||||
|
||||
|
||||
def test_local_wiki_index_defaults_to_automatic_sync() -> None:
|
||||
config = LocalWikiIndexConfig()
|
||||
|
||||
assert config.auto_sync is True
|
||||
assert config.sync_interval_seconds == 30
|
||||
|
||||
|
||||
def test_local_wiki_index_accepts_legacy_sync_flag() -> None:
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, UploadFile
|
||||
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 import embedding as embedding_module
|
||||
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
|
||||
@@ -146,7 +149,7 @@ async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch):
|
||||
async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch, tmp_path):
|
||||
observed = {}
|
||||
|
||||
class LocalModel:
|
||||
@@ -162,13 +165,115 @@ async def test_local_encoder_is_bounded_cpu_and_does_not_need_http(monkeypatch):
|
||||
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)
|
||||
(tmp_path / "onnx").mkdir()
|
||||
(tmp_path / "onnx" / "model.onnx").touch()
|
||||
config = LocalWikiEmbeddingConfig(
|
||||
provider="local",
|
||||
model="bge-embedding-m3",
|
||||
dimensions=1024,
|
||||
batch_size=2,
|
||||
local_threads=2,
|
||||
local_model_path=str(tmp_path),
|
||||
local_process_isolation=False,
|
||||
)
|
||||
client = StrictWikiEmbeddingClient(config)
|
||||
assert len(await client.embed_query("本地查询")) == 1024
|
||||
assert observed["model_name"] == "BAAI/bge-m3"
|
||||
assert observed["providers"] == ["CPUExecutionProvider"]
|
||||
assert observed["local_files_only"] is True
|
||||
assert observed["specific_model_path"] == str(tmp_path)
|
||||
assert observed["batch_size"] == observed["threads"] == 2
|
||||
assert client._client is None
|
||||
assert client.local_encoder_status()["state"] == "ready"
|
||||
assert client.local_encoder_status()["model_files_ready"] is True
|
||||
|
||||
cold_client = StrictWikiEmbeddingClient(config)
|
||||
status = cold_client.start_local_encoder()
|
||||
assert status["state"] == "starting"
|
||||
assert status["running"] is True
|
||||
await cold_client._local_warmup_task
|
||||
assert cold_client.local_encoder_status()["state"] == "ready"
|
||||
|
||||
monkeypatch.delenv("DEERFLOW_BGE_M3_MODEL_PATH", raising=False)
|
||||
missing_path = StrictWikiEmbeddingClient(
|
||||
LocalWikiEmbeddingConfig(provider="local", model="bge-embedding-m3", dimensions=1024),
|
||||
)
|
||||
missing_path.start_local_encoder()
|
||||
await missing_path._local_warmup_task
|
||||
assert missing_path.local_encoder_status()["state"] == "failed"
|
||||
assert "目录未配置" in str(missing_path.local_encoder_status()["last_error"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_encoder_uses_offline_isolated_worker(monkeypatch, tmp_path):
|
||||
(tmp_path / "onnx").mkdir()
|
||||
(tmp_path / "onnx" / "model.onnx").touch()
|
||||
observed: dict = {}
|
||||
|
||||
class FakeStdout:
|
||||
def __init__(self):
|
||||
self.lines = [json.dumps({"status": "ready"}) + "\n"]
|
||||
|
||||
def readline(self):
|
||||
return self.lines.pop(0) if self.lines else ""
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self):
|
||||
self.stdout = FakeStdout()
|
||||
self.returncode = None
|
||||
self.stdin = SimpleNamespace(write=self.write, flush=lambda: None)
|
||||
|
||||
def write(self, line):
|
||||
payload = json.loads(line)
|
||||
if payload.get("command") == "shutdown":
|
||||
self.returncode = 0
|
||||
return
|
||||
self.stdout.lines.append(
|
||||
json.dumps(
|
||||
{
|
||||
"id": payload["id"],
|
||||
"status": "ok",
|
||||
"vectors": [[1.0, *([0.0] * 1023)] for _ in payload["texts"]],
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = -15
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
process = FakeProcess()
|
||||
|
||||
def fake_popen(command, **kwargs):
|
||||
observed.update({"command": command, **kwargs})
|
||||
return process
|
||||
|
||||
monkeypatch.setattr(embedding_module.subprocess, "Popen", fake_popen)
|
||||
client = StrictWikiEmbeddingClient(
|
||||
LocalWikiEmbeddingConfig(
|
||||
provider="local",
|
||||
model="bge-embedding-m3",
|
||||
dimensions=1024,
|
||||
local_model_path=str(tmp_path),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(await client.embed_query("隔离编码")) == 1024
|
||||
assert "deerflow.integrations.weknora.local_index.embedding_worker" in observed["command"]
|
||||
assert observed["env"]["HF_HUB_OFFLINE"] == "1"
|
||||
assert observed["env"]["PYTHONIOENCODING"] == "utf-8"
|
||||
assert client.local_encoder_status()["process_isolation"] is True
|
||||
await client.close()
|
||||
assert process.poll() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -178,7 +283,7 @@ async def test_builtin_skill_without_ownership_record_can_be_distilled(monkeypat
|
||||
from app.gateway.routers import skill_knowledge
|
||||
|
||||
async def admin(_):
|
||||
return "admin"
|
||||
return "admin", True
|
||||
|
||||
class Mappings:
|
||||
async def get_authorized(self, *args, **kwargs):
|
||||
@@ -193,11 +298,61 @@ async def test_builtin_skill_without_ownership_record_can_be_distilled(monkeypat
|
||||
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, "_actor", 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"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_distill_visible_skill_only_to_writable_wiki(monkeypatch, tmp_path):
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.routers import skill_knowledge
|
||||
|
||||
async def regular_user(_):
|
||||
return "user-1", False
|
||||
|
||||
class Skills:
|
||||
async def get_visible(self, name, user_id):
|
||||
assert user_id == "user-1"
|
||||
return {"name": name} if name == "visible-skill" else None
|
||||
|
||||
async def list_favorite_skill_names(self, user_id):
|
||||
assert user_id == "user-1"
|
||||
return []
|
||||
|
||||
class Mappings:
|
||||
async def get_authorized(self, mapping_id, user_id, *, write, is_admin):
|
||||
assert (mapping_id, user_id, write, is_admin) == ("mapping", "user-1", True, False)
|
||||
return {"name": "我的 Wiki", "weknora_id": "remote"}
|
||||
|
||||
class Jobs:
|
||||
async def create_job(self, **kwargs):
|
||||
return kwargs
|
||||
|
||||
app = FastAPI()
|
||||
app.state.config = None
|
||||
app.state.skill_store = Skills()
|
||||
app.state.llmwiki_store = Mappings()
|
||||
app.state.skill_knowledge_store = Jobs()
|
||||
monkeypatch.setattr(skill_knowledge, "_actor", regular_user)
|
||||
monkeypatch.setattr(skill_knowledge, "_skill_directory", lambda *args: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
skill_knowledge,
|
||||
"get_or_new_skill_storage",
|
||||
lambda **_: SimpleNamespace(get_skills_root_path=lambda: tmp_path),
|
||||
)
|
||||
body = skill_knowledge.CreateSyncJobRequest(
|
||||
skill_names=["visible-skill"],
|
||||
targets=[{"target_type": "weknora", "target_mode": "wiki", "target_id": "mapping"}],
|
||||
review_mode="required",
|
||||
)
|
||||
|
||||
job = await skill_knowledge.create_sync_job(Request({"type": "http", "app": app}), body)
|
||||
|
||||
assert job["created_by"] == "user-1"
|
||||
assert job["review_mode"] == "off"
|
||||
|
||||
|
||||
@@ -205,3 +360,70 @@ 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_path):
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.gateway.routers import llmwiki_index
|
||||
from deerflow.config.system_settings import SystemSettings
|
||||
|
||||
async def admin(_):
|
||||
return "admin", True
|
||||
|
||||
class Embedding:
|
||||
def __init__(self):
|
||||
self.model_path = ""
|
||||
|
||||
async def reset_local_encoder(self, *, model_path=None):
|
||||
if model_path is not None:
|
||||
self.model_path = model_path
|
||||
|
||||
def local_encoder_status(self):
|
||||
return {
|
||||
"provider": "local",
|
||||
"model": "bge-embedding-m3",
|
||||
"state": "idle",
|
||||
"model_path": self.model_path,
|
||||
"model_files_ready": bool(self.model_path),
|
||||
}
|
||||
|
||||
saved = []
|
||||
monkeypatch.setattr(llmwiki_index, "_actor", admin)
|
||||
monkeypatch.setattr(llmwiki_index, "runtime_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(llmwiki_index, "load_system_settings", SystemSettings)
|
||||
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
|
||||
embedding = Embedding()
|
||||
app = FastAPI()
|
||||
app.state.config = SimpleNamespace(
|
||||
llmwiki=SimpleNamespace(
|
||||
local_wiki_index=SimpleNamespace(embedding=SimpleNamespace(provider="local"))
|
||||
)
|
||||
)
|
||||
app.state.llmwiki_embedding = embedding
|
||||
request = Request({"type": "http", "headers": [], "app": app})
|
||||
files = [
|
||||
UploadFile(filename="model.onnx", file=BytesIO(b"onnx")),
|
||||
UploadFile(filename="config.json", file=BytesIO(b"{}")),
|
||||
]
|
||||
|
||||
result = await llmwiki_index.upload_local_encoder(
|
||||
request,
|
||||
files=files,
|
||||
paths=["onnx/model.onnx", "config.json"],
|
||||
)
|
||||
|
||||
active = tmp_path / "models" / "bge-m3"
|
||||
assert (active / "onnx" / "model.onnx").read_bytes() == b"onnx"
|
||||
assert result["uploaded_files"] == 2
|
||||
assert saved[-1].local_wiki_encoder.model_path == str(active.resolve())
|
||||
assert embedding.model_path == str(active.resolve())
|
||||
|
||||
|
||||
def test_encoder_upload_rejects_parent_traversal():
|
||||
from app.gateway.routers import llmwiki_index
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
llmwiki_index._safe_relative_upload_path("../model.onnx")
|
||||
assert getattr(exc_info.value, "status_code", None) == 422
|
||||
Reference in new issue
Block a user