deeptutor-knowledge 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""On-disk manifest for a PageIndex-backed knowledge base.
|
|
2
|
+
|
|
3
|
+
PageIndex has no embeddings, so there is nothing to vectorise locally. The only
|
|
4
|
+
local state is a lightweight manifest mapping each ingested file to its hosted
|
|
5
|
+
``doc_id``. It is written into the KB's flat ``version-N`` directory (reusing
|
|
6
|
+
``index_versioning`` with a ``None`` signature) so the Index-versions UI and the
|
|
7
|
+
"is this KB initialised?" checks see a ready version just like LlamaIndex KBs —
|
|
8
|
+
only the file contents differ (a doc-id map instead of a vector store).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from deeptutor.services.file_io import atomic_write_json
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
MANIFEST_FILENAME = "pageindex_docs.json"
|
|
24
|
+
META_FILENAME = "meta.json"
|
|
25
|
+
SDK_STORAGE_DIRNAME = "pageindex"
|
|
26
|
+
CLOUD_PROVIDER = "pageindex"
|
|
27
|
+
OSS_PROVIDER = "pageindex-oss"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _empty_manifest(provider: str = CLOUD_PROVIDER) -> dict[str, Any]:
|
|
31
|
+
return {"provider": provider, "docs": {}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def manifest_path(storage_dir: Path) -> Path:
|
|
35
|
+
return Path(storage_dir) / MANIFEST_FILENAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_manifest(
|
|
39
|
+
storage_dir: Path | None,
|
|
40
|
+
*,
|
|
41
|
+
provider: str = CLOUD_PROVIDER,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
if storage_dir is None:
|
|
44
|
+
return _empty_manifest(provider)
|
|
45
|
+
path = manifest_path(storage_dir)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return _empty_manifest(provider)
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning("Failed to read PageIndex manifest %s: %s", path, exc)
|
|
52
|
+
return _empty_manifest(provider)
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
return _empty_manifest(provider)
|
|
55
|
+
data.setdefault("provider", provider)
|
|
56
|
+
if not isinstance(data.get("docs"), dict):
|
|
57
|
+
data["docs"] = {}
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_manifest(storage_dir: Path, manifest: dict[str, Any]) -> None:
|
|
62
|
+
atomic_write_json(manifest_path(storage_dir), manifest)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_meta(storage_dir: Path, *, provider: str = CLOUD_PROVIDER) -> None:
|
|
66
|
+
"""Write a flat-layout ``meta.json`` so the version is listed as ready.
|
|
67
|
+
|
|
68
|
+
Mirrors ``index_versioning.write_version_meta`` but carries a synthetic
|
|
69
|
+
``pageindex`` signature instead of an embedding hash.
|
|
70
|
+
"""
|
|
71
|
+
target = Path(storage_dir)
|
|
72
|
+
payload = {
|
|
73
|
+
"version": target.name,
|
|
74
|
+
"signature": provider,
|
|
75
|
+
"provider": provider,
|
|
76
|
+
"layout": "flat",
|
|
77
|
+
"created_at": datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + "Z",
|
|
78
|
+
}
|
|
79
|
+
atomic_write_json(target / META_FILENAME, payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def doc_entries(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
docs = manifest.get("docs")
|
|
84
|
+
return docs if isinstance(docs, dict) else {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def doc_ids(manifest: dict[str, Any]) -> list[str]:
|
|
88
|
+
return [
|
|
89
|
+
str(entry["doc_id"])
|
|
90
|
+
for entry in doc_entries(manifest).values()
|
|
91
|
+
if isinstance(entry, dict) and entry.get("doc_id")
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def upsert_doc(
|
|
96
|
+
manifest: dict[str, Any],
|
|
97
|
+
file_name: str,
|
|
98
|
+
doc_id: str,
|
|
99
|
+
*,
|
|
100
|
+
size: int | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
docs = manifest.setdefault("docs", {})
|
|
103
|
+
docs[file_name] = {
|
|
104
|
+
"doc_id": doc_id,
|
|
105
|
+
"size": size,
|
|
106
|
+
"submitted_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def remove_doc(manifest: dict[str, Any], file_name: str) -> dict[str, Any] | None:
|
|
111
|
+
entry = doc_entries(manifest).pop(file_name, None)
|
|
112
|
+
return entry if isinstance(entry, dict) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sdk_storage_path(storage_dir: Path) -> Path:
|
|
116
|
+
return Path(storage_dir) / SDK_STORAGE_DIRNAME
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"MANIFEST_FILENAME",
|
|
121
|
+
"CLOUD_PROVIDER",
|
|
122
|
+
"OSS_PROVIDER",
|
|
123
|
+
"SDK_STORAGE_DIRNAME",
|
|
124
|
+
"manifest_path",
|
|
125
|
+
"read_manifest",
|
|
126
|
+
"write_manifest",
|
|
127
|
+
"write_meta",
|
|
128
|
+
"doc_entries",
|
|
129
|
+
"doc_ids",
|
|
130
|
+
"upsert_doc",
|
|
131
|
+
"remove_doc",
|
|
132
|
+
"sdk_storage_path",
|
|
133
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Turn-scoped DeepTutor wrappers for PageIndex Cloud and OSS SDK tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolLookup, ToolResult
|
|
12
|
+
from deeptutor.runtime.registry.scoped_registry import ScopedToolRegistry
|
|
13
|
+
|
|
14
|
+
from .pipeline import PageIndexPipeline
|
|
15
|
+
from .storage import CLOUD_PROVIDER, OSS_PROVIDER
|
|
16
|
+
|
|
17
|
+
TOOL_PREFIXES = {
|
|
18
|
+
CLOUD_PROVIDER: "pageindex_cloud_",
|
|
19
|
+
OSS_PROVIDER: "pageindex_oss_",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pageindex_sources_from_text(
|
|
24
|
+
text: str,
|
|
25
|
+
*,
|
|
26
|
+
provider: str,
|
|
27
|
+
kb_name: str = "",
|
|
28
|
+
doc_ids: dict[str, str] | None = None,
|
|
29
|
+
) -> list[dict[str, Any]]:
|
|
30
|
+
"""Normalize real PageIndex page reads into DeepTutor source rows."""
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(text)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return []
|
|
35
|
+
if not isinstance(payload, dict) or payload.get("success") is False or payload.get("errorCode"):
|
|
36
|
+
return []
|
|
37
|
+
document_name = str(payload.get("doc_name") or "").strip()
|
|
38
|
+
content = payload.get("content")
|
|
39
|
+
if not document_name or not isinstance(content, list):
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
rows: list[dict[str, Any]] = []
|
|
43
|
+
seen: set[tuple[str, str]] = set()
|
|
44
|
+
for item in content:
|
|
45
|
+
if not isinstance(item, dict) or item.get("page") in (None, ""):
|
|
46
|
+
continue
|
|
47
|
+
page = item["page"]
|
|
48
|
+
block_id = str(item.get("block_id") or "").strip()
|
|
49
|
+
key = (str(page), block_id)
|
|
50
|
+
if key in seen:
|
|
51
|
+
continue
|
|
52
|
+
seen.add(key)
|
|
53
|
+
row: dict[str, Any] = {
|
|
54
|
+
"type": "pageindex",
|
|
55
|
+
"provider": provider,
|
|
56
|
+
"document_name": document_name,
|
|
57
|
+
"source": document_name,
|
|
58
|
+
"page": page,
|
|
59
|
+
}
|
|
60
|
+
if kb_name:
|
|
61
|
+
row["kb_name"] = kb_name
|
|
62
|
+
if doc_ids and document_name in doc_ids:
|
|
63
|
+
row["doc_id"] = doc_ids[document_name]
|
|
64
|
+
if block_id:
|
|
65
|
+
row["block_id"] = block_id
|
|
66
|
+
rows.append(row)
|
|
67
|
+
return rows
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PageIndexSDKTool(BaseTool):
|
|
71
|
+
deferred = True
|
|
72
|
+
provider_kind = "pageindex"
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
sdk_tool: Any,
|
|
77
|
+
*,
|
|
78
|
+
provider: str,
|
|
79
|
+
kb_name: str,
|
|
80
|
+
doc_ids: dict[str, str],
|
|
81
|
+
) -> None:
|
|
82
|
+
self._sdk_tool = sdk_tool
|
|
83
|
+
self.provider_id = provider
|
|
84
|
+
self._kb_name = kb_name
|
|
85
|
+
self._doc_ids = doc_ids
|
|
86
|
+
|
|
87
|
+
def get_definition(self) -> ToolDefinition:
|
|
88
|
+
label = "Cloud" if self.provider_id == CLOUD_PROVIDER else "OSS"
|
|
89
|
+
return ToolDefinition(
|
|
90
|
+
name=f"{TOOL_PREFIXES[self.provider_id]}{self._sdk_tool.name}",
|
|
91
|
+
description=f"[PageIndex {label}: {self._kb_name}] {self._sdk_tool.description}",
|
|
92
|
+
raw_parameters=dict(self._sdk_tool.params_json_schema),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
96
|
+
kwargs.pop("event_sink", None)
|
|
97
|
+
text = await self._sdk_tool.on_invoke_tool(
|
|
98
|
+
None,
|
|
99
|
+
json.dumps(kwargs, ensure_ascii=False),
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(text)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
payload = {}
|
|
105
|
+
success = not (
|
|
106
|
+
isinstance(payload, dict)
|
|
107
|
+
and (payload.get("success") is False or payload.get("errorCode"))
|
|
108
|
+
)
|
|
109
|
+
sources = pageindex_sources_from_text(
|
|
110
|
+
str(text),
|
|
111
|
+
provider=self.provider_id,
|
|
112
|
+
kb_name=self._kb_name,
|
|
113
|
+
doc_ids=self._doc_ids,
|
|
114
|
+
)
|
|
115
|
+
return ToolResult(
|
|
116
|
+
content=str(text),
|
|
117
|
+
sources=sources,
|
|
118
|
+
metadata={
|
|
119
|
+
"pageindex_provider": self.provider_id,
|
|
120
|
+
"kb_name": self._kb_name,
|
|
121
|
+
"sources": sources,
|
|
122
|
+
},
|
|
123
|
+
success=success,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class PageIndexSDKToolBundle:
|
|
129
|
+
provider: str
|
|
130
|
+
tools: tuple[PageIndexSDKTool, ...]
|
|
131
|
+
instructions: str
|
|
132
|
+
documents: dict[str, str]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class PageIndexToolContext:
|
|
137
|
+
provider: str
|
|
138
|
+
registry: ToolLookup
|
|
139
|
+
tools: tuple[BaseTool, ...]
|
|
140
|
+
instructions: str
|
|
141
|
+
documents: dict[str, str]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@lru_cache(maxsize=1)
|
|
145
|
+
def _cloud_read_tools(client: Any) -> tuple[Any, ...]:
|
|
146
|
+
"""Cache live Cloud schemas on the SDK client that owns their MCP session."""
|
|
147
|
+
return tuple(client.as_openai_tools(include_management=False))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def build_sdk_tool_bundle(
|
|
151
|
+
kb_name: str,
|
|
152
|
+
kb_base_dir: str,
|
|
153
|
+
*,
|
|
154
|
+
provider: str,
|
|
155
|
+
) -> PageIndexSDKToolBundle:
|
|
156
|
+
"""Build one KB's read-only SDK tools without exposing their transport."""
|
|
157
|
+
|
|
158
|
+
def build() -> PageIndexSDKToolBundle:
|
|
159
|
+
pipeline = PageIndexPipeline(kb_base_dir=kb_base_dir, provider=provider)
|
|
160
|
+
documents = pipeline.document_map(kb_name)
|
|
161
|
+
client = pipeline.sdk_client_for_read(kb_name)
|
|
162
|
+
sdk_tools = (
|
|
163
|
+
client.as_openai_tools(include_management=False)
|
|
164
|
+
if provider == OSS_PROVIDER
|
|
165
|
+
else _cloud_read_tools(client)
|
|
166
|
+
)
|
|
167
|
+
instructions = client.agent_instructions()
|
|
168
|
+
return PageIndexSDKToolBundle(
|
|
169
|
+
provider=provider,
|
|
170
|
+
tools=tuple(
|
|
171
|
+
PageIndexSDKTool(
|
|
172
|
+
tool,
|
|
173
|
+
provider=provider,
|
|
174
|
+
kb_name=kb_name,
|
|
175
|
+
doc_ids=documents,
|
|
176
|
+
)
|
|
177
|
+
for tool in sdk_tools
|
|
178
|
+
),
|
|
179
|
+
instructions=str(instructions or ""),
|
|
180
|
+
documents=documents,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return await asyncio.to_thread(build)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def build_pageindex_tool_context(
|
|
187
|
+
kb_name: str | None,
|
|
188
|
+
*,
|
|
189
|
+
base_registry: ToolLookup,
|
|
190
|
+
) -> PageIndexToolContext | None:
|
|
191
|
+
"""Resolve one PageIndex KB into tools for an existing workflow loop."""
|
|
192
|
+
if not kb_name:
|
|
193
|
+
return None
|
|
194
|
+
from deeptutor.multi_user.knowledge_access import resolve_kb
|
|
195
|
+
from deeptutor.services.rag.provider_binding import resolve_bound_provider
|
|
196
|
+
|
|
197
|
+
resource = resolve_kb(kb_name, require_write=False)
|
|
198
|
+
base_dir = str(resource.base_dir)
|
|
199
|
+
provider = resolve_bound_provider(base_dir, resource.name)
|
|
200
|
+
if provider in {CLOUD_PROVIDER, OSS_PROVIDER}:
|
|
201
|
+
bundle = await build_sdk_tool_bundle(resource.name, base_dir, provider=provider)
|
|
202
|
+
registry = ScopedToolRegistry(base=base_registry, overlay=bundle.tools)
|
|
203
|
+
return PageIndexToolContext(
|
|
204
|
+
provider=provider,
|
|
205
|
+
registry=registry,
|
|
206
|
+
tools=bundle.tools,
|
|
207
|
+
instructions=bundle.instructions,
|
|
208
|
+
documents=bundle.documents,
|
|
209
|
+
)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = [
|
|
214
|
+
"PageIndexSDKTool",
|
|
215
|
+
"PageIndexSDKToolBundle",
|
|
216
|
+
"PageIndexToolContext",
|
|
217
|
+
"TOOL_PREFIXES",
|
|
218
|
+
"build_sdk_tool_bundle",
|
|
219
|
+
"build_pageindex_tool_context",
|
|
220
|
+
"pageindex_sources_from_text",
|
|
221
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Retrieval-only integration with an external Tencent WeKnora server."""
|
|
2
|
+
|
|
3
|
+
from .client import WeKnoraAPIError, WeKnoraClient
|
|
4
|
+
from .config import WeKnoraConfig, config_from_entry
|
|
5
|
+
from .pipeline import WeKnoraPipeline
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"WeKnoraAPIError",
|
|
9
|
+
"WeKnoraClient",
|
|
10
|
+
"WeKnoraConfig",
|
|
11
|
+
"WeKnoraPipeline",
|
|
12
|
+
"config_from_entry",
|
|
13
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Thin HTTP client for Tencent WeKnora's documented REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import WeKnoraConfig
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# An external service is not allowed to make the DeepTutor process buffer an
|
|
16
|
+
# unbounded body. Four MiB is ample for a page of KB summaries or retrieval
|
|
17
|
+
# chunks while keeping a compromised/misconfigured server cheap to reject.
|
|
18
|
+
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WeKnoraAPIError(RuntimeError):
|
|
22
|
+
"""Raised when WeKnora returns an error or unexpected payload."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WeKnoraClient:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config: WeKnoraConfig,
|
|
29
|
+
*,
|
|
30
|
+
timeout: float = 60.0,
|
|
31
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._config = config
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._transport = transport
|
|
36
|
+
|
|
37
|
+
def _open(self) -> httpx.AsyncClient:
|
|
38
|
+
return httpx.AsyncClient(
|
|
39
|
+
base_url=self._config.base_url,
|
|
40
|
+
headers={
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
"X-API-Key": self._config.api_key,
|
|
43
|
+
},
|
|
44
|
+
timeout=self._timeout,
|
|
45
|
+
transport=self._transport,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
49
|
+
# Re-resolve on every request. Checking only when the connection is
|
|
50
|
+
# saved would leave retrieval vulnerable to DNS rebinding later.
|
|
51
|
+
from deeptutor.services.mcp.network import validate_mcp_url_async
|
|
52
|
+
|
|
53
|
+
ok, error = await validate_mcp_url_async(self._config.base_url)
|
|
54
|
+
if not ok:
|
|
55
|
+
raise WeKnoraAPIError(f"Unsafe WeKnora server URL: {error}")
|
|
56
|
+
|
|
57
|
+
async with self._open() as client:
|
|
58
|
+
async with client.stream(method, path, **kwargs) as resp:
|
|
59
|
+
declared = resp.headers.get("content-length")
|
|
60
|
+
if declared:
|
|
61
|
+
try:
|
|
62
|
+
if int(declared) > MAX_RESPONSE_BYTES:
|
|
63
|
+
raise WeKnoraAPIError("WeKnora response exceeds the 4 MiB limit.")
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
body = bytearray()
|
|
67
|
+
async for chunk in resp.aiter_bytes():
|
|
68
|
+
body.extend(chunk)
|
|
69
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
70
|
+
raise WeKnoraAPIError("WeKnora response exceeds the 4 MiB limit.")
|
|
71
|
+
|
|
72
|
+
if resp.status_code >= 400:
|
|
73
|
+
preview = bytes(body[:300]).decode("utf-8", errors="replace")
|
|
74
|
+
raise WeKnoraAPIError(f"WeKnora returned {resp.status_code}: {preview}")
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(body)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise WeKnoraAPIError(f"WeKnora returned a non-JSON response: {exc}") from exc
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise WeKnoraAPIError(f"WeKnora returned unexpected payload: {data!r}")
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
async def list_knowledge_bases(self) -> list[dict[str, Any]]:
|
|
84
|
+
data = (await self._request_json("GET", "/api/v1/knowledge-bases")).get("data")
|
|
85
|
+
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
|
|
86
|
+
raise WeKnoraAPIError("WeKnora returned an unexpected knowledge-base list.")
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
async def search(self, query: str) -> list[dict[str, Any]]:
|
|
90
|
+
data = (
|
|
91
|
+
await self._request_json(
|
|
92
|
+
"POST",
|
|
93
|
+
"/api/v1/knowledge-search",
|
|
94
|
+
params={"resource_urls": "handle"},
|
|
95
|
+
json={
|
|
96
|
+
"query": query,
|
|
97
|
+
"knowledge_base_id": self._config.knowledge_base_id,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
).get("data")
|
|
101
|
+
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
|
|
102
|
+
raise WeKnoraAPIError("WeKnora returned unexpected search results.")
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = ["MAX_RESPONSE_BYTES", "WeKnoraAPIError", "WeKnoraClient"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Per-KB connection configuration for Tencent WeKnora."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WeKnoraNotConfiguredError(RuntimeError):
|
|
11
|
+
"""Raised when a KB lacks the fields needed to reach WeKnora."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WeKnoraConfig:
|
|
16
|
+
base_url: str
|
|
17
|
+
api_key: str
|
|
18
|
+
knowledge_base_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_base_url(url: str | None) -> str:
|
|
22
|
+
normalized = (url or "").strip().rstrip("/")
|
|
23
|
+
if not normalized:
|
|
24
|
+
return ""
|
|
25
|
+
parsed = urlparse(normalized)
|
|
26
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
27
|
+
return ""
|
|
28
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
29
|
+
return ""
|
|
30
|
+
return normalized
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def config_from_entry(entry: dict[str, Any]) -> WeKnoraConfig:
|
|
34
|
+
base_url = normalize_base_url(entry.get("server_url"))
|
|
35
|
+
api_key = str(entry.get("api_key") or "").strip()
|
|
36
|
+
knowledge_base_id = str(entry.get("knowledge_base_id") or "").strip()
|
|
37
|
+
if not base_url or not knowledge_base_id:
|
|
38
|
+
raise WeKnoraNotConfiguredError(
|
|
39
|
+
"This knowledge base is not connected to WeKnora "
|
|
40
|
+
"(server URL and knowledge base ID are required)."
|
|
41
|
+
)
|
|
42
|
+
if not api_key:
|
|
43
|
+
raise WeKnoraNotConfiguredError("A WeKnora API key is required for retrieval.")
|
|
44
|
+
return WeKnoraConfig(
|
|
45
|
+
base_url=base_url,
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
knowledge_base_id=knowledge_base_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"WeKnoraConfig",
|
|
53
|
+
"WeKnoraNotConfiguredError",
|
|
54
|
+
"config_from_entry",
|
|
55
|
+
"normalize_base_url",
|
|
56
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Retrieval-only RAG pipeline backed by an external WeKnora knowledge base."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from deeptutor.runtime.home import get_runtime_data_root
|
|
9
|
+
from deeptutor.services.rag.provider_binding import load_kb_config_entry
|
|
10
|
+
|
|
11
|
+
from .config import WeKnoraNotConfiguredError, config_from_entry
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
PROVIDER = "weknora"
|
|
16
|
+
DEFAULT_KB_BASE_DIR = str(get_runtime_data_root() / "knowledge_bases")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WeKnoraPipeline:
|
|
20
|
+
def __init__(self, kb_base_dir: Optional[str] = None, *, client_factory=None, **_: Any) -> None:
|
|
21
|
+
self.kb_base_dir = kb_base_dir or DEFAULT_KB_BASE_DIR
|
|
22
|
+
self._client_factory = client_factory
|
|
23
|
+
|
|
24
|
+
def _client(self, config):
|
|
25
|
+
if self._client_factory is not None:
|
|
26
|
+
return self._client_factory(config)
|
|
27
|
+
from .client import WeKnoraClient
|
|
28
|
+
|
|
29
|
+
return WeKnoraClient(config)
|
|
30
|
+
|
|
31
|
+
async def search(self, query: str, kb_name: str, **kwargs) -> Dict[str, Any]:
|
|
32
|
+
try:
|
|
33
|
+
config = config_from_entry(load_kb_config_entry(self.kb_base_dir, kb_name))
|
|
34
|
+
except WeKnoraNotConfiguredError as exc:
|
|
35
|
+
return self._error_result(query, exc, error_type="not_configured")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
chunks = await self._client(config).search(query)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.error("WeKnora search failed for '%s': %s", kb_name, exc)
|
|
41
|
+
return self._error_result(query, exc, error_type="retrieval_error")
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"query": query,
|
|
45
|
+
"answer": "\n\n---\n\n".join(str(item.get("content") or "") for item in chunks),
|
|
46
|
+
"content": "\n\n---\n\n".join(str(item.get("content") or "") for item in chunks),
|
|
47
|
+
"sources": chunks,
|
|
48
|
+
"provider": PROVIDER,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def _error_result(self, query: str, exc: Exception, *, error_type: str) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"query": query,
|
|
54
|
+
"answer": str(exc),
|
|
55
|
+
"content": "",
|
|
56
|
+
"sources": [],
|
|
57
|
+
"provider": PROVIDER,
|
|
58
|
+
"error_type": error_type,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async def initialize(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"WeKnora knowledge bases are managed in WeKnora; DeepTutor does not "
|
|
64
|
+
"upload or index their documents."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def add_documents(self, kb_name: str, file_paths: List[str], **kwargs) -> bool:
|
|
68
|
+
return await self.initialize(kb_name, file_paths, **kwargs)
|
|
69
|
+
|
|
70
|
+
async def delete(self, kb_name: str, **kwargs) -> bool:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["WeKnoraPipeline", "PROVIDER"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Validate a WeKnora endpoint, API key, and knowledge-base binding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .client import WeKnoraClient
|
|
9
|
+
from .config import WeKnoraConfig, normalize_base_url
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WeKnoraProbe:
|
|
14
|
+
base_url: str
|
|
15
|
+
knowledge_base_id: str
|
|
16
|
+
ok: bool = False
|
|
17
|
+
reachable: bool = False
|
|
18
|
+
credentials_ok: bool = False
|
|
19
|
+
knowledge_base_found: bool = False
|
|
20
|
+
knowledge_base_name: Optional[str] = None
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return asdict(self)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def probe_weknora(
|
|
28
|
+
server_url: str,
|
|
29
|
+
api_key: str,
|
|
30
|
+
knowledge_base_id: str,
|
|
31
|
+
*,
|
|
32
|
+
client_factory=None,
|
|
33
|
+
) -> WeKnoraProbe:
|
|
34
|
+
base_url = normalize_base_url(server_url)
|
|
35
|
+
knowledge_base_id = (knowledge_base_id or "").strip()
|
|
36
|
+
api_key = (api_key or "").strip()
|
|
37
|
+
probe = WeKnoraProbe(base_url=base_url, knowledge_base_id=knowledge_base_id)
|
|
38
|
+
|
|
39
|
+
if not base_url or not knowledge_base_id or not api_key:
|
|
40
|
+
probe.error = "Server URL, API key, and knowledge base ID are required."
|
|
41
|
+
return probe
|
|
42
|
+
|
|
43
|
+
# WeKnora is administrator-configured and often lives on the deployment's
|
|
44
|
+
# private network, so loopback/LAN remain valid. Link-local, metadata and
|
|
45
|
+
# malformed targets are still forbidden, and the client repeats this check
|
|
46
|
+
# at every retrieval request to narrow DNS-rebinding exposure.
|
|
47
|
+
from deeptutor.services.mcp.network import validate_mcp_url_async
|
|
48
|
+
|
|
49
|
+
safe, error = await validate_mcp_url_async(base_url)
|
|
50
|
+
if not safe:
|
|
51
|
+
probe.error = f"Unsafe WeKnora server URL: {error}"
|
|
52
|
+
return probe
|
|
53
|
+
|
|
54
|
+
config = WeKnoraConfig(
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
api_key=api_key,
|
|
57
|
+
knowledge_base_id=knowledge_base_id,
|
|
58
|
+
)
|
|
59
|
+
client = client_factory(config) if client_factory else WeKnoraClient(config)
|
|
60
|
+
try:
|
|
61
|
+
knowledge_bases = await client.list_knowledge_bases()
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
probe.error = f"Could not validate the WeKnora connection: {exc}"
|
|
64
|
+
return probe
|
|
65
|
+
|
|
66
|
+
probe.reachable = True
|
|
67
|
+
probe.credentials_ok = True
|
|
68
|
+
matches = [item for item in knowledge_bases if str(item.get("id") or "") == knowledge_base_id]
|
|
69
|
+
if not matches:
|
|
70
|
+
probe.error = f"Knowledge base {knowledge_base_id} is not visible to this WeKnora API key."
|
|
71
|
+
return probe
|
|
72
|
+
|
|
73
|
+
probe.knowledge_base_found = True
|
|
74
|
+
name = str(matches[0].get("name") or "").strip()
|
|
75
|
+
probe.knowledge_base_name = name or None
|
|
76
|
+
probe.ok = True
|
|
77
|
+
return probe
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["WeKnoraProbe", "probe_weknora"]
|