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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. 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,5 @@
1
+ """GitHub-source sync subsystem: pull Markdown from GitHub repos into KBs."""
2
+
3
+ from deeptutor.services.github_source.sync_service import get_sync_service
4
+
5
+ __all__ = ["get_sync_service"]
@@ -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