"""Small, version-pinned adapter over WeKnora's official REST API.""" from __future__ import annotations import time from collections.abc import Mapping, Sequence from typing import Any from urllib.parse import quote import httpx class WeKnoraError(RuntimeError): """Normalized upstream failure that is safe for DeerFlow to translate.""" def __init__(self, message: str, *, status_code: int = 502) -> None: super().__init__(message) self.status_code = status_code def _data(payload: Any) -> Any: if not isinstance(payload, dict): raise WeKnoraError("WeKnora returned an invalid response") if payload.get("success") is False: message = payload.get("message") or payload.get("error") or "WeKnora rejected the request" raise WeKnoraError(str(message), status_code=400) return payload.get("data", payload) def _as_list(value: Any) -> list[dict[str, Any]]: if isinstance(value, list): return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, dict): for key in ("items", "list", "knowledge_bases", "knowledge", "records"): nested = value.get(key) if isinstance(nested, list): return [dict(item) for item in nested if isinstance(item, Mapping)] return [] class WeKnoraClient: """Async client for a trusted-network WeKnora endpoint. The caller may inject an ``httpx.AsyncClient`` for connection pooling and tests. When omitted, each public method creates and closes a short-lived client. If admin credentials are configured, DeerFlow authenticates to WeKnora with a cached bearer token; otherwise it keeps the legacy trusted-network anonymous mode for deployments that allow DeerFlow through a reverse proxy. """ def __init__( self, *, base_url: str, timeout_seconds: float = 30.0, http_client: httpx.AsyncClient | None = None, admin_email: str = "", admin_password: str = "", ) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds self._http_client = http_client self.admin_email = admin_email.strip() self.admin_password = admin_password self._auth_token = "" self._tenant_id = "" self._auth_expires_at = 0.0 if not self.base_url: raise ValueError("WeKnora base URL is required") @property def _has_admin_credentials(self) -> bool: return bool(self.admin_email and self.admin_password) @staticmethod def _extract_login_token(payload: Any) -> tuple[str, str]: if not isinstance(payload, dict): return "", "" source = payload.get("data") if isinstance(payload.get("data"), dict) else payload token = str( source.get("token") or source.get("access_token") or payload.get("token") or payload.get("access_token") or "" ) tenant = ( source.get("tenant") if isinstance(source.get("tenant"), dict) else source.get("active_tenant") if isinstance(source.get("active_tenant"), dict) else payload.get("tenant") ) user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user") tenant_id = "" if isinstance(tenant, dict): tenant_id = str(tenant.get("id") or "") if not tenant_id and isinstance(user, dict): tenant_id = str(user.get("tenant_id") or "") return token, tenant_id async def _login(self, *, force_refresh: bool = False) -> None: if not self._has_admin_credentials: return now = time.time() if self._auth_token and not force_refresh and self._auth_expires_at > now + 60: return owns_client = self._http_client is None client = self._http_client or httpx.AsyncClient( timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False, trust_env=False, ) try: response = await client.post( f"{self.base_url}/api/v1/auth/login", json={"email": self.admin_email, "password": self.admin_password}, headers={"Accept": "application/json"}, ) except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora admin login timed out", status_code=504) from exc except httpx.HTTPError as exc: raise WeKnoraError("WeKnora admin login is unavailable", status_code=502) from exc finally: if owns_client: await client.aclose() if response.status_code >= 400: raise WeKnoraError(f"WeKnora admin login failed ({response.status_code})", status_code=502) try: payload = response.json() except ValueError as exc: raise WeKnoraError("WeKnora admin login returned invalid JSON", status_code=502) from exc if isinstance(payload, dict) and payload.get("success") is False: raise WeKnoraError(str(payload.get("message") or "WeKnora admin login failed"), status_code=502) token, tenant_id = self._extract_login_token(payload) if not token: raise WeKnoraError("WeKnora admin login did not return a token", status_code=502) self._auth_token = token self._tenant_id = tenant_id # WeKnora login responses do not consistently expose expiry. Refresh # periodically and retry once if upstream says the token is stale. self._auth_expires_at = now + 25 * 60 async def _headers(self, *, force_refresh: bool = False, accept: str = "application/json") -> dict[str, str]: headers = {"Accept": accept} if self._has_admin_credentials: await self._login(force_refresh=force_refresh) if self._auth_token: headers["Authorization"] = f"Bearer {self._auth_token}" if self._tenant_id: headers["X-Tenant-ID"] = self._tenant_id return headers async def _request( self, method: str, path: str, *, json: Any = None, params: Mapping[str, Any] | None = None, files: Any = None, data: Mapping[str, Any] | None = None, ) -> Any: owns_client = self._http_client is None client = self._http_client or httpx.AsyncClient( timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False, trust_env=False, ) try: for attempt in range(2): response = await client.request( method, f"{self.base_url}{path}", headers=await self._headers(force_refresh=attempt > 0), json=json, params=params, files=files, data=data, ) if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0: break except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora request timed out", status_code=504) from exc except httpx.HTTPError as exc: raise WeKnoraError("WeKnora is unavailable", status_code=502) from exc finally: if owns_client: await client.aclose() if response.status_code == 404: raise WeKnoraError("WeKnora resource was not found", status_code=404) if response.status_code in {401, 403}: if self._has_admin_credentials: raise WeKnoraError("WeKnora rejected DeerFlow's authenticated internal request", status_code=502) raise WeKnoraError( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, ) if response.status_code >= 400: detail = "" try: body = response.json() if isinstance(body, dict): detail = str(body.get("message") or body.get("error") or body.get("detail") or "") except ValueError: detail = "" raise WeKnoraError(detail or f"WeKnora returned HTTP {response.status_code}", status_code=502) if response.status_code == 204 or not response.content: return {"success": True} try: return response.json() except ValueError as exc: raise WeKnoraError("WeKnora returned invalid JSON") from exc async def health(self) -> dict[str, Any]: payload = await self._request("GET", "/health") return dict(payload) if isinstance(payload, dict) else {"status": "ok"} async def list_knowledge_bases(self) -> list[dict[str, Any]]: return _as_list(_data(await self._request("GET", "/api/v1/knowledge-bases"))) async def get_knowledge_base(self, knowledge_base_id: str) -> dict[str, Any]: value = _data(await self._request("GET", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}")) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid knowledge base") return dict(value) async def list_models(self) -> list[dict[str, Any]]: """List models from WeKnora; model credentials are never returned.""" return _as_list(_data(await self._request("GET", "/api/v1/models"))) async def get_default_embedding_model_id(self) -> str: models = [item for item in await self.list_models() if str(item.get("type") or "").strip().lower() == "embedding"] preferred = next((item for item in models if item.get("is_default") is True), None) selected = preferred or next( (item for item in models if str(item.get("status") or "active").lower() == "active"), None, ) model_id = str((selected or {}).get("id") or "") if not model_id: raise WeKnoraError( "WeKnora has no available embedding model; configure its default model in WeKnora", status_code=400, ) return model_id async def get_default_knowledge_qa_model_id(self) -> str: """Resolve WeKnora's own active synthesis model for Wiki indexing.""" models = [item for item in await self.list_models() if str(item.get("type") or "").strip().lower() == "knowledgeqa"] preferred = next((item for item in models if item.get("is_default") is True), None) selected = preferred or next( (item for item in models if str(item.get("status") or "active").lower() == "active"), None, ) model_id = str((selected or {}).get("id") or "") if not model_id: raise WeKnoraError( "WeKnora has no available KnowledgeQA model; configure one in WeKnora before creating a Wiki", status_code=400, ) return model_id async def create_knowledge_base( self, *, name: str, description: str = "", kb_type: str = "document", embedding_model_id: str | None = None, wiki_enabled: bool = False, ) -> dict[str, Any]: model_id = embedding_model_id or await self.get_default_embedding_model_id() create_payload: dict[str, Any] = { "name": name, "description": description, "type": kb_type, "embedding_model_id": model_id, } if wiki_enabled: synthesis_model_id = await self.get_default_knowledge_qa_model_id() create_payload.update( { "summary_model_id": synthesis_model_id, "indexing_strategy": { "vector_enabled": True, "keyword_enabled": True, "wiki_enabled": True, "graph_enabled": False, }, "wiki_config": { "synthesis_model_id": synthesis_model_id, "extraction_granularity": "standard", }, } ) payload = await self._request( "POST", "/api/v1/knowledge-bases", json=create_payload, ) value = _data(payload) if not isinstance(value, dict) or not value.get("id"): raise WeKnoraError("WeKnora did not return the created knowledge base") return dict(value) async def update_knowledge_base(self, knowledge_base_id: str, changes: Mapping[str, Any]) -> dict[str, Any]: payload = dict(changes) if not str(payload.get("name") or "").strip(): current = await self.get_knowledge_base(knowledge_base_id) current_name = str(current.get("name") or "").strip() if not current_name: raise WeKnoraError("WeKnora knowledge base name is required for updates") payload["name"] = current_name value = _data( await self._request( "PUT", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}", json=payload, ) ) return dict(value) if isinstance(value, dict) else {} async def delete_knowledge_base(self, knowledge_base_id: str) -> None: await self._request("DELETE", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}") async def list_documents(self, knowledge_base_id: str, *, page: int = 1, page_size: int = 100) -> dict[str, Any]: payload = await self._request( "GET", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge", params={"page": page, "page_size": page_size}, ) rows = _as_list(_data(payload)) total = payload.get("total", len(rows)) if isinstance(payload, dict) else len(rows) return {"items": rows, "total": int(total or 0), "page": page, "page_size": page_size} async def upload_document( self, knowledge_base_id: str, *, filename: str, content: bytes, content_type: str = "application/octet-stream", ) -> dict[str, Any]: value = _data( await self._request( "POST", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/file", files={"file": (filename, content, content_type)}, ) ) return dict(value) if isinstance(value, dict) else {} async def import_document_url(self, knowledge_base_id: str, *, url: str) -> dict[str, Any]: value = _data( await self._request( "POST", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/url", json={"url": url}, ) ) return dict(value) if isinstance(value, dict) else {} async def create_manual_document( self, knowledge_base_id: str, *, title: str, content: str, ) -> dict[str, Any]: value = _data( await self._request( "POST", f"/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/knowledge/manual", json={"title": title, "content": content, "status": "publish"}, ) ) return dict(value) if isinstance(value, dict) else {} async def delete_document(self, knowledge_id: str) -> None: await self._request("DELETE", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}") async def get_document(self, knowledge_id: str) -> dict[str, Any]: value = _data(await self._request("GET", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}")) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid document") return dict(value) async def get_document_preview(self, knowledge_id: str) -> tuple[bytes, str]: """Return the original document preview bytes and its media type.""" owns_client = self._http_client is None client = self._http_client or httpx.AsyncClient( timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=True, trust_env=False, ) try: for attempt in range(2): response = await client.request( "GET", f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", headers=await self._headers(force_refresh=attempt > 0, accept="*/*"), follow_redirects=True, ) if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0: break except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc except httpx.HTTPError as exc: raise WeKnoraError("WeKnora preview is unavailable", status_code=502) from exc finally: if owns_client: await client.aclose() if response.status_code == 404: raise WeKnoraError("WeKnora document preview was not found", status_code=404) if response.status_code in {401, 403}: if self._has_admin_credentials: raise WeKnoraError("WeKnora rejected DeerFlow's authenticated preview request", status_code=502) raise WeKnoraError( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, ) if response.status_code >= 400: raise WeKnoraError(f"WeKnora returned HTTP {response.status_code}", status_code=502) media_type = response.headers.get("content-type", "application/octet-stream").split(";", 1)[0].strip() return response.content, media_type or "application/octet-stream" async def get_knowledge_base_file( self, knowledge_base_id: str, file_path: str, ) -> tuple[bytes, str]: """Return one protected Wiki attachment using DeerFlow's WeKnora identity.""" owns_client = self._http_client is None client = self._http_client or httpx.AsyncClient( timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=True, trust_env=False, ) try: for attempt in range(2): response = await client.request( "GET", f"{self.base_url}/api/v1/knowledge-bases/{quote(knowledge_base_id, safe='')}/files", params={"file_path": file_path}, headers=await self._headers(force_refresh=attempt > 0, accept="*/*"), follow_redirects=True, ) if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0: break except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora Wiki image request timed out", status_code=504) from exc except httpx.HTTPError as exc: raise WeKnoraError("WeKnora Wiki image is unavailable", status_code=502) from exc finally: if owns_client: await client.aclose() if response.status_code == 404: raise WeKnoraError("WeKnora Wiki image was not found", status_code=404) if response.status_code in {401, 403}: if self._has_admin_credentials: raise WeKnoraError("WeKnora rejected DeerFlow's authenticated Wiki image request", status_code=502) raise WeKnoraError( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, ) if response.status_code >= 400: raise WeKnoraError(f"WeKnora returned HTTP {response.status_code}", status_code=502) media_type = response.headers.get("content-type", "application/octet-stream").split(";", 1)[0].strip() return response.content, media_type or "application/octet-stream" async def reprocess_document(self, knowledge_id: str) -> dict[str, Any]: value = _data(await self._request("POST", f"/api/v1/knowledge/{quote(knowledge_id, safe='')}/reparse")) return dict(value) if isinstance(value, dict) else {} async def list_chunks(self, knowledge_id: str, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: payload = await self._request( "GET", f"/api/v1/chunks/{quote(knowledge_id, safe='')}", params={"page": page, "page_size": page_size}, ) rows = _as_list(_data(payload)) total = payload.get("total", len(rows)) if isinstance(payload, dict) else len(rows) return {"items": rows, "total": int(total or 0), "page": page, "page_size": page_size} async def get_wiki_graph(self, knowledge_base_id: str, *, limit: int = 200) -> dict[str, Any]: value = _data( await self._request( "GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/graph", params={"mode": "overview", "limit": max(1, min(limit, 10000))}, ) ) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid wiki graph") return dict(value) async def get_wiki_stats(self, knowledge_base_id: str) -> dict[str, Any]: value = _data(await self._request("GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/stats")) if not isinstance(value, dict): raise WeKnoraError("Invalid Wiki generation status") return value async def create_wiki_folder(self, knowledge_base_id: str, *, name: str, parent_id: str = "") -> dict[str, Any]: value = _data(await self._request("POST", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/folders", json={"name": name, "parent_id": parent_id})) if not isinstance(value, dict) or not value.get("id"): raise WeKnoraError("Invalid Wiki folder response") return value async def rebuild_wiki_links(self, knowledge_base_id: str) -> None: await self._request("POST", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/rebuild-links") async def list_wiki_pages( self, knowledge_base_id: str, *, page: int = 1, page_size: int = 50, query: str = "", ) -> dict[str, Any]: payload = await self._request( "GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages", params={"page": page, "page_size": page_size, "query": query or None}, ) value = _data(payload) source = value if isinstance(value, dict) else payload if isinstance(payload, dict) else {} rows = _as_list(source.get("pages") if isinstance(source, dict) else value) return { "pages": rows, "total": int(source.get("total") or len(rows)), "page": int(source.get("page") or page), "page_size": int(source.get("page_size") or page_size), "total_pages": int(source.get("total_pages") or max(1, (int(source.get("total") or len(rows)) + page_size - 1) // page_size)), } async def get_wiki_page(self, knowledge_base_id: str, slug: str) -> dict[str, Any]: safe_slug = "/".join(quote(part, safe="") for part in slug.split("/")) value = _data( await self._request( "GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}", ) ) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid Wiki page") return dict(value) async def get_wiki_index( self, knowledge_base_id: str, *, types: Sequence[str], limit: int = 50, cursor: str = "", ) -> dict[str, Any]: params: dict[str, Any] = { "types": ",".join(value for value in types if value), "limit": max(1, min(limit, 500)), } if cursor: params["cursor"] = cursor value = _data( await self._request( "GET", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/index", params=params, ) ) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid Wiki index") return dict(value) async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: value = _data( await self._request( "POST", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages", json=dict(payload), ) ) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid Wiki page") return dict(value) async def update_wiki_page(self, knowledge_base_id: str, slug: str, changes: Mapping[str, Any]) -> dict[str, Any]: safe_slug = "/".join(quote(part, safe="") for part in slug.split("/")) value = _data( await self._request( "PUT", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}", json=dict(changes), ) ) return dict(value) if isinstance(value, dict) else {} async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None: safe_slug = "/".join(quote(part, safe="") for part in slug.split("/")) await self._request( "DELETE", f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}", ) async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]: ids = [str(value) for value in knowledge_base_ids if str(value)] if not ids: return [] rows = _as_list( _data( await self._request( "POST", "/api/v1/knowledge-search", json={"query": query, "knowledge_base_ids": ids}, ) ) ) normalized: list[dict[str, Any]] = [] for row in rows: normalized.append( { "chunk_id": str(row.get("id") or row.get("chunk_id") or ""), "content": str(row.get("content") or ""), "knowledge_id": str(row.get("knowledge_id") or ""), "knowledge_base_id": str(row.get("knowledge_base_id") or ""), "title": str(row.get("knowledge_title") or row.get("title") or row.get("knowledge_filename") or ""), "filename": str(row.get("knowledge_filename") or ""), "score": float(row.get("score") or 0), "chunk_index": int(row.get("chunk_index") or 0), "metadata": row.get("metadata") if isinstance(row.get("metadata"), dict) else {}, } ) return normalized async def get_chunk(self, chunk_id: str) -> dict[str, Any]: value = _data(await self._request("GET", f"/api/v1/chunks/by-id/{quote(chunk_id, safe='')}")) if not isinstance(value, dict): raise WeKnoraError("WeKnora returned an invalid chunk") return dict(value) async def get_chunk_context(self, chunk_id: str, *, expected_knowledge_base_id: str, radius: int = 2) -> list[dict[str, Any]]: center = await self.get_chunk(chunk_id) if str(center.get("knowledge_base_id") or "") != expected_knowledge_base_id: raise WeKnoraError("Chunk does not belong to the authorized knowledge base", status_code=404) before: list[dict[str, Any]] = [] cursor = center for _ in range(max(0, min(radius, 4))): previous_id = str(cursor.get("pre_chunk_id") or "") if not previous_id: break previous = await self.get_chunk(previous_id) if str(previous.get("knowledge_base_id") or "") != expected_knowledge_base_id: break before.append(previous) cursor = previous after: list[dict[str, Any]] = [] cursor = center for _ in range(max(0, min(radius, 4))): next_id = str(cursor.get("next_chunk_id") or "") if not next_id: break following = await self.get_chunk(next_id) if str(following.get("knowledge_base_id") or "") != expected_knowledge_base_id: break after.append(following) cursor = following return [*reversed(before), center, *after]