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
deeptutor_cli/kb.py ADDED
@@ -0,0 +1,756 @@
1
+ """
2
+ CLI Knowledge Base Command
3
+ ===========================
4
+
5
+ Manage knowledge bases from the command line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Optional
14
+
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+ import typer
18
+
19
+ from deeptutor.knowledge.manager import KnowledgeBaseManager
20
+ from deeptutor.knowledge.naming import validate_knowledge_base_name
21
+ from deeptutor.services.path_service import get_path_service
22
+ from deeptutor.services.rag.factory import DEFAULT_PROVIDER
23
+ from deeptutor.services.rag.file_routing import FileTypeRouter
24
+
25
+ if TYPE_CHECKING: # pragma: no cover - imported for type annotations only
26
+ from deeptutor.services.rag.eval import EvalDataset, EvalReport, ProgressFn, QueryEvaluation
27
+
28
+ console = Console()
29
+
30
+
31
+ def _get_kb_manager() -> KnowledgeBaseManager:
32
+ """Return a KnowledgeBaseManager rooted at the canonical project-level KB directory."""
33
+ base_dir = get_path_service().project_root / "data" / "knowledge_bases"
34
+ return KnowledgeBaseManager(base_dir=str(base_dir))
35
+
36
+
37
+ def _collect_documents(docs: list[str], docs_dir: Optional[str]) -> list[str]:
38
+ """Collect and de-duplicate document files from explicit paths and a directory."""
39
+ candidates: list[Path] = []
40
+
41
+ for doc in docs:
42
+ path = Path(doc).expanduser().resolve()
43
+ if path.exists() and path.is_file():
44
+ candidates.append(path)
45
+
46
+ if docs_dir:
47
+ base = Path(docs_dir).expanduser().resolve()
48
+ if not base.exists() or not base.is_dir():
49
+ raise typer.BadParameter(f"docs directory does not exist: {base}")
50
+ candidates.extend(FileTypeRouter.collect_supported_files(base, recursive=True))
51
+
52
+ unique: list[str] = []
53
+ seen: set[str] = set()
54
+ for path in candidates:
55
+ key = str(path)
56
+ if key in seen:
57
+ continue
58
+ seen.add(key)
59
+ unique.append(key)
60
+
61
+ return unique
62
+
63
+
64
+ def _load_eval_dataset(dataset: str) -> EvalDataset:
65
+ """Load the QA set for ``kb eval``, exiting with a readable message when it is invalid."""
66
+ from deeptutor.services.rag.eval import EvalDatasetError, load_dataset
67
+
68
+ try:
69
+ return load_dataset(dataset)
70
+ except EvalDatasetError as exc:
71
+ console.print(f"[red]{exc}[/]")
72
+ raise typer.Exit(code=1) from exc
73
+
74
+
75
+ def _resolve_eval_provider(mgr: KnowledgeBaseManager, name: str) -> str:
76
+ """Return the provider bound to ``name``, rejecting engines that return no ranked chunks."""
77
+ from deeptutor.services.rag.factory import PAGEINDEX_OSS_PROVIDER, PAGEINDEX_PROVIDER
78
+ from deeptutor.services.rag.provider_binding import resolve_bound_provider
79
+
80
+ provider = resolve_bound_provider(str(mgr.base_dir), name)
81
+ if provider in {PAGEINDEX_PROVIDER, PAGEINDEX_OSS_PROVIDER}:
82
+ console.print(
83
+ "[red]PageIndex answers by reasoning over pages inside an agent loop, so it "
84
+ "returns no ranked chunks to score. Evaluate a KB bound to llamaindex, "
85
+ "graphrag, lightrag, a LightRAG server, WeKnora or IMA instead.[/]"
86
+ )
87
+ raise typer.Exit(code=1)
88
+ return provider
89
+
90
+
91
+ def _build_eval_progress(top_k: int, fmt: str) -> ProgressFn:
92
+ """Return a per-case progress printer that stays silent while emitting JSON."""
93
+ from deeptutor.services.rag.eval import format_metric
94
+
95
+ recall_key = f"recall@{top_k}"
96
+
97
+ def _report(index: int, total: int, item: QueryEvaluation) -> None:
98
+ if fmt == "json":
99
+ return
100
+ if item.failed:
101
+ console.print(f" {index}/{total} [red]failed[/] {item.case_id}: {item.error}")
102
+ return
103
+ value = format_metric(item.metrics.values()[recall_key])
104
+ console.print(f" {index}/{total} [green]ok[/] {item.case_id} {recall_key}={value}")
105
+
106
+ return _report
107
+
108
+
109
+ def _save_eval_report(save: str, payload: str) -> None:
110
+ """Write a serialized evaluation report to ``save``, creating parent directories."""
111
+ target = Path(save).expanduser()
112
+ try:
113
+ target.parent.mkdir(parents=True, exist_ok=True)
114
+ target.write_text(payload, encoding="utf-8")
115
+ except OSError as exc:
116
+ typer.echo(f"Could not write report: {exc}", err=True)
117
+ raise typer.Exit(code=1) from exc
118
+ typer.echo(f"Report written to {target}", err=True)
119
+
120
+
121
+ def _render_eval_report(report: EvalReport, name: str, provider: str) -> None:
122
+ """Print the per-case metric table followed by the aggregate summary lines."""
123
+ columns = report.metric_columns()
124
+ table = Table(title=f"Retrieval evaluation - {name} ({provider})")
125
+ table.add_column("Case", style="bold")
126
+ table.add_column("Status")
127
+ table.add_column("Query", overflow="fold", max_width=48)
128
+ for column in columns:
129
+ table.add_column(column, justify="right")
130
+ for item in report.evaluations:
131
+ cells = report.format_metrics(item)
132
+ table.add_row(
133
+ item.case_id,
134
+ "[red]failed[/]" if item.failed else "[green]ok[/]",
135
+ item.query,
136
+ *(cells[column] for column in columns),
137
+ )
138
+ console.print(table)
139
+ for line in report.summary_lines():
140
+ console.print(line)
141
+
142
+
143
+ def _parse_rewrite_flag(value: Optional[str]) -> Optional[bool]:
144
+ """Map a ``--rewrite`` value to the three-state rewrite flag, or None to defer to settings.
145
+
146
+ Args:
147
+ value: ``None`` (flag omitted, defer to settings), ``"off"`` or ``"standalone"``.
148
+
149
+ Returns:
150
+ ``False`` for ``"off"``, ``True`` for ``"standalone"``, ``None`` when omitted.
151
+
152
+ Raises:
153
+ typer.BadParameter: If ``value`` is not a recognized strategy.
154
+ """
155
+ if value is None:
156
+ return None
157
+ normalized = value.strip().lower()
158
+ if normalized == "off":
159
+ return False
160
+ if normalized == "standalone":
161
+ return True
162
+ raise typer.BadParameter(
163
+ f"unknown rewrite strategy '{value}'; expected 'off' or 'standalone'",
164
+ param_hint="--rewrite",
165
+ )
166
+
167
+
168
+ def _load_eval_report(path: str) -> dict:
169
+ """Load a saved ``kb eval --save`` report for ``kb eval-diff``, exiting readably on error.
170
+
171
+ Args:
172
+ path: Path to a report JSON file written by ``kb eval --save``.
173
+
174
+ Returns:
175
+ The parsed report as a mapping, ready for
176
+ :func:`deeptutor.services.rag.eval.diff_reports`.
177
+
178
+ Raises:
179
+ typer.Exit: If the file is missing, is not valid JSON, or is not a JSON object.
180
+ """
181
+ target = Path(path).expanduser()
182
+ if not target.exists():
183
+ console.print(f"[red]Report not found: {target}[/]")
184
+ raise typer.Exit(code=1)
185
+ try:
186
+ loaded = json.loads(target.read_text(encoding="utf-8"))
187
+ except (OSError, json.JSONDecodeError) as exc:
188
+ console.print(f"[red]Could not read report '{target}': {exc}[/]")
189
+ raise typer.Exit(code=1) from exc
190
+ if not isinstance(loaded, dict):
191
+ console.print(f"[red]Report '{target}' is not a JSON object.[/]")
192
+ raise typer.Exit(code=1)
193
+ return loaded
194
+
195
+
196
+ def register(app: typer.Typer) -> None:
197
+ @app.command("list")
198
+ def kb_list(
199
+ fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."),
200
+ ) -> None:
201
+ """List all knowledge bases."""
202
+ mgr = _get_kb_manager()
203
+ kb_names = mgr.list_knowledge_bases()
204
+ if not kb_names:
205
+ if fmt == "json":
206
+ console.print_json("[]")
207
+ else:
208
+ console.print("[dim]No knowledge bases found.[/]")
209
+ return
210
+
211
+ if fmt == "json":
212
+ items = []
213
+ for name in kb_names:
214
+ info = mgr.get_info(name)
215
+ stats = info.get("statistics", {})
216
+ metadata = info.get("metadata", {})
217
+ items.append(
218
+ {
219
+ "name": name,
220
+ "status": info.get("status", "unknown"),
221
+ "documents": stats.get("raw_documents", 0),
222
+ "rag_provider": metadata.get(
223
+ "rag_provider", stats.get("rag_provider", DEFAULT_PROVIDER)
224
+ ),
225
+ "is_default": bool(info.get("is_default")),
226
+ }
227
+ )
228
+ console.print_json(json.dumps(items, ensure_ascii=False, default=str))
229
+ return
230
+
231
+ table = Table(title="Knowledge Bases")
232
+ table.add_column("Name", style="bold")
233
+ table.add_column("Status")
234
+ table.add_column("Documents", justify="right")
235
+ table.add_column("RAG Provider")
236
+ table.add_column("Default")
237
+
238
+ for name in kb_names:
239
+ info = mgr.get_info(name)
240
+ stats = info.get("statistics", {})
241
+ metadata = info.get("metadata", {})
242
+ table.add_row(
243
+ name,
244
+ str(info.get("status", "unknown")),
245
+ str(stats.get("raw_documents", 0)),
246
+ str(metadata.get("rag_provider", stats.get("rag_provider", DEFAULT_PROVIDER))),
247
+ "yes" if info.get("is_default") else "",
248
+ )
249
+
250
+ console.print(table)
251
+
252
+ @app.command("info")
253
+ def kb_info(name: str = typer.Argument(..., help="Knowledge base name.")) -> None:
254
+ """Show details of a knowledge base."""
255
+ mgr = _get_kb_manager()
256
+ try:
257
+ info = mgr.get_info(name)
258
+ except Exception as exc:
259
+ console.print(f"[red]Knowledge base '{name}' not found: {exc}[/]")
260
+ raise typer.Exit(code=1) from exc
261
+ console.print_json(json.dumps(info, indent=2, ensure_ascii=False, default=str))
262
+
263
+ @app.command("set-default")
264
+ def kb_set_default(name: str = typer.Argument(..., help="Knowledge base name.")) -> None:
265
+ """Set the default knowledge base."""
266
+ mgr = _get_kb_manager()
267
+ try:
268
+ mgr.set_default(name)
269
+ except Exception as exc:
270
+ console.print(f"[red]Failed to set default KB '{name}': {exc}[/]")
271
+ raise typer.Exit(code=1) from exc
272
+ console.print(f"[green]Set '{name}' as default knowledge base.[/]")
273
+
274
+ @app.command("create")
275
+ def kb_create(
276
+ name: str = typer.Argument(..., help="New KB name."),
277
+ docs: list[str] = typer.Option([], "--doc", "-d", help="Document paths."),
278
+ docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="Directory of documents."),
279
+ ) -> None:
280
+ """Initialize a new knowledge base from documents."""
281
+ mgr = _get_kb_manager()
282
+ try:
283
+ name = validate_knowledge_base_name(name)
284
+ except ValueError as exc:
285
+ console.print(f"[red]{exc}[/]")
286
+ raise typer.Exit(code=1) from exc
287
+
288
+ if name in mgr.list_knowledge_bases():
289
+ console.print(f"[red]Knowledge base '{name}' already exists.[/]")
290
+ raise typer.Exit(code=1)
291
+
292
+ try:
293
+ doc_paths = _collect_documents(docs, docs_dir)
294
+ except typer.BadParameter as exc:
295
+ console.print(f"[red]{exc}[/]")
296
+ raise typer.Exit(code=1) from exc
297
+
298
+ if not doc_paths:
299
+ console.print("[red]Provide at least one supported document (--doc or --docs-dir).[/]")
300
+ raise typer.Exit(code=1)
301
+
302
+ console.print(
303
+ f"Creating KB [bold]{name}[/] with {len(doc_paths)} document(s) via [bold]LlamaIndex[/]..."
304
+ )
305
+ from deeptutor.knowledge.initializer import initialize_knowledge_base
306
+
307
+ try:
308
+ asyncio.run(
309
+ initialize_knowledge_base(
310
+ kb_name=name,
311
+ source_files=doc_paths,
312
+ base_dir=str(mgr.base_dir),
313
+ )
314
+ )
315
+ except Exception as exc:
316
+ console.print(f"[red]KB creation failed: {exc}[/]")
317
+ raise typer.Exit(code=1) from exc
318
+ console.print("[green]Knowledge base created successfully.[/]")
319
+
320
+ @app.command("connect-kiwix")
321
+ def kb_connect_kiwix(
322
+ name: str = typer.Argument(..., help="New KB name."),
323
+ server_url: str = typer.Option(..., "--server-url", help="kiwix-serve base URL."),
324
+ zim_name: str = typer.Option(..., "--zim-name", help="One ZIM name served by Kiwix."),
325
+ ) -> None:
326
+ """Connect an existing ZIM archive without copying or indexing it."""
327
+ from deeptutor.services.rag.pipelines.kiwix.client import KiwixClient
328
+
329
+ try:
330
+ client = KiwixClient(server_url, zim_name)
331
+ title = asyncio.run(client.probe())
332
+ _get_kb_manager().register_kiwix_kb(
333
+ name, client.base_url, client.zim_name, zim_title=title
334
+ )
335
+ except Exception as exc:
336
+ console.print(f"[red]Could not connect Kiwix archive: {exc}[/]")
337
+ raise typer.Exit(code=1) from exc
338
+ console.print(f"[green]Connected '{name}' to Kiwix archive '{title}'.[/]")
339
+
340
+ @app.command("add")
341
+ def kb_add(
342
+ name: str = typer.Argument(..., help="KB name."),
343
+ docs: list[str] = typer.Option([], "--doc", "-d", help="Document paths to add."),
344
+ docs_dir: Optional[str] = typer.Option(None, "--docs-dir", help="Directory of documents."),
345
+ ) -> None:
346
+ """Add documents to an existing knowledge base."""
347
+ mgr = _get_kb_manager()
348
+ if name not in mgr.list_knowledge_bases():
349
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
350
+ raise typer.Exit(code=1)
351
+
352
+ try:
353
+ doc_paths = _collect_documents(docs, docs_dir)
354
+ except typer.BadParameter as exc:
355
+ console.print(f"[red]{exc}[/]")
356
+ raise typer.Exit(code=1) from exc
357
+
358
+ if not doc_paths:
359
+ console.print("[red]Provide at least one supported document.[/]")
360
+ raise typer.Exit(code=1)
361
+
362
+ console.print(f"Adding {len(doc_paths)} document(s) to [bold]{name}[/]...")
363
+ from deeptutor.knowledge.add_documents import add_documents
364
+
365
+ try:
366
+ processed_count = asyncio.run(
367
+ add_documents(
368
+ kb_name=name,
369
+ source_files=doc_paths,
370
+ base_dir=str(mgr.base_dir),
371
+ allow_duplicates=False,
372
+ )
373
+ )
374
+ except Exception as exc:
375
+ console.print(f"[red]Document upload failed: {exc}[/]")
376
+ raise typer.Exit(code=1) from exc
377
+
378
+ if processed_count:
379
+ console.print(f"[green]Done. Indexed {processed_count} document(s).[/]")
380
+ else:
381
+ console.print("[yellow]No new unique documents were indexed.[/]")
382
+
383
+ @app.command("delete")
384
+ def kb_delete(
385
+ name: str = typer.Argument(..., help="KB name."),
386
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
387
+ ) -> None:
388
+ """Delete a knowledge base."""
389
+ if not force:
390
+ confirm = typer.confirm(f"Delete knowledge base '{name}'?")
391
+ if not confirm:
392
+ raise typer.Abort()
393
+
394
+ mgr = _get_kb_manager()
395
+ try:
396
+ deleted = mgr.delete_knowledge_base(name, confirm=True)
397
+ except Exception as exc:
398
+ console.print(f"[red]Failed to delete '{name}': {exc}[/]")
399
+ raise typer.Exit(code=1) from exc
400
+
401
+ if deleted:
402
+ console.print(f"[green]Deleted '{name}'.[/]")
403
+ else:
404
+ console.print(f"[yellow]Knowledge base '{name}' was not deleted.[/]")
405
+
406
+ @app.command("search")
407
+ def kb_search(
408
+ name: str = typer.Argument(..., help="KB name."),
409
+ query: str = typer.Argument(..., help="Search query."),
410
+ mode: str = typer.Option("hybrid", help="Search mode."),
411
+ fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."),
412
+ ) -> None:
413
+ """Search a knowledge base."""
414
+ from deeptutor.tools.rag_tool import rag_search
415
+
416
+ mgr = _get_kb_manager()
417
+ if name not in mgr.list_knowledge_bases():
418
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
419
+ raise typer.Exit(code=1)
420
+
421
+ try:
422
+ from deeptutor.services.rag.factory import PAGEINDEX_OSS_PROVIDER, PAGEINDEX_PROVIDER
423
+ from deeptutor.services.rag.provider_binding import resolve_bound_provider
424
+
425
+ provider = resolve_bound_provider(str(mgr.base_dir), name)
426
+ if provider in {PAGEINDEX_PROVIDER, PAGEINDEX_OSS_PROVIDER}:
427
+ from deeptutor.services.rag.pipelines.pageindex.reasoning import (
428
+ read_pageindex_with_agent,
429
+ )
430
+
431
+ reading = asyncio.run(
432
+ read_pageindex_with_agent(
433
+ kb_name=name,
434
+ system_prompt=(
435
+ "Answer the CLI user's question from the selected PageIndex "
436
+ "knowledge base. Include page references from tool results."
437
+ ),
438
+ user_prompt=query,
439
+ source="cli_kb_search",
440
+ stage="search",
441
+ )
442
+ )
443
+ result = {
444
+ "query": query,
445
+ "answer": reading.text,
446
+ "content": reading.text,
447
+ "sources": reading.sources,
448
+ "provider": reading.tool_context.provider,
449
+ }
450
+ else:
451
+ result = asyncio.run(
452
+ rag_search(
453
+ query=query,
454
+ kb_name=name,
455
+ mode=mode,
456
+ kb_base_dir=str(mgr.base_dir),
457
+ )
458
+ )
459
+ except Exception as exc:
460
+ console.print(f"[red]Search failed: {exc}[/]")
461
+ raise typer.Exit(code=1) from exc
462
+
463
+ if fmt == "json":
464
+ console.print_json(json.dumps(result, indent=2, ensure_ascii=False, default=str))
465
+ return
466
+
467
+ answer = result.get("answer") or result.get("content", "")
468
+ provider = str(result.get("provider", DEFAULT_PROVIDER))
469
+ console.print(f"[bold]Provider:[/] {provider}")
470
+ console.print(f"[bold]Answer:[/]\n{answer}")
471
+
472
+ @app.command("eval")
473
+ def kb_eval(
474
+ name: str = typer.Argument(..., help="KB name."),
475
+ dataset: str = typer.Option(..., "--dataset", "-d", help="QA set file (.jsonl or .json)."),
476
+ top_k: int = typer.Option(5, "--top-k", "-k", help="Rank cutoff for the metrics."),
477
+ mode: Optional[str] = typer.Option(
478
+ None, "--mode", help="Search mode for engines that support one (e.g. hybrid)."
479
+ ),
480
+ limit: Optional[int] = typer.Option(
481
+ None, "--limit", help="Evaluate only the first N cases of the set."
482
+ ),
483
+ min_ratio: float = typer.Option(
484
+ 0.5, "--min-ratio", help="Token coverage a retrieved chunk needs to count as a match."
485
+ ),
486
+ save: Optional[str] = typer.Option(None, "--save", help="Write the JSON report to a file."),
487
+ fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."),
488
+ rewrite: Optional[str] = typer.Option(
489
+ None,
490
+ "--rewrite",
491
+ help="Force the query-rewrite layer: off | standalone. Omit to defer to settings.",
492
+ ),
493
+ ) -> None:
494
+ """Score a knowledge base's retrieval quality against a QA set."""
495
+ from deeptutor.services.rag.eval import MatchPolicy, RetrievalEvaluator
496
+
497
+ rewrite_flag = _parse_rewrite_flag(rewrite)
498
+
499
+ mgr = _get_kb_manager()
500
+ if name not in mgr.list_knowledge_bases():
501
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
502
+ raise typer.Exit(code=1)
503
+
504
+ eval_set = _load_eval_dataset(dataset)
505
+ provider = _resolve_eval_provider(mgr, name)
506
+
507
+ try:
508
+ evaluator = RetrievalEvaluator(
509
+ kb_name=name,
510
+ kb_base_dir=str(mgr.base_dir),
511
+ k=top_k,
512
+ mode=mode,
513
+ query_limit=limit,
514
+ policy=MatchPolicy(min_ratio=min_ratio),
515
+ provider=provider,
516
+ rewrite=rewrite_flag,
517
+ )
518
+ except ValueError as exc:
519
+ console.print(f"[red]{exc}[/]")
520
+ raise typer.Exit(code=1) from exc
521
+
522
+ cases = eval_set.limited(limit)
523
+ if fmt != "json":
524
+ suffix = ""
525
+ if rewrite_flag is True:
526
+ suffix = ", rewrite=standalone"
527
+ elif rewrite_flag is False:
528
+ suffix = ", rewrite=off"
529
+ console.print(
530
+ f"[bold]Evaluating[/] '{name}' ({provider}) on {len(cases)} case(s), "
531
+ f"k={top_k}{suffix} ..."
532
+ )
533
+
534
+ progress = _build_eval_progress(top_k, fmt)
535
+ try:
536
+ report = asyncio.run(evaluator.evaluate(eval_set, progress=progress))
537
+ except Exception as exc:
538
+ console.print(f"[red]Evaluation failed: {exc}[/]")
539
+ raise typer.Exit(code=1) from exc
540
+
541
+ payload = report.to_json()
542
+ if save:
543
+ _save_eval_report(save, payload)
544
+
545
+ if fmt == "json":
546
+ console.print_json(payload)
547
+ return
548
+
549
+ _render_eval_report(report, name, provider)
550
+
551
+ @app.command("eval-diff")
552
+ def kb_eval_diff(
553
+ baseline: str = typer.Argument(..., help="Baseline report JSON (from `kb eval --save`)."),
554
+ candidate: str = typer.Argument(..., help="Candidate report JSON (from `kb eval --save`)."),
555
+ fmt: str = typer.Option("rich", "--format", "-f", help="Output format: rich | json."),
556
+ ) -> None:
557
+ """Diff two `kb eval` reports to quantify a retrieval change.
558
+
559
+ Records a baseline before a settings change (e.g. turning the query-rewrite
560
+ layer on), a candidate after, and shows the per-metric and per-case movement.
561
+ """
562
+ from deeptutor.services.rag.eval.diff import diff_reports
563
+
564
+ base_report = _load_eval_report(baseline)
565
+ cand_report = _load_eval_report(candidate)
566
+
567
+ try:
568
+ diff = diff_reports(
569
+ base_report,
570
+ cand_report,
571
+ baseline_label=Path(baseline).stem,
572
+ candidate_label=Path(candidate).stem,
573
+ )
574
+ except ValueError as exc:
575
+ console.print(f"[red]{exc}[/]")
576
+ raise typer.Exit(code=1) from exc
577
+
578
+ if fmt == "json":
579
+ console.print_json(data=diff.to_dict())
580
+ return
581
+
582
+ for line in diff.summary_lines():
583
+ console.print(line)
584
+
585
+ if diff.has_regression:
586
+ raise typer.Exit(code=2)
587
+
588
+ # ── GitHub source commands ──────────────────────────────────────
589
+
590
+ @app.command("add-github-source")
591
+ def kb_add_github_source(
592
+ name: str = typer.Argument(..., help="KB name."),
593
+ repo: str = typer.Option(..., "--repo", "-r", help="GitHub repo as owner/name or URL."),
594
+ branch: str = typer.Option("main", "--branch", "-b", help="Branch to track."),
595
+ path: str = typer.Option("", "--path", "-p", help="Subdirectory prefix (e.g. docs/)."),
596
+ glob: str = typer.Option("*.md", "--glob", "-g", help="File glob pattern."),
597
+ ) -> None:
598
+ """Add a GitHub repo as a Markdown document source for a KB."""
599
+ mgr = _get_kb_manager()
600
+ if name not in mgr.list_knowledge_bases():
601
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
602
+ raise typer.Exit(code=1)
603
+ try:
604
+ info = mgr.add_github_source(name, repo, branch, path, glob)
605
+ except Exception as exc:
606
+ console.print(f"[red]Failed: {exc}[/]")
607
+ raise typer.Exit(code=1) from exc
608
+ console.print("[green]GitHub source added:[/]")
609
+ console.print_json(json.dumps(info, ensure_ascii=False))
610
+
611
+ @app.command("remove-github-source")
612
+ def kb_remove_github_source(
613
+ name: str = typer.Argument(..., help="KB name."),
614
+ source_id: str = typer.Option(..., "--source-id", "-s", help="Source ID to remove."),
615
+ ) -> None:
616
+ """Remove a GitHub source from a KB."""
617
+ mgr = _get_kb_manager()
618
+ ok = mgr.remove_github_source(name, source_id)
619
+ if ok:
620
+ console.print(f"[green]Removed '{source_id}'.[/]")
621
+ else:
622
+ console.print(f"[yellow]Source '{source_id}' not found.[/]")
623
+
624
+ @app.command("add-web-source")
625
+ def kb_add_web_source(
626
+ name: str = typer.Argument(..., help="KB name."),
627
+ url: str = typer.Option(..., "--url", "-u", help="Documentation site base URL."),
628
+ max_depth: int = typer.Option(3, "--max-depth", help="Crawl depth."),
629
+ max_pages: int = typer.Option(200, "--max-pages", help="Max pages to crawl."),
630
+ ) -> None:
631
+ """Add a documentation site URL as a source for a KB."""
632
+ mgr = _get_kb_manager()
633
+ if name not in mgr.list_knowledge_bases():
634
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
635
+ raise typer.Exit(code=1)
636
+ try:
637
+ info = mgr.add_web_source(name, url, max_depth, max_pages)
638
+ except Exception as exc:
639
+ console.print(f"[red]Failed: {exc}[/]")
640
+ raise typer.Exit(code=1) from exc
641
+ console.print("[green]Web source added:[/]")
642
+ console.print_json(json.dumps(info, ensure_ascii=False))
643
+
644
+ @app.command("remove-web-source")
645
+ def kb_remove_web_source(
646
+ name: str = typer.Argument(..., help="KB name."),
647
+ source_id: str = typer.Option(..., "--source-id", "-s", help="Source ID to remove."),
648
+ ) -> None:
649
+ """Remove a web source from a KB."""
650
+ mgr = _get_kb_manager()
651
+ removed = mgr.remove_web_source(name, source_id)
652
+ if removed:
653
+ console.print(f"[green]Removed '{source_id}'.[/]")
654
+ else:
655
+ console.print(f"[yellow]Source '{source_id}' not found.[/]")
656
+
657
+ @app.command("list-sources")
658
+ def kb_list_sources(
659
+ name: str = typer.Argument(..., help="KB name."),
660
+ ) -> None:
661
+ """List GitHub and web sources for a KB."""
662
+ mgr = _get_kb_manager()
663
+ if name not in mgr.list_knowledge_bases():
664
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
665
+ raise typer.Exit(code=1)
666
+
667
+ gh_sources = mgr.get_github_sources(name)
668
+ web_sources = mgr.get_web_sources(name)
669
+
670
+ if not gh_sources and not web_sources:
671
+ console.print("[dim]No sources for this KB.[/]")
672
+ return
673
+
674
+ if gh_sources:
675
+ table = Table(title=f"GitHub Sources - {name}")
676
+ table.add_column("ID", style="dim")
677
+ table.add_column("Repo")
678
+ table.add_column("Branch")
679
+ table.add_column("Status")
680
+ table.add_column("Files", justify="right")
681
+ for source in gh_sources:
682
+ table.add_row(
683
+ source.get("id", ""),
684
+ source.get("repo", ""),
685
+ source.get("branch", ""),
686
+ source.get("last_sync_status", "pending"),
687
+ str(source.get("files_synced", 0)),
688
+ )
689
+ console.print(table)
690
+
691
+ if web_sources:
692
+ table = Table(title=f"Web Sources - {name}")
693
+ table.add_column("ID", style="dim")
694
+ table.add_column("URL")
695
+ table.add_column("Status")
696
+ table.add_column("Pages", justify="right")
697
+ for source in web_sources:
698
+ table.add_row(
699
+ source.get("id", ""),
700
+ source.get("url", ""),
701
+ source.get("last_sync_status", "pending"),
702
+ str(source.get("page_count", 0)),
703
+ )
704
+ console.print(table)
705
+
706
+ @app.command("sync")
707
+ def kb_sync(name: str = typer.Argument(..., help="KB name.")) -> None:
708
+ """Immediately sync all GitHub and web sources for a KB."""
709
+ mgr = _get_kb_manager()
710
+ if name not in mgr.list_knowledge_bases():
711
+ console.print(f"[red]Knowledge base '{name}' not found.[/]")
712
+ raise typer.Exit(code=1)
713
+
714
+ gh_sources = mgr.get_github_sources(name)
715
+ web_sources = mgr.get_web_sources(name)
716
+ if not gh_sources and not web_sources:
717
+ console.print("[yellow]No sources to sync.[/]")
718
+ raise typer.Exit(code=0)
719
+
720
+ from deeptutor.services.github_source.sync import sync_source as gh_sync
721
+ from deeptutor.services.web_source.sync import sync_source as web_sync
722
+
723
+ for src in gh_sources:
724
+ if not src.get("enabled", True):
725
+ continue
726
+ console.print(f"Syncing GitHub [bold]{src.get('repo')}[/] ...")
727
+ try:
728
+ r = asyncio.run(gh_sync(kb_name=name, source=src, base_dir=str(mgr.base_dir)))
729
+ except Exception as exc:
730
+ console.print(f" [red]Error: {exc}[/]")
731
+ continue
732
+ if r.skipped:
733
+ console.print(" [dim]Up to date.[/]")
734
+ elif r.ok:
735
+ console.print(f" [green]+{r.files_added} ~{r.files_updated} -{r.files_removed}[/]")
736
+ else:
737
+ console.print(f" [red]Failed: {r.error}[/]")
738
+
739
+ for source in web_sources:
740
+ if not source.get("enabled", True):
741
+ continue
742
+ console.print(f"Crawling [bold]{source.get('url')}[/] ...")
743
+ try:
744
+ result = asyncio.run(
745
+ web_sync(kb_name=name, source=source, base_dir=str(mgr.base_dir))
746
+ )
747
+ except Exception as exc:
748
+ console.print(f" [red]Error: {exc}[/]")
749
+ continue
750
+ if result.ok:
751
+ console.print(
752
+ f" [green]+{result.pages_added} ~{result.pages_updated} "
753
+ f"-{result.pages_removed} ({result.pages_unchanged} unchanged)[/]"
754
+ )
755
+ else:
756
+ console.print(f" [red]Failed: {result.error}[/]")