deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py
2026-09-07 18:24:55 +08:00

675 lines
29 KiB
Python

"""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]