"""Read-mostly global assistant knowledge API and admin import actions.""" from __future__ import annotations import asyncio import base64 import json import logging from collections.abc import AsyncIterable, AsyncIterator from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import uuid4 from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile 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.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 router = APIRouter(prefix="/api/assistant-knowledge", tags=["assistant-knowledge"]) export_router = APIRouter(tags=["assistant-knowledge"]) logger = logging.getLogger(__name__) def _package_root() -> Path: return runtime_home() / "assistant-knowledge" / "packages" def _job_package_url(job_id: str) -> str: return f"/api/assistant-knowledge/import-jobs/{job_id}/package" def _weknora_export_package_url(mapping_id: str, job_id: str) -> str: return f"/api/llmwiki/knowledge-bases/{mapping_id}/export-package/{job_id}/download" def _weknora_export_status_url(mapping_id: str, job_id: str) -> str: return f"/api/llmwiki/knowledge-bases/{mapping_id}/export-package/{job_id}" def _export_manifest_path(job_id: str) -> Path: return package_path(_package_root(), job_id).with_suffix(".manifest.json") def _now_iso() -> str: return datetime.now(UTC).isoformat() def _normalize_export_manifest(payload: dict[str, Any]) -> dict[str, Any]: job_id = str(payload.get("id") or "") mapping_id = str(payload.get("mapping_id") or "") path = package_path(_package_root(), job_id) if job_id else None file_exists = bool(path and path.is_file()) status = str(payload.get("status") or "") normalized = { **payload, "id": job_id, "mapping_id": mapping_id, "status": status, "phase": str(payload.get("phase") or status or "unknown"), "counts": payload.get("counts") if isinstance(payload.get("counts"), dict) else {}, "package_download_url": ( _weknora_export_package_url(mapping_id, job_id) if mapping_id and job_id else payload.get("package_download_url") ), "status_url": ( _weknora_export_status_url(mapping_id, job_id) if mapping_id and job_id else payload.get("status_url") ), "file_exists": file_exists, "file_size_bytes": int(path.stat().st_size) if file_exists and path else int(payload.get("file_size_bytes") or 0), } return normalized def _write_export_manifest( *, job_id: str, mapping_id: str, status: str, phase: str, counts: dict[str, Any] | None = None, error: str | None = None, ) -> dict[str, Any]: path = _export_manifest_path(job_id) existing: dict[str, Any] = {} if path.is_file(): try: value = json.loads(path.read_text(encoding="utf-8")) existing = value if isinstance(value, dict) else {} except (OSError, ValueError, TypeError): existing = {} now = _now_iso() payload = { **existing, "id": job_id, "mapping_id": mapping_id, "status": status, "phase": phase, "counts": counts if counts is not None else existing.get("counts") or {}, "package_download_url": _weknora_export_package_url(mapping_id, job_id), "status_url": _weknora_export_status_url(mapping_id, job_id), "error": error, "created_at": existing.get("created_at") or now, "updated_at": now, "completed_at": now if status in {"completed", "failed"} else existing.get("completed_at"), } 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") return payload def _read_export_manifest(job_id: str) -> dict[str, Any] | None: path = _export_manifest_path(job_id) if not path.is_file(): return None try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): return None return _normalize_export_manifest(value) if isinstance(value, dict) else None def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any]]: root = _package_root() if not root.is_dir(): return [] rows: list[dict[str, Any]] = [] for path in root.glob("*.manifest.json"): try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): continue if not isinstance(value, dict) or str(value.get("mapping_id") or "") != mapping_id: continue rows.append(_normalize_export_manifest(value)) rows.sort(key=lambda row: str(row.get("created_at") or row.get("updated_at") or ""), reverse=True) return rows[:limit] async def _actor(request: Request, *, admin: bool = False) -> str | None: user = await get_optional_user_from_request(request) if user is None: return None if admin and getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="该操作仅管理员可用") return str(user.id) def _store(request: Request): store = getattr(request.app.state, "assistant_knowledge_store", None) if store is None: raise HTTPException(status_code=503, detail="助手知识库存储不可用") return store class InitializeRequest(BaseModel): name: str = Field(default="知识梳理总库", min_length=1, max_length=255) class BaseCreateRequest(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 class ExportToAssistantRequest(BaseModel): assistant_base_id: str | None = Field(default=None, max_length=128) @router.get("/base") async def get_base(request: Request) -> dict[str, Any]: await _actor(request) return {"base": await _store(request).get_base()} @router.post("/base/initialize", status_code=201) async def initialize(request: Request, body: InitializeRequest) -> dict[str, Any]: actor = await _actor(request, admin=True) return {"base": await _store(request).initialize(created_by=actor, name=body.name)} @router.get("/bases") async def list_bases(request: Request) -> dict[str, Any]: await _actor(request) bases = await _store(request).list_bases() return {"bases": bases, "total": len(bases)} @router.post("/bases", status_code=201) async def create_base(request: Request, body: BaseCreateRequest) -> dict[str, Any]: actor = await _actor(request, admin=True) return { "base": await _store(request).create_base( created_by=actor, name=body.name, description=body.description, ) } @router.get("/bases/{base_id}") async def get_base_by_id(request: Request, base_id: str) -> dict[str, Any]: await _actor(request) base = await _store(request).get_base(base_id) if base is None: raise HTTPException(status_code=404, detail="助手知识库不存在") return {"base": base} @router.get("/wiki-pages") async def list_wiki_pages( request: Request, base_id: str | None = Query(default=None), q: str | None = Query(default=None), page_type: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), ) -> 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)} @router.get("/wiki-pages/{slug:path}/revisions/compare") async def compare_revisions(request: Request, slug: str, left: str = Query(...), right: str = Query(...)) -> dict[str, Any]: await _actor(request) result = await _store(request).compare_revisions(left, right) if result is None: raise HTTPException(status_code=404, detail="版本不存在或不属于同一页面") return result @router.get("/wiki-pages/{slug:path}/revisions") async def list_revisions(request: Request, slug: str, base_id: str | None = Query(default=None)) -> dict[str, Any]: await _actor(request) return {"revisions": await _store(request).list_revisions(slug, base_id=base_id)} @router.post("/wiki-pages/{slug:path}/revisions/{revision_id}/rollback") async def rollback_revision( request: Request, slug: str, revision_id: str, base_id: str | None = Query(default=None), ) -> dict[str, Any]: actor = await _actor(request, admin=True) row = await _store(request).rollback(slug=slug, revision_id=revision_id, actor=actor, base_id=base_id) if row is None: raise HTTPException(status_code=404, detail="页面或版本不存在") return row @router.get("/wiki-pages/{slug:path}") async def get_wiki_page(request: Request, slug: str, base_id: str | None = Query(default=None)) -> dict[str, Any]: await _actor(request) row = await _store(request).get_page(slug, base_id=base_id) if row is None: raise HTTPException(status_code=404, detail="Wiki 页面不存在") return row @router.get("/search") async def search( request: Request, q: str = Query(..., min_length=1), base_id: str | None = Query(default=None), 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)} @router.get("/sources") async def list_sources(request: Request, base_id: str | None = Query(default=None)) -> dict[str, Any]: await _actor(request) return {"sources": await _store(request).list_sources(base_id=base_id)} @router.get("/entities/{entity_id}") async def get_entity(request: Request, entity_id: str) -> dict[str, Any]: await _actor(request) row = await _store(request).get_entity(entity_id) if row is None: raise HTTPException(status_code=404, detail="实体不存在") return row @router.get("/relations/{relation_id}") async def get_relation(request: Request, relation_id: str) -> dict[str, Any]: await _actor(request) row = await _store(request).get_relation(relation_id) if row is None: raise HTTPException(status_code=404, detail="关系不存在") return row @router.delete("/sources/{source_id}", status_code=204) async def delete_source(request: Request, source_id: str) -> None: await _actor(request, admin=True) if not await _store(request).delete_source(source_id): raise HTTPException(status_code=404, detail="来源不存在") @router.get("/import-jobs") async def list_import_jobs( request: Request, base_id: str | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), ) -> dict[str, Any]: await _actor(request) jobs = await _store(request).list_jobs(limit, base_id=base_id) for job in jobs: job["package_download_url"] = _job_package_url(str(job["id"])) return {"jobs": jobs} @router.get("/import-jobs/{job_id}") async def get_import_job(request: Request, job_id: str) -> dict[str, Any]: await _actor(request) row = await _store(request).get_job(job_id) if row is None: raise HTTPException(status_code=404, detail="导入任务不存在") row["package_download_url"] = _job_package_url(job_id) return row @router.get("/import-jobs/{job_id}/package") async def download_import_package(request: Request, job_id: str) -> FileResponse: await _actor(request, admin=True) row = await _store(request).get_job(job_id) if row is None: raise HTTPException(status_code=404, detail="导入任务不存在") path = package_path(_package_root(), job_id) if not path.is_file(): raise HTTPException(status_code=404, detail="导出包尚未生成或已被清理") return FileResponse( path, media_type="application/x-ndjson", filename=f"weknora-assistant-knowledge-{job_id}.jsonl", ) @router.post("/imports/skills", status_code=202) async def import_skills(request: Request, _body: SkillImportRequest) -> dict[str, Any]: await _actor(request, admin=True) raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库") @router.post("/imports/files", status_code=410) async def import_files(request: Request) -> dict[str, Any]: await _actor(request, admin=True) raise HTTPException(status_code=410, detail="助手知识库不支持导入任意本地文件,请从 WeKnora 普通知识库导出后导入") @router.post("/imports/weknora-package", status_code=202) async def import_weknora_package_file( request: Request, file: UploadFile = File(...), assistant_base_id: str | None = Form(default=None), ) -> dict[str, Any]: actor = await _actor(request, admin=True) upload_id = str(uuid4()) filename = (file.filename or "weknora-assistant-knowledge.jsonl").strip() job = await _store(request).create_queued_import_job( source_type="weknora", source_key=f"upload:{upload_id}", source_name=filename, trigger="manual_package_upload", created_by=actor, metadata={ "type": "weknora_export_package", "filename": filename, "upload_id": upload_id, }, upload_batch_id=upload_id, base_id=assistant_base_id, phase="uploading", ) path = package_path(_package_root(), str(job["id"])) path.parent.mkdir(parents=True, exist_ok=True) size = 0 try: with path.open("wb") as handle: while True: chunk = await file.read(1024 * 1024) if not chunk: break size += len(chunk) handle.write(chunk) finally: await file.close() if size <= 0: await _store(request).update_import_job( str(job["id"]), status="failed", phase="failed", error="上传文件为空", completed=True, ) raise HTTPException(status_code=422, detail="上传文件为空") await _store(request).update_import_job( str(job["id"]), status="queued", phase="queued", counts={"uploaded_bytes": size}, ) asyncio.create_task( _import_package_file_for_job( request=request, job_id=str(job["id"]), path=path, actor=actor, ) ) job["package_download_url"] = _job_package_url(str(job["id"])) return job async def _write_weknora_export_package( client: Any, remote_id: str, source: dict[str, Any], path: Path, *, vectors: AsyncIterable[dict[str, Any]] | None = None, ) -> dict[str, int]: path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(path.suffix + ".tmp") counts: dict[str, int] = { "wiki_page": 0, "document": 0, "chunk": 0, "vector": 0, "entity": 0, "relation": 0, "graph": 0, } with tmp_path.open("w", encoding="utf-8", newline="\n") as handle: write_jsonl_record( handle, "source", { **source, "package_version": PACKAGE_VERSION, "export_format": "assistant-knowledge-jsonl", }, ) # Wiki pages first: these are the human-readable pages that the # assistant knowledge base presents in wiki form. page_no = 1 while True: page = await client.list_wiki_pages(remote_id, page=page_no, page_size=100) batch = page.get("pages") or [] for row in batch: if isinstance(row, dict): payload = row slug = str(row.get("slug") or row.get("wiki_slug") or row.get("path") or "") if slug: try: detail = await client.get_wiki_page(remote_id, slug) 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, ) write_jsonl_record(handle, "wiki_page", payload) counts["wiki_page"] += 1 if counts["wiki_page"] >= int(page.get("total") or counts["wiki_page"]) or not batch: break page_no += 1 # Then export WeKnora's processed knowledge and chunks. Chunks are the # important boundary for large RAG imports: the assistant side should # consume the already-parsed/segmented material instead of re-reading # arbitrary source files. document_count = 0 page_no = 1 while True: page = await client.list_documents(remote_id, page=page_no, page_size=100) batch = page.get("items") or [] for document in batch: if not isinstance(document, dict): continue write_jsonl_record(handle, "document", document) counts["document"] += 1 document_count += 1 document_id = str(document.get("id") or document.get("knowledge_id") or "") if not document_id: continue chunk_page = 1 while True: values = await client.list_chunks(document_id, page=chunk_page, page_size=100) chunk_batch = values.get("items") or [] for row in chunk_batch: if isinstance(row, dict): write_jsonl_record(handle, "chunk", {**row, "source_document_id": document_id}) counts["chunk"] += 1 if len(chunk_batch) < 100: break chunk_page += 1 if document_count >= int(page.get("total") or document_count) or not batch: break page_no += 1 if vectors is not None: async for row in vectors: if isinstance(row, dict): 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"} 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")): write_jsonl_record( handle, "entity", { "id": row.get("id"), "name": row.get("title") or row.get("name"), "type": row.get("type") or "concept", "confidence": 1.0, }, ) counts["entity"] += 1 for row in edges: write_jsonl_record( handle, "relation", { "source_entity_id": row.get("source"), "target_entity_id": row.get("target"), "predicate": row.get("predicate") or "references", "confidence": 1.0, "evidence": [{"source": "weknora_graph"}], }, ) counts["relation"] += 1 tmp_path.replace(path) return counts async def _local_wiki_vector_context(request: Request, mapping_id: str) -> tuple[Any | None, str, str]: store = getattr(request.app.state, "llmwiki_index_store", None) if store is None: return None, "", "" embedding = getattr(request.app.state, "llmwiki_embedding", None) fingerprint = str(getattr(embedding, "fingerprint", "") or "") if not fingerprint: try: _revision, state_fingerprint, _state = await store.get_index_revision(mapping_id) fingerprint = str(state_fingerprint or "") except Exception: # noqa: BLE001 fingerprint = "" if not fingerprint: return store, "", "" model = str(getattr(getattr(embedding, "config", None), "model", "") or "") return store, fingerprint, model def _local_wiki_vector_export_payload( row: dict[str, Any], *, model: str, fingerprint: str, ) -> dict[str, Any] | None: blob = row.get("vector_blob") if isinstance(blob, memoryview): blob = blob.tobytes() if not isinstance(blob, (bytes, bytearray)): return None page = row.get("page") if isinstance(row.get("page"), dict) else {} return { "id": row.get("id"), "wiki_slug": str(page.get("slug") or "").strip("/"), "wiki_page_id": page.get("id") or page.get("remote_page_id"), "title": page.get("title") or "", "section_index": int(row.get("section_index") or 0), "heading": row.get("heading"), "section_content": row.get("section_content") or "", "content_hash": row.get("content_hash") or "", "embedding_model": model, "embedding_fingerprint": row.get("embedding_fingerprint") or fingerprint, "embedding_dimensions": int(row.get("embedding_dimensions") or 0), "vector_encoding": "float32-le-normalized-base64", "vector_blob_base64": base64.b64encode(bytes(blob)).decode("ascii"), } async def _iter_local_wiki_vectors_for_export( request: Request, mapping_id: str, *, batch_size: int = 1000, ) -> AsyncIterator[dict[str, Any]]: store, fingerprint, model = await _local_wiki_vector_context(request, mapping_id) if store is None or not fingerprint: return try: async for row in store.iter_vector_snapshot_rows(mapping_id, fingerprint, batch_size=batch_size): if not isinstance(row, dict): continue 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) async def _has_local_wiki_vectors_for_export(request: Request, mapping_id: str) -> bool: async for _row in _iter_local_wiki_vectors_for_export(request, mapping_id, batch_size=1): return True return False async def _load_local_wiki_vectors_for_export(request: Request, mapping_id: str) -> list[dict[str, Any]]: return [row async for row in _iter_local_wiki_vectors_for_export(request, mapping_id)] async def _ensure_local_wiki_vectors_for_export( request: Request, 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 sync_mapping = dict(mapping) sync_mapping.setdefault("id", mapping_id) sync_mapping.setdefault( "weknora_id", mapping.get("remote_knowledge_base_id") or mapping.get("weknora_id") or "", ) 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, ) async def _import_package_file_for_job( *, request: Request, job_id: str, path: Path, actor: str | None, ) -> None: store = _store(request) try: await store.update_import_job(job_id, status="running", phase="loading_package") package = await asyncio.to_thread(read_package, path) await store.update_import_job(job_id, status="running", phase="importing") await store.import_package_for_job( job_id, package=package, created_by=actor, mirror_to_global=True, ) except Exception as exc: # noqa: BLE001 logger.exception("Assistant knowledge package import failed: job=%s", job_id) await store.update_import_job( job_id, status="failed", phase="failed", error=str(exc), completed=True, ) async def _export_weknora_then_import_for_job( *, request: Request, job_id: str, mapping: dict[str, Any], remote_id: str, source: dict[str, Any], actor: str | None, ) -> None: store = _store(request) path = package_path(_package_root(), job_id) try: await store.update_import_job(job_id, status="running", phase="exporting") runtime = get_resolved_llmwiki_runtime(request.app.state.config) 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) counts = await _write_weknora_export_package( build_weknora_client(runtime), remote_id, source, path, vectors=_iter_local_wiki_vectors_for_export(request, mapping_id), ) await store.update_import_job(job_id, status="running", phase="package_ready", counts=counts) await _import_package_file_for_job(request=request, job_id=job_id, path=path, actor=actor) except Exception as exc: # noqa: BLE001 logger.exception("Assistant knowledge WeKnora export failed: job=%s", job_id) await store.update_import_job( job_id, status="failed", phase="failed", error=str(exc), completed=True, ) async def _export_weknora_package(client: Any, remote_id: str, source: dict[str, Any]) -> dict[str, Any]: """Legacy in-memory export shape, kept for tests and older callers.""" pages: list[dict[str, Any]] = [] page_no = 1 while True: page = await client.list_wiki_pages(remote_id, page=page_no, page_size=100) batch = page.get("pages") or [] pages.extend(batch) if len(pages) >= int(page.get("total") or len(pages)) or not batch: break page_no += 1 documents: list[dict[str, Any]] = [] chunks: list[dict[str, Any]] = [] page_no = 1 while True: page = await client.list_documents(remote_id, page=page_no, page_size=100) batch = page.get("items") or [] documents.extend(batch) for document in batch: document_id = str(document.get("id") or document.get("knowledge_id") or "") if not document_id: continue chunk_page = 1 while True: values = await client.list_chunks(document_id, page=chunk_page, page_size=100) chunk_batch = values.get("items") or [] chunks.extend([{**row, "source_document_id": document_id} for row in chunk_batch]) if len(chunk_batch) < 100: break chunk_page += 1 if len(documents) >= int(page.get("total") or len(documents)) or not batch: break page_no += 1 try: graph = await client.get_wiki_graph(remote_id, limit=10000) except WeKnoraError: graph = {"nodes": [], "edges": [], "capability_warning": "graph API unavailable"} entities = [{"id": row.get("id"), "name": row.get("title") or row.get("name"), "type": row.get("type") or "concept", "confidence": 1.0} for row in graph.get("nodes") or [] if row.get("id") and (row.get("title") or row.get("name"))] relations = [ {"source_entity_id": row.get("source"), "target_entity_id": row.get("target"), "predicate": row.get("predicate") or "references", "confidence": 1.0, "evidence": [{"source": "weknora_graph"}]} for row in graph.get("edges") or [] ] return {"source": source, "wiki_pages": pages, "documents": documents, "chunks": chunks, "entities": entities, "relations": relations, "graph": graph} async def _export_weknora_package_only_job( *, request: Request, job_id: str, mapping: dict[str, Any], mapping_id: str, remote_id: str, source: dict[str, Any], ) -> None: try: _write_export_manifest(job_id=job_id, mapping_id=mapping_id, status="running", phase="exporting") runtime = get_resolved_llmwiki_runtime(request.app.state.config) if not runtime.weknora_enabled: raise RuntimeError("未配置普通知识库服务") await _ensure_local_wiki_vectors_for_export(request, mapping) counts = await _write_weknora_export_package( build_weknora_client(runtime), remote_id, source, package_path(_package_root(), job_id), vectors=_iter_local_wiki_vectors_for_export(request, mapping_id), ) _write_export_manifest( job_id=job_id, mapping_id=mapping_id, status="completed", phase="package_ready", counts=counts, ) except Exception as exc: # noqa: BLE001 logger.exception("WeKnora vectorized export package failed: mapping=%s job=%s", mapping_id, job_id) _write_export_manifest( job_id=job_id, mapping_id=mapping_id, status="failed", phase="failed", error=str(exc), ) @export_router.get("/api/llmwiki/knowledge-bases/{mapping_id}/export-package") async def list_weknora_export_packages( request: Request, mapping_id: str, limit: int = Query(default=50, ge=1, le=200), ) -> dict[str, Any]: actor = await _actor(request, admin=True) mapping = await request.app.state.llmwiki_store.get_authorized( mapping_id, actor or "system", write=False, is_admin=True, ) if mapping is None: raise HTTPException(status_code=404, detail="普通知识库不存在") rows = _list_export_manifests(mapping_id, limit=limit) return {"jobs": rows, "total": len(rows)} @export_router.post("/api/llmwiki/knowledge-bases/{mapping_id}/export-package", status_code=202) async def create_weknora_export_package(request: Request, mapping_id: str) -> dict[str, Any]: actor = await _actor(request, admin=True) runtime = get_resolved_llmwiki_runtime(request.app.state.config) if not runtime.weknora_enabled: raise HTTPException(status_code=503, detail="未配置普通知识库服务") mapping = await request.app.state.llmwiki_store.get_authorized( mapping_id, actor or "system", write=False, is_admin=True, ) if mapping is None: raise HTTPException(status_code=404, detail="普通知识库不存在") job_id = str(uuid4()) source = { "type": "weknora", "knowledge_base_id": mapping_id, "remote_knowledge_base_id": mapping["weknora_id"], "knowledge_base_name": mapping["name"], "package_kind": "weknora_vectorized_export", } payload = _write_export_manifest(job_id=job_id, mapping_id=mapping_id, status="queued", phase="queued") asyncio.create_task( _export_weknora_package_only_job( request=request, job_id=job_id, mapping=mapping, mapping_id=mapping_id, remote_id=str(mapping["weknora_id"]), source=source, ) ) return payload @export_router.get("/api/llmwiki/knowledge-bases/{mapping_id}/export-package/{job_id}") async def get_weknora_export_package_status(request: Request, mapping_id: str, job_id: str) -> dict[str, Any]: await _actor(request, admin=True) payload = _read_export_manifest(job_id) if payload is None or str(payload.get("mapping_id") or "") != mapping_id: raise HTTPException(status_code=404, detail="导出任务不存在") return payload @export_router.get("/api/llmwiki/knowledge-bases/{mapping_id}/export-package/{job_id}/download") async def download_weknora_export_package(request: Request, mapping_id: str, job_id: str) -> FileResponse: await _actor(request, admin=True) payload = _read_export_manifest(job_id) if payload is None or str(payload.get("mapping_id") or "") != mapping_id: raise HTTPException(status_code=404, detail="导出任务不存在") if str(payload.get("status") or "") != "completed": raise HTTPException(status_code=409, detail="导出包尚未生成完成") path = package_path(_package_root(), job_id) if not path.is_file(): raise HTTPException(status_code=404, detail="导出包已不存在") return FileResponse( path, media_type="application/x-ndjson", filename=f"weknora-vectorized-knowledge-{mapping_id}-{job_id}.jsonl", ) @export_router.post("/api/llmwiki/knowledge-bases/{mapping_id}/export-to-assistant", status_code=202) async def export_to_assistant( request: Request, mapping_id: str, body: ExportToAssistantRequest | None = None, ) -> dict[str, Any]: actor = await _actor(request, admin=True) runtime = get_resolved_llmwiki_runtime(request.app.state.config) if not runtime.weknora_enabled: raise HTTPException(status_code=503, detail="未配置普通知识库服务") mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, actor or "system", write=False, is_admin=True) if mapping is None: raise HTTPException(status_code=404, detail="普通知识库不存在") source = { "type": "weknora", "knowledge_base_id": mapping_id, "remote_knowledge_base_id": mapping["weknora_id"], "knowledge_base_name": mapping["name"], } try: job = await _store(request).create_queued_import_job( source_type="weknora", source_key=mapping_id, source_name=str(mapping["name"]), trigger="manual_from_weknora", created_by=actor, metadata=source, mapping_id=mapping_id, base_id=body.assistant_base_id if body else None, phase="exporting", ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc job["package_download_url"] = _job_package_url(str(job["id"])) asyncio.create_task( _export_weknora_then_import_for_job( request=request, job_id=str(job["id"]), mapping=mapping, remote_id=str(mapping["weknora_id"]), source=source, actor=actor, ) ) return job @router.post("/sources/{source_id}/reimport", status_code=202) async def reimport_source(request: Request, source_id: str) -> dict[str, Any]: actor = await _actor(request, admin=True) store = _store(request) source = await store.get_source(source_id) if source is None: raise HTTPException(status_code=404, detail="来源不存在") if source["source_type"] == "skill": raise HTTPException(status_code=410, detail="不支持技能来源直接重新导入助手知识库,请重新从 WeKnora 普通知识库导入") if source["source_type"] != "weknora": raise HTTPException(status_code=409, detail="本地文件来源未保留原文件,请重新上传") mapping_id = str(source.get("knowledge_base_mapping_id") or source["source_key"]) if not mapping_id or mapping_id.startswith("upload:"): raise HTTPException(status_code=409, detail="上传包来源不能直接重新导入,请重新上传 WeKnora 导出包") runtime = get_resolved_llmwiki_runtime(request.app.state.config) if not runtime.weknora_enabled: raise HTTPException(status_code=503, detail="未配置普通知识库服务") mapping = await request.app.state.llmwiki_store.get_authorized( mapping_id, actor or "system", write=False, is_admin=True, ) if mapping is None: raise HTTPException(status_code=404, detail="来源普通知识库不存在") source_payload = { "type": "weknora", "knowledge_base_id": mapping_id, "remote_knowledge_base_id": mapping["weknora_id"], "knowledge_base_name": mapping["name"], } try: job = await store.create_queued_import_job( source_type="weknora", source_key=mapping_id, source_name=str(mapping["name"]), trigger="source_reimport", created_by=actor, metadata=source_payload, mapping_id=mapping_id, base_id=str(source["base_id"]), phase="exporting", ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc job["package_download_url"] = _job_package_url(str(job["id"])) asyncio.create_task( _export_weknora_then_import_for_job( request=request, job_id=str(job["id"]), mapping=mapping, remote_id=str(mapping["weknora_id"]), source=source_payload, actor=actor, ) ) return job