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,128 @@
|
|
|
1
|
+
"""Validation helpers for embedding vectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
import math
|
|
7
|
+
from numbers import Real
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _context(
|
|
12
|
+
*,
|
|
13
|
+
binding: str | None,
|
|
14
|
+
model: str | None,
|
|
15
|
+
batch_index: int | None,
|
|
16
|
+
total_batches: int | None,
|
|
17
|
+
) -> str:
|
|
18
|
+
parts: list[str] = []
|
|
19
|
+
if binding:
|
|
20
|
+
parts.append(f"binding={binding}")
|
|
21
|
+
if model:
|
|
22
|
+
parts.append(f"model={model}")
|
|
23
|
+
if batch_index is not None and total_batches is not None:
|
|
24
|
+
parts.append(f"batch={batch_index}/{total_batches}")
|
|
25
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _raise_invalid_vector(message: str, *, item_index: int, context: str) -> None:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"Embedding provider returned invalid vector "
|
|
31
|
+
f"at item {item_index}{context}: {message}. "
|
|
32
|
+
"RAG requires dense numeric embeddings; check the embedding provider/model "
|
|
33
|
+
"and re-index the knowledge base after fixing it."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_embedding_batch(
|
|
38
|
+
embeddings: Any,
|
|
39
|
+
*,
|
|
40
|
+
expected_count: int,
|
|
41
|
+
binding: str | None = None,
|
|
42
|
+
model: str | None = None,
|
|
43
|
+
batch_index: int | None = None,
|
|
44
|
+
total_batches: int | None = None,
|
|
45
|
+
start_index: int = 0,
|
|
46
|
+
) -> list[list[float]]:
|
|
47
|
+
"""Return normalized float vectors or raise a clear provider error.
|
|
48
|
+
|
|
49
|
+
Provider smoke tests and RAG indexing both ultimately need a list of dense
|
|
50
|
+
numeric vectors. A single ``None`` coordinate otherwise reaches LlamaIndex's
|
|
51
|
+
similarity code and fails later as ``NoneType * float``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
context = _context(
|
|
55
|
+
binding=binding,
|
|
56
|
+
model=model,
|
|
57
|
+
batch_index=batch_index,
|
|
58
|
+
total_batches=total_batches,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
embeddings is None
|
|
63
|
+
or isinstance(embeddings, (str, bytes))
|
|
64
|
+
or not isinstance(embeddings, Sequence)
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Embedding provider returned invalid embeddings payload"
|
|
68
|
+
f"{context}: expected a list of {expected_count} vector(s), "
|
|
69
|
+
f"got {type(embeddings).__name__}."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
actual_count = len(embeddings)
|
|
73
|
+
if actual_count != expected_count:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Embedding provider returned an unexpected number of vectors"
|
|
76
|
+
f"{context}: expected {expected_count}, got {actual_count}. "
|
|
77
|
+
"This usually means the provider dropped one or more inputs; "
|
|
78
|
+
"RAG indexing/search cannot safely continue."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
normalized: list[list[float]] = []
|
|
82
|
+
for local_index, vector in enumerate(embeddings):
|
|
83
|
+
item_index = start_index + local_index
|
|
84
|
+
if vector is None:
|
|
85
|
+
_raise_invalid_vector("vector is null", item_index=item_index, context=context)
|
|
86
|
+
if isinstance(vector, (str, bytes)) or not isinstance(vector, Sequence):
|
|
87
|
+
_raise_invalid_vector(
|
|
88
|
+
f"expected a numeric sequence, got {type(vector).__name__}",
|
|
89
|
+
item_index=item_index,
|
|
90
|
+
context=context,
|
|
91
|
+
)
|
|
92
|
+
if len(vector) == 0:
|
|
93
|
+
_raise_invalid_vector("vector is empty", item_index=item_index, context=context)
|
|
94
|
+
|
|
95
|
+
normalized_vector: list[float] = []
|
|
96
|
+
for dim_index, value in enumerate(vector):
|
|
97
|
+
if value is None:
|
|
98
|
+
_raise_invalid_vector(
|
|
99
|
+
f"dimension {dim_index} is null",
|
|
100
|
+
item_index=item_index,
|
|
101
|
+
context=context,
|
|
102
|
+
)
|
|
103
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
104
|
+
_raise_invalid_vector(
|
|
105
|
+
f"dimension {dim_index} is {type(value).__name__}, not a number",
|
|
106
|
+
item_index=item_index,
|
|
107
|
+
context=context,
|
|
108
|
+
)
|
|
109
|
+
numeric = float(value)
|
|
110
|
+
if not math.isfinite(numeric):
|
|
111
|
+
_raise_invalid_vector(
|
|
112
|
+
f"dimension {dim_index} is not finite",
|
|
113
|
+
item_index=item_index,
|
|
114
|
+
context=context,
|
|
115
|
+
)
|
|
116
|
+
normalized_vector.append(numeric)
|
|
117
|
+
|
|
118
|
+
normalized.append(normalized_vector)
|
|
119
|
+
|
|
120
|
+
dims = {len(vector) for vector in normalized}
|
|
121
|
+
if len(dims) > 1:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"Embedding provider returned inconsistent vector dimensions"
|
|
124
|
+
f"{context}: dimensions={sorted(dims)}. "
|
|
125
|
+
"Use a single embedding model/dimension and re-index the knowledge base."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return normalized
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Minimal async GitHub REST API client used by the KB source-sync engine.
|
|
2
|
+
|
|
3
|
+
Self-contained, depends only on ``httpx`` (already a project dependency).
|
|
4
|
+
No ``gh`` or ``git`` CLI required. A ``GITHUB_TOKEN`` environment variable
|
|
5
|
+
is optional but recommended — it lifts the unauthenticated rate limit from
|
|
6
|
+
60 to 5 000 requests/hour and enables private-repo access.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
21
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
22
|
+
MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MB cap per individual file download
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TreeEntry:
|
|
27
|
+
"""One entry from a GitHub tree listing."""
|
|
28
|
+
|
|
29
|
+
path: str
|
|
30
|
+
sha: str
|
|
31
|
+
type: str # "blob" | "tree"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class FileChange:
|
|
36
|
+
"""One file changed between two commits."""
|
|
37
|
+
|
|
38
|
+
path: str
|
|
39
|
+
status: str # "added" | "removed" | "modified" | "renamed" | ...
|
|
40
|
+
sha: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class GitHubAPIError(Exception):
|
|
45
|
+
"""Raised when the GitHub API returns a non-2xx response."""
|
|
46
|
+
|
|
47
|
+
status_code: int
|
|
48
|
+
message: str
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return f"GitHub API {self.status_code}: {self.message}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _token() -> str | None:
|
|
55
|
+
return os.environ.get("GITHUB_TOKEN") or None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _headers() -> dict[str, str]:
|
|
59
|
+
h: dict[str, str] = {
|
|
60
|
+
"Accept": "application/vnd.github+json",
|
|
61
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
62
|
+
"User-Agent": "DeepTutor/1.0 (+https://hkuds.dev/deeptutor)",
|
|
63
|
+
}
|
|
64
|
+
token = _token()
|
|
65
|
+
if token:
|
|
66
|
+
h["Authorization"] = f"Bearer {token}"
|
|
67
|
+
return h
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitHubClient:
|
|
71
|
+
"""Thin async wrapper around the handful of REST endpoints we need."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
77
|
+
client_factory: Any = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._timeout_s = timeout_s
|
|
80
|
+
self._client_factory = client_factory
|
|
81
|
+
|
|
82
|
+
async def _request_json(self, method: str, url: str, **kw: Any) -> Any:
|
|
83
|
+
factory = self._client_factory or (lambda: httpx.AsyncClient(timeout=self._timeout_s))
|
|
84
|
+
async with factory() as client:
|
|
85
|
+
resp = await client.request(method, url, headers=_headers(), **kw)
|
|
86
|
+
if resp.status_code >= 400:
|
|
87
|
+
try:
|
|
88
|
+
body = resp.json()
|
|
89
|
+
msg = body.get("message", resp.text[:500])
|
|
90
|
+
except Exception:
|
|
91
|
+
msg = resp.text[:500]
|
|
92
|
+
raise GitHubAPIError(resp.status_code, str(msg))
|
|
93
|
+
return resp.json()
|
|
94
|
+
|
|
95
|
+
async def _request_bytes(self, url: str, **kw: Any) -> bytes:
|
|
96
|
+
factory = self._client_factory or (lambda: httpx.AsyncClient(timeout=self._timeout_s))
|
|
97
|
+
async with factory() as client:
|
|
98
|
+
resp = await client.get(url, headers=_headers(), **kw)
|
|
99
|
+
if resp.status_code >= 400:
|
|
100
|
+
raise GitHubAPIError(resp.status_code, resp.text[:500])
|
|
101
|
+
return resp.content
|
|
102
|
+
|
|
103
|
+
async def get_default_branch(self, repo: str) -> str:
|
|
104
|
+
data = await self._request_json("GET", f"{GITHUB_API_BASE}/repos/{repo}")
|
|
105
|
+
return data.get("default_branch", "main")
|
|
106
|
+
|
|
107
|
+
async def get_latest_commit_sha(self, repo: str, branch: str) -> str:
|
|
108
|
+
data = await self._request_json("GET", f"{GITHUB_API_BASE}/repos/{repo}/commits/{branch}")
|
|
109
|
+
return data["sha"]
|
|
110
|
+
|
|
111
|
+
async def get_tree(
|
|
112
|
+
self,
|
|
113
|
+
repo: str,
|
|
114
|
+
branch: str,
|
|
115
|
+
*,
|
|
116
|
+
path_prefix: str = "",
|
|
117
|
+
glob: str = "*.md",
|
|
118
|
+
) -> list[TreeEntry]:
|
|
119
|
+
data = await self._request_json(
|
|
120
|
+
"GET",
|
|
121
|
+
f"{GITHUB_API_BASE}/repos/{repo}/git/trees/{branch}",
|
|
122
|
+
params={"recursive": "1"},
|
|
123
|
+
)
|
|
124
|
+
entries: list[TreeEntry] = []
|
|
125
|
+
prefix = path_prefix.strip("/")
|
|
126
|
+
for item in data.get("tree", []):
|
|
127
|
+
if item.get("type") != "blob":
|
|
128
|
+
continue
|
|
129
|
+
p = item.get("path", "")
|
|
130
|
+
if prefix and not p.startswith(prefix + "/") and p != prefix:
|
|
131
|
+
continue
|
|
132
|
+
if not _match_glob(p, glob):
|
|
133
|
+
continue
|
|
134
|
+
entries.append(TreeEntry(path=p, sha=item.get("sha", ""), type="blob"))
|
|
135
|
+
return entries
|
|
136
|
+
|
|
137
|
+
async def compare_commits(self, repo: str, base: str, head: str) -> list[FileChange]:
|
|
138
|
+
data = await self._request_json(
|
|
139
|
+
"GET",
|
|
140
|
+
f"{GITHUB_API_BASE}/repos/{repo}/compare/{base}...{head}",
|
|
141
|
+
)
|
|
142
|
+
changes: list[FileChange] = []
|
|
143
|
+
for item in data.get("files", []):
|
|
144
|
+
changes.append(
|
|
145
|
+
FileChange(
|
|
146
|
+
path=item.get("filename", ""),
|
|
147
|
+
status=item.get("status", "modified"),
|
|
148
|
+
sha=item.get("sha", ""),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return changes
|
|
152
|
+
|
|
153
|
+
async def download_file(self, repo: str, path: str, ref: str) -> bytes:
|
|
154
|
+
url = f"https://raw.githubusercontent.com/{repo}/{ref}/{path}"
|
|
155
|
+
content = await self._request_bytes(url)
|
|
156
|
+
if len(content) > MAX_FILE_BYTES:
|
|
157
|
+
content = content[:MAX_FILE_BYTES]
|
|
158
|
+
return content
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _match_glob(path: str, glob: str) -> bool:
|
|
162
|
+
import fnmatch
|
|
163
|
+
|
|
164
|
+
return fnmatch.fnmatch(path, glob) or fnmatch.fnmatch(path.rsplit("/", 1)[-1], glob)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Core sync logic: pull Markdown from a GitHub repo into a KB's raw/ dir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
from deeptutor.knowledge.add_documents import DEFAULT_BASE_DIR
|
|
13
|
+
from deeptutor.services.github_source.client import (
|
|
14
|
+
GitHubAPIError,
|
|
15
|
+
GitHubClient,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
SYNC_INTERVAL_HOURS = 24
|
|
21
|
+
MARKDOWN_EXTENSIONS = (".md", ".markdown")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SyncResult:
|
|
26
|
+
ok: bool
|
|
27
|
+
skipped: bool = False
|
|
28
|
+
files_added: int = 0
|
|
29
|
+
files_updated: int = 0
|
|
30
|
+
files_removed: int = 0
|
|
31
|
+
error: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _utcnow_iso() -> str:
|
|
35
|
+
return datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def redact_sync_error(exc: Exception) -> str:
|
|
39
|
+
"""Return a bounded sync error without credential-shaped values."""
|
|
40
|
+
message = str(exc).strip() or type(exc).__name__
|
|
41
|
+
|
|
42
|
+
def redact_url(match: re.Match[str]) -> str:
|
|
43
|
+
try:
|
|
44
|
+
parsed = urlsplit(match.group(0))
|
|
45
|
+
host = parsed.hostname or ""
|
|
46
|
+
if parsed.port is not None:
|
|
47
|
+
host = f"{host}:{parsed.port}"
|
|
48
|
+
return urlunsplit((parsed.scheme, host, parsed.path, "", ""))
|
|
49
|
+
except ValueError:
|
|
50
|
+
# Error strings are untrusted input too. A malformed bracket or port
|
|
51
|
+
# must not make credential redaction mask the original sync failure.
|
|
52
|
+
return "[redacted-url]"
|
|
53
|
+
|
|
54
|
+
message = re.sub(r"https?://[^\s,;]+", redact_url, message)
|
|
55
|
+
message = re.sub(r"\bsk-[A-Za-z0-9_-]{4,}\b", "[redacted]", message)
|
|
56
|
+
message = re.sub(
|
|
57
|
+
r"(?i)((?:api[_-]?key|authorization|token|password)\s*[:=]\s*)[^\s,;]+",
|
|
58
|
+
r"\1[redacted]",
|
|
59
|
+
message,
|
|
60
|
+
)
|
|
61
|
+
return message[:1000]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _record_sync_failure(kb_name: str, source: dict, base_dir: str, error: str) -> None:
|
|
65
|
+
try:
|
|
66
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
67
|
+
|
|
68
|
+
KnowledgeBaseManager(base_dir=base_dir).update_github_source_state(
|
|
69
|
+
kb_name=kb_name,
|
|
70
|
+
source_id=source["id"],
|
|
71
|
+
last_sync_status="error",
|
|
72
|
+
last_sync_error=error,
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.warning("Could not persist GitHub sync failure status for '%s'", kb_name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _record_sync_success(
|
|
79
|
+
kb_name: str,
|
|
80
|
+
source: dict,
|
|
81
|
+
base_dir: str,
|
|
82
|
+
*,
|
|
83
|
+
sha: str,
|
|
84
|
+
files_synced: int | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Persist "GitHub was checked and the source is current".
|
|
87
|
+
|
|
88
|
+
A same-SHA check is a successful sync that had nothing to transfer, so it
|
|
89
|
+
owes the same freshness stamp as one that did: without it the hourly
|
|
90
|
+
service read the source as stale forever and re-queried GitHub every
|
|
91
|
+
cycle, and an error left by an earlier transient failure stayed on the
|
|
92
|
+
source after a later check had confirmed it current (#1489). ``files_synced``
|
|
93
|
+
is omitted on that path so the last real transfer's count survives.
|
|
94
|
+
"""
|
|
95
|
+
from deeptutor.knowledge.manager import KnowledgeBaseManager
|
|
96
|
+
|
|
97
|
+
fields: dict = {} if files_synced is None else {"files_synced": files_synced}
|
|
98
|
+
KnowledgeBaseManager(base_dir=base_dir).update_github_source_state(
|
|
99
|
+
kb_name=kb_name,
|
|
100
|
+
source_id=source["id"],
|
|
101
|
+
last_synced_sha=sha,
|
|
102
|
+
last_synced_at=_utcnow_iso(),
|
|
103
|
+
last_sync_status="success",
|
|
104
|
+
last_sync_error=None,
|
|
105
|
+
**fields,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_markdown(path: str) -> bool:
|
|
110
|
+
return path.lower().endswith(MARKDOWN_EXTENSIONS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _raw_rel_path(github_path: str, path_prefix: str) -> str:
|
|
114
|
+
prefix = path_prefix.strip("/")
|
|
115
|
+
if prefix and github_path.startswith(prefix + "/"):
|
|
116
|
+
return github_path[len(prefix) + 1 :]
|
|
117
|
+
return github_path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _contained_dest(raw_dir: Path, rel: str) -> Path | None:
|
|
121
|
+
"""Resolve *rel* under *raw_dir*, or ``None`` if it escapes.
|
|
122
|
+
|
|
123
|
+
``rel`` comes from the GitHub API's tree/compare response, so it is remote
|
|
124
|
+
input. Two shapes would otherwise write outside the KB: a ``..`` segment,
|
|
125
|
+
and — more quietly — an absolute path, because ``Path("/kb") / "/etc/x"``
|
|
126
|
+
is ``/etc/x``, silently discarding the base. git rejects both in tree
|
|
127
|
+
entries today, but a downloader must not depend on the remote to enforce
|
|
128
|
+
where it writes.
|
|
129
|
+
"""
|
|
130
|
+
candidate = (raw_dir / rel).resolve()
|
|
131
|
+
root = raw_dir.resolve()
|
|
132
|
+
if candidate != root and root not in candidate.parents:
|
|
133
|
+
logger.warning("GitHub sync: refusing path outside the KB raw dir: %s", rel)
|
|
134
|
+
return None
|
|
135
|
+
return candidate
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _filter_markdown_changes(changes, path_prefix, glob):
|
|
139
|
+
from fnmatch import fnmatch
|
|
140
|
+
|
|
141
|
+
prefix = path_prefix.strip("/")
|
|
142
|
+
result = []
|
|
143
|
+
for ch in changes:
|
|
144
|
+
p = ch.path
|
|
145
|
+
if prefix and not p.startswith(prefix + "/") and p != prefix:
|
|
146
|
+
continue
|
|
147
|
+
if not (_is_markdown(p) and (fnmatch(p, glob) or fnmatch(p.rsplit("/", 1)[-1], glob))):
|
|
148
|
+
continue
|
|
149
|
+
result.append(ch)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _filter_markdown_entries(entries, path_prefix, glob):
|
|
154
|
+
from fnmatch import fnmatch
|
|
155
|
+
|
|
156
|
+
prefix = path_prefix.strip("/")
|
|
157
|
+
result = []
|
|
158
|
+
for e in entries:
|
|
159
|
+
p = e.path
|
|
160
|
+
if prefix and not p.startswith(prefix + "/") and p != prefix:
|
|
161
|
+
continue
|
|
162
|
+
if not (_is_markdown(p) and (fnmatch(p, glob) or fnmatch(p.rsplit("/", 1)[-1], glob))):
|
|
163
|
+
continue
|
|
164
|
+
result.append(e)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def sync_source(kb_name, source, *, base_dir=DEFAULT_BASE_DIR, client=None):
|
|
169
|
+
from deeptutor.services.rag.provider_binding import resolve_bound_provider
|
|
170
|
+
|
|
171
|
+
accepted_indexing_snapshot = None
|
|
172
|
+
if resolve_bound_provider(base_dir, kb_name) == "lightrag":
|
|
173
|
+
from deeptutor.services.rag.pipelines.lightrag.indexing_policy import (
|
|
174
|
+
bind_target,
|
|
175
|
+
resolve_write_snapshot,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
kb_dir = Path(base_dir) / kb_name
|
|
179
|
+
try:
|
|
180
|
+
accepted_indexing_snapshot = bind_target(
|
|
181
|
+
resolve_write_snapshot(kb_dir, base_dir=str(base_dir), kb_name=kb_name), kb_dir
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
error = redact_sync_error(exc)
|
|
185
|
+
_record_sync_failure(kb_name, source, base_dir, error)
|
|
186
|
+
return SyncResult(ok=False, error=error)
|
|
187
|
+
indexing_options = (
|
|
188
|
+
{"accepted_indexing_snapshot": accepted_indexing_snapshot}
|
|
189
|
+
if accepted_indexing_snapshot is not None
|
|
190
|
+
else {}
|
|
191
|
+
)
|
|
192
|
+
client = client or GitHubClient()
|
|
193
|
+
kb_dir = Path(base_dir) / kb_name
|
|
194
|
+
raw_dir = kb_dir / "raw"
|
|
195
|
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
repo = source["repo"]
|
|
198
|
+
branch = source.get("branch") or "main"
|
|
199
|
+
path_prefix = source.get("path") or ""
|
|
200
|
+
glob = source.get("glob") or "*.md"
|
|
201
|
+
old_sha = source.get("last_synced_sha") or ""
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
latest_sha = await client.get_latest_commit_sha(repo, branch)
|
|
205
|
+
except GitHubAPIError as exc:
|
|
206
|
+
error = redact_sync_error(exc)
|
|
207
|
+
_record_sync_failure(kb_name, source, base_dir, error)
|
|
208
|
+
return SyncResult(ok=False, error=error)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
error = f"Failed to fetch latest SHA: {redact_sync_error(exc)}"
|
|
211
|
+
_record_sync_failure(kb_name, source, base_dir, error)
|
|
212
|
+
return SyncResult(ok=False, error=error)
|
|
213
|
+
|
|
214
|
+
if old_sha and old_sha == latest_sha:
|
|
215
|
+
_record_sync_success(kb_name, source, base_dir, sha=latest_sha)
|
|
216
|
+
return SyncResult(ok=True, skipped=True)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
if not old_sha:
|
|
220
|
+
result = await _full_sync(
|
|
221
|
+
client,
|
|
222
|
+
kb_name,
|
|
223
|
+
raw_dir,
|
|
224
|
+
repo,
|
|
225
|
+
branch,
|
|
226
|
+
path_prefix,
|
|
227
|
+
glob,
|
|
228
|
+
latest_sha,
|
|
229
|
+
base_dir,
|
|
230
|
+
**indexing_options,
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
result = await _incremental_sync(
|
|
234
|
+
client,
|
|
235
|
+
kb_name,
|
|
236
|
+
raw_dir,
|
|
237
|
+
repo,
|
|
238
|
+
branch,
|
|
239
|
+
path_prefix,
|
|
240
|
+
glob,
|
|
241
|
+
old_sha,
|
|
242
|
+
latest_sha,
|
|
243
|
+
base_dir,
|
|
244
|
+
**indexing_options,
|
|
245
|
+
)
|
|
246
|
+
except GitHubAPIError as exc:
|
|
247
|
+
error = redact_sync_error(exc)
|
|
248
|
+
_record_sync_failure(kb_name, source, base_dir, error)
|
|
249
|
+
return SyncResult(ok=False, error=error)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
error = redact_sync_error(exc)
|
|
252
|
+
_record_sync_failure(kb_name, source, base_dir, error)
|
|
253
|
+
return SyncResult(ok=False, error=error)
|
|
254
|
+
|
|
255
|
+
if not result.ok:
|
|
256
|
+
_record_sync_failure(kb_name, source, base_dir, result.error)
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
_record_sync_success(
|
|
260
|
+
kb_name,
|
|
261
|
+
source,
|
|
262
|
+
base_dir,
|
|
263
|
+
sha=latest_sha,
|
|
264
|
+
files_synced=result.files_added + result.files_updated,
|
|
265
|
+
)
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _full_sync(
|
|
270
|
+
client,
|
|
271
|
+
kb_name,
|
|
272
|
+
raw_dir,
|
|
273
|
+
repo,
|
|
274
|
+
branch,
|
|
275
|
+
path_prefix,
|
|
276
|
+
glob,
|
|
277
|
+
latest_sha,
|
|
278
|
+
base_dir,
|
|
279
|
+
*,
|
|
280
|
+
accepted_indexing_snapshot=None,
|
|
281
|
+
):
|
|
282
|
+
tree = await client.get_tree(repo, branch, path_prefix=path_prefix, glob=glob)
|
|
283
|
+
entries = _filter_markdown_entries(tree, path_prefix, glob)
|
|
284
|
+
downloaded = []
|
|
285
|
+
for entry in entries:
|
|
286
|
+
rel = _raw_rel_path(entry.path, path_prefix)
|
|
287
|
+
dest = _contained_dest(raw_dir, rel)
|
|
288
|
+
if dest is None:
|
|
289
|
+
continue
|
|
290
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
content = await client.download_file(repo, entry.path, latest_sha)
|
|
292
|
+
dest.write_bytes(content)
|
|
293
|
+
downloaded.append(str(dest))
|
|
294
|
+
if downloaded:
|
|
295
|
+
await _index_files(
|
|
296
|
+
kb_name,
|
|
297
|
+
downloaded,
|
|
298
|
+
base_dir,
|
|
299
|
+
**(
|
|
300
|
+
{"accepted_indexing_snapshot": accepted_indexing_snapshot}
|
|
301
|
+
if accepted_indexing_snapshot is not None
|
|
302
|
+
else {}
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
return SyncResult(ok=True, files_added=len(downloaded))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _incremental_sync(
|
|
309
|
+
client,
|
|
310
|
+
kb_name,
|
|
311
|
+
raw_dir,
|
|
312
|
+
repo,
|
|
313
|
+
branch,
|
|
314
|
+
path_prefix,
|
|
315
|
+
glob,
|
|
316
|
+
old_sha,
|
|
317
|
+
new_sha,
|
|
318
|
+
base_dir,
|
|
319
|
+
*,
|
|
320
|
+
accepted_indexing_snapshot=None,
|
|
321
|
+
):
|
|
322
|
+
all_changes = await client.compare_commits(repo, old_sha, new_sha)
|
|
323
|
+
changes = _filter_markdown_changes(all_changes, path_prefix, glob)
|
|
324
|
+
added_or_modified = []
|
|
325
|
+
removed = []
|
|
326
|
+
for ch in changes:
|
|
327
|
+
rel = _raw_rel_path(ch.path, path_prefix)
|
|
328
|
+
if ch.status == "removed":
|
|
329
|
+
removed.append(rel)
|
|
330
|
+
else:
|
|
331
|
+
added_or_modified.append(ch.path)
|
|
332
|
+
downloaded = []
|
|
333
|
+
for gh_path in added_or_modified:
|
|
334
|
+
rel = _raw_rel_path(gh_path, path_prefix)
|
|
335
|
+
dest = _contained_dest(raw_dir, rel)
|
|
336
|
+
if dest is None:
|
|
337
|
+
continue
|
|
338
|
+
content = await client.download_file(repo, gh_path, new_sha)
|
|
339
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
dest.write_bytes(content)
|
|
341
|
+
downloaded.append(str(dest))
|
|
342
|
+
if downloaded:
|
|
343
|
+
await _index_files(
|
|
344
|
+
kb_name,
|
|
345
|
+
downloaded,
|
|
346
|
+
base_dir,
|
|
347
|
+
**(
|
|
348
|
+
{"accepted_indexing_snapshot": accepted_indexing_snapshot}
|
|
349
|
+
if accepted_indexing_snapshot is not None
|
|
350
|
+
else {}
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
removed_count = 0
|
|
354
|
+
for rel in removed:
|
|
355
|
+
target = _contained_dest(raw_dir, rel)
|
|
356
|
+
if target is not None and target.exists():
|
|
357
|
+
try:
|
|
358
|
+
from deeptutor.knowledge.add_documents import remove_raw_document
|
|
359
|
+
|
|
360
|
+
kb_dir = Path(base_dir) / kb_name
|
|
361
|
+
remove_raw_document(kb_dir, target)
|
|
362
|
+
removed_count += 1
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
logger.warning("Failed to remove %s: %s", rel, exc)
|
|
365
|
+
return SyncResult(ok=True, files_added=len(downloaded), files_removed=removed_count)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def _index_files(kb_name, file_paths, base_dir, *, accepted_indexing_snapshot=None):
|
|
369
|
+
if not file_paths:
|
|
370
|
+
return 0
|
|
371
|
+
from deeptutor.knowledge.add_documents import add_documents
|
|
372
|
+
|
|
373
|
+
count = await add_documents(
|
|
374
|
+
kb_name=kb_name,
|
|
375
|
+
source_files=file_paths,
|
|
376
|
+
base_dir=base_dir,
|
|
377
|
+
allow_duplicates=False,
|
|
378
|
+
**(
|
|
379
|
+
{"accepted_indexing_snapshot": accepted_indexing_snapshot}
|
|
380
|
+
if accepted_indexing_snapshot is not None
|
|
381
|
+
else {}
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
return count or 0
|