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,274 @@
1
+ """Per-engine environment preflight checks.
2
+
3
+ Powers the "check whether this engine can run right now" affordance on each
4
+ engine's detail page. Every check is best-effort and never raises — a failed
5
+ import or missing config becomes a failed/optional check, not an exception.
6
+
7
+ A check is ``{key, label, ok, detail, optional}``. Overall ``ok`` is true when
8
+ every *required* (non-optional) check passes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from .factory import (
16
+ DEFAULT_PROVIDER,
17
+ GRAPHRAG_PROVIDER,
18
+ IMA_PROVIDER,
19
+ LIGHTRAG_PROVIDER,
20
+ LIGHTRAG_SERVER_PROVIDER,
21
+ PAGEINDEX_OSS_PROVIDER,
22
+ PAGEINDEX_PROVIDER,
23
+ normalize_provider_name,
24
+ )
25
+
26
+
27
+ def _check(key: str, label: str, ok: bool, detail: str = "", *, optional: bool = False) -> dict:
28
+ return {"key": key, "label": label, "ok": bool(ok), "detail": detail, "optional": optional}
29
+
30
+
31
+ def _active_chat_model() -> tuple[str | None, str]:
32
+ """Return ``(model, binding)`` for the active chat LLM, or ``(None, "")``."""
33
+ try:
34
+ from deeptutor.services.config import resolve_llm_runtime_config
35
+
36
+ cfg = resolve_llm_runtime_config()
37
+ return getattr(cfg, "model", None), str(getattr(cfg, "binding", "") or "")
38
+ except Exception:
39
+ return None, ""
40
+
41
+
42
+ def _active_embedding() -> tuple[str | None, int]:
43
+ """Return ``(model, dim)`` for the active embedding model, or ``(None, 0)``."""
44
+ try:
45
+ from deeptutor.services.embedding import get_embedding_config
46
+
47
+ cfg = get_embedding_config()
48
+ return getattr(cfg, "model", None), int(getattr(cfg, "dim", 0) or 0)
49
+ except Exception:
50
+ return None, 0
51
+
52
+
53
+ def _llamaindex_preflight() -> dict:
54
+ emb_model, emb_dim = _active_embedding()
55
+ checks = [
56
+ _check(
57
+ "embedding",
58
+ "Active embedding model",
59
+ bool(emb_model) and emb_dim > 0,
60
+ f"{emb_model} · {emb_dim}d" if emb_model else "Configure one in the model catalog.",
61
+ )
62
+ ]
63
+ try:
64
+ from .pipelines.llamaindex.retrievers import _import_bm25_retriever
65
+
66
+ bm25_ok = _import_bm25_retriever() is not None
67
+ except Exception:
68
+ bm25_ok = False
69
+ checks.append(
70
+ _check(
71
+ "bm25",
72
+ "BM25 hybrid retrieval",
73
+ bm25_ok,
74
+ "Installed." if bm25_ok else "Not installed — hybrid falls back to vector-only.",
75
+ optional=True,
76
+ )
77
+ )
78
+ return _finalize(checks)
79
+
80
+
81
+ def _pageindex_preflight() -> dict:
82
+ try:
83
+ from .pipelines.pageindex.config import get_pageindex_config
84
+
85
+ cfg = get_pageindex_config(require_key=False)
86
+ configured = bool(cfg.api_key)
87
+ except Exception:
88
+ configured = False
89
+ return _finalize(
90
+ [
91
+ _check(
92
+ "api_key",
93
+ "API key configured",
94
+ configured,
95
+ "PageIndex Cloud" if configured else "Add a PageIndex API key under Credentials.",
96
+ )
97
+ ]
98
+ )
99
+
100
+
101
+ def _pageindex_oss_preflight() -> dict:
102
+ try:
103
+ from .pipelines.pageindex.client import resolve_oss_sdk_config
104
+
105
+ model, _backend = resolve_oss_sdk_config()
106
+ llm_ok, detail = bool(model), model
107
+ except Exception as exc:
108
+ llm_ok = False
109
+ detail = str(exc)
110
+ return _finalize(
111
+ [
112
+ _check("chat", "Active LLM for indexing", llm_ok, detail),
113
+ ]
114
+ )
115
+
116
+
117
+ def _graphrag_preflight() -> dict:
118
+ try:
119
+ from .pipelines.graphrag.config import is_graphrag_available
120
+
121
+ installed = is_graphrag_available()
122
+ except Exception:
123
+ installed = False
124
+ emb_model, emb_dim = _active_embedding()
125
+ chat_model, _ = _active_chat_model()
126
+ return _finalize(
127
+ [
128
+ _check(
129
+ "package",
130
+ "GraphRAG package installed",
131
+ installed,
132
+ "Installed." if installed else "pip install 'deeptutor[graphrag]'",
133
+ ),
134
+ _check(
135
+ "chat",
136
+ "Active chat model",
137
+ bool(chat_model),
138
+ chat_model or "Configure one in the model catalog.",
139
+ ),
140
+ _check(
141
+ "embedding",
142
+ "Active embedding model",
143
+ bool(emb_model) and emb_dim > 0,
144
+ f"{emb_model} · {emb_dim}d" if emb_model else "Configure one in the model catalog.",
145
+ ),
146
+ ]
147
+ )
148
+
149
+
150
+ def _lightrag_preflight() -> dict:
151
+ package_detail = "pip install 'deeptutor[rag-lightrag]'"
152
+ try:
153
+ from .pipelines.lightrag.config import is_lightrag_available
154
+ from .pipelines.lightrag.engine import LIGHTRAG_VERSION, installed_version
155
+
156
+ current_version = installed_version() if is_lightrag_available() else ""
157
+ installed = current_version == LIGHTRAG_VERSION
158
+ if current_version:
159
+ package_detail = (
160
+ f"Installed {current_version}."
161
+ if installed
162
+ else f"Found {current_version}; required {LIGHTRAG_VERSION}."
163
+ )
164
+ except Exception:
165
+ installed = False
166
+ emb_model, emb_dim = _active_embedding()
167
+ chat_model, binding = _active_chat_model()
168
+ vision_ok = False
169
+ if chat_model:
170
+ try:
171
+ from deeptutor.services.llm.capabilities import supports_vision
172
+
173
+ vision_ok = supports_vision(binding, chat_model)
174
+ except Exception:
175
+ vision_ok = False
176
+ return _finalize(
177
+ [
178
+ _check(
179
+ "package",
180
+ "LightRAG package installed",
181
+ installed,
182
+ package_detail,
183
+ ),
184
+ _check(
185
+ "chat",
186
+ "Active chat model",
187
+ bool(chat_model),
188
+ chat_model or "Configure one in the model catalog.",
189
+ ),
190
+ _check(
191
+ "embedding",
192
+ "Active embedding model",
193
+ bool(emb_model) and emb_dim > 0,
194
+ f"{emb_model} · {emb_dim}d" if emb_model else "Configure one in the model catalog.",
195
+ ),
196
+ _check(
197
+ "vision",
198
+ "Vision model for multimodal",
199
+ vision_ok,
200
+ "Active chat model supports vision."
201
+ if vision_ok
202
+ else "Active chat model has no vision — image analysis is disabled.",
203
+ optional=True,
204
+ ),
205
+ ]
206
+ )
207
+
208
+
209
+ def _lightrag_server_preflight() -> dict:
210
+ try:
211
+ from deeptutor.services.config import load_lightrag_server_settings
212
+
213
+ settings = load_lightrag_server_settings()
214
+ server_url = str(settings.get("server_url") or "").strip()
215
+ except Exception:
216
+ server_url = ""
217
+ return _finalize(
218
+ [
219
+ _check(
220
+ "server_url",
221
+ "Default server configured",
222
+ bool(server_url),
223
+ server_url or "Add a server URL under Connection defaults.",
224
+ )
225
+ ]
226
+ )
227
+
228
+
229
+ def _ima_preflight() -> dict:
230
+ try:
231
+ from .pipelines.ima.config import get_account_credentials
232
+
233
+ credentials = get_account_credentials()
234
+ except Exception:
235
+ from .pipelines.ima.config import ImaCredentials
236
+
237
+ credentials = ImaCredentials()
238
+ return _finalize(
239
+ [
240
+ _check(
241
+ "credentials",
242
+ "IMA Client ID and API key configured",
243
+ credentials.complete,
244
+ credentials.client_id
245
+ if credentials.complete
246
+ else "Add them under Credentials, or supply a pair per knowledge base "
247
+ "when connecting one.",
248
+ )
249
+ ]
250
+ )
251
+
252
+
253
+ def _finalize(checks: list[dict]) -> dict:
254
+ ok = all(c["ok"] for c in checks if not c["optional"])
255
+ return {"ok": ok, "checks": checks}
256
+
257
+
258
+ _PREFLIGHTS = {
259
+ DEFAULT_PROVIDER: _llamaindex_preflight,
260
+ PAGEINDEX_PROVIDER: _pageindex_preflight,
261
+ PAGEINDEX_OSS_PROVIDER: _pageindex_oss_preflight,
262
+ GRAPHRAG_PROVIDER: _graphrag_preflight,
263
+ LIGHTRAG_PROVIDER: _lightrag_preflight,
264
+ LIGHTRAG_SERVER_PROVIDER: _lightrag_server_preflight,
265
+ IMA_PROVIDER: _ima_preflight,
266
+ }
267
+
268
+
269
+ def engine_preflight(provider: str) -> dict[str, Any]:
270
+ """Run the requirement checks for ``provider`` and return the report."""
271
+ return _PREFLIGHTS[normalize_provider_name(provider)]()
272
+
273
+
274
+ __all__ = ["engine_preflight"]
@@ -0,0 +1,70 @@
1
+ """Resolve a knowledge base's bound RAG provider.
2
+
3
+ DeepTutor owns the knowledge-base lifecycle and stores the authoritative
4
+ provider binding in ``kb_config.json``. Older KBs may only have
5
+ ``metadata.json``, so metadata remains a legacy fallback. Retrieval and
6
+ incremental indexing should use this helper instead of hand-reading either file
7
+ directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from deeptutor.services.rag.factory import DEFAULT_PROVIDER, normalize_provider_name
17
+
18
+
19
+ def load_kb_config_entry(kb_base_dir: str | Path, kb_name: str) -> dict[str, Any]:
20
+ """Return the raw ``kb_config.json`` entry for ``kb_name``, if present."""
21
+ config_path = Path(kb_base_dir) / "kb_config.json"
22
+ if not config_path.exists():
23
+ return {}
24
+ try:
25
+ with open(config_path, encoding="utf-8") as handle:
26
+ entry = json.load(handle).get("knowledge_bases", {}).get(kb_name, {})
27
+ return entry if isinstance(entry, dict) else {}
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def load_metadata_provider(kb_base_dir: str | Path, kb_name: str) -> str | None:
33
+ """Return the provider stored in legacy ``metadata.json``, if present."""
34
+ metadata_path = Path(kb_base_dir) / kb_name / "metadata.json"
35
+ if not metadata_path.exists():
36
+ return None
37
+ try:
38
+ with open(metadata_path, encoding="utf-8") as handle:
39
+ provider = json.load(handle).get("rag_provider")
40
+ except Exception:
41
+ return None
42
+ return normalize_provider_name(provider) if provider else None
43
+
44
+
45
+ def resolve_bound_provider(kb_base_dir: str | Path, kb_name: str | None) -> str:
46
+ """Resolve the provider DeepTutor has bound to a KB.
47
+
48
+ Order:
49
+ 1. ``kb_config.json``: authoritative runtime state.
50
+ 2. ``metadata.json``: legacy/import fallback.
51
+ 3. default provider.
52
+ """
53
+ if kb_name:
54
+ entry = load_kb_config_entry(kb_base_dir, kb_name)
55
+ provider = entry.get("rag_provider")
56
+ if provider:
57
+ return normalize_provider_name(provider)
58
+
59
+ metadata_provider = load_metadata_provider(kb_base_dir, kb_name)
60
+ if metadata_provider:
61
+ return metadata_provider
62
+
63
+ return DEFAULT_PROVIDER
64
+
65
+
66
+ __all__ = [
67
+ "load_kb_config_entry",
68
+ "load_metadata_provider",
69
+ "resolve_bound_provider",
70
+ ]
@@ -0,0 +1,186 @@
1
+ """Single-query rewrite for retrieval.
2
+
3
+ Rewrites a conversational or anaphoric user query into one self-contained,
4
+ keyword-leaning search query before it reaches the KB pipeline. The layer is
5
+ fail-open by design: any LLM error, timeout, or empty output falls back to
6
+ the original query so retrieval never fails because of a rewrite.
7
+
8
+ Only the ``standalone`` strategy exists in v1 (one LLM call, one query).
9
+ ``multi_query`` is reserved for a follow-up PR; the settings schema already
10
+ carries the ``strategy`` field so no migration is needed later.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import dataclass
18
+ import logging
19
+ import time
20
+ from typing import Any, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ #: Rewrite is disabled / was forced off.
25
+ STRATEGY_OFF = "off"
26
+ #: Single self-contained query produced by one LLM call.
27
+ STRATEGY_STANDALONE = "standalone"
28
+
29
+ #: Strategies ``maybe_rewrite`` accepts (``off`` forces the pass-through).
30
+ KNOWN_STRATEGIES = (STRATEGY_OFF, STRATEGY_STANDALONE)
31
+
32
+ _SYSTEM_PROMPT = (
33
+ "You rewrite a user question into a single self-contained search query "
34
+ "for a document retrieval engine. Keep the original language (never "
35
+ "translate). Resolve pronouns and conversational context into explicit "
36
+ "keywords. Output ONLY the rewritten query, no quotes, no explanation."
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RewriteOutcome:
42
+ """What the rewrite layer did to one query."""
43
+
44
+ original: str
45
+ rewritten: str
46
+ strategy: str
47
+ latency_ms: float = 0.0
48
+ error: str = ""
49
+
50
+ @property
51
+ def applied(self) -> bool:
52
+ """True when the query sent to the engine differs from the input."""
53
+ return bool(self.rewritten) and self.rewritten != self.original
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Serializable form attached to search results and eval reports."""
57
+ return {
58
+ "original": self.original,
59
+ "rewritten": self.rewritten,
60
+ "strategy": self.strategy,
61
+ "latency_ms": round(self.latency_ms, 1),
62
+ "error": self.error,
63
+ }
64
+
65
+
66
+ def _clean_rewritten(raw: str) -> str:
67
+ """Take the first non-empty line of LLM output, minus wrapping quotes."""
68
+ for line in (raw or "").splitlines():
69
+ text = line.strip().strip("\"'“”‘’")
70
+ if text:
71
+ return text
72
+ return ""
73
+
74
+
75
+ async def rewrite_query(
76
+ query: str,
77
+ *,
78
+ settings: dict[str, Any] | None = None,
79
+ complete_fn: Callable[..., Awaitable[str]] | None = None,
80
+ ) -> RewriteOutcome:
81
+ """Rewrite ``query`` per ``settings``; always fail open to the original."""
82
+ original = query if isinstance(query, str) else ""
83
+ started = time.monotonic()
84
+ strategy = STRATEGY_STANDALONE
85
+ try:
86
+ cfg = settings or {}
87
+ strategy = str(cfg.get("strategy") or STRATEGY_STANDALONE)
88
+ if not bool(cfg.get("enabled", False)) or strategy == STRATEGY_OFF:
89
+ return RewriteOutcome(original=original, rewritten=original, strategy=STRATEGY_OFF)
90
+ min_chars = int(cfg.get("min_query_chars") or 0)
91
+ if len(original.strip()) < max(0, min_chars):
92
+ return RewriteOutcome(original=original, rewritten=original, strategy=strategy)
93
+ timeout = float(cfg.get("timeout_seconds") or 5.0)
94
+ model = str(cfg.get("model") or "").strip() or None
95
+ complete = complete_fn
96
+ if complete is None:
97
+ from deeptutor.services.llm import complete as _default_complete
98
+
99
+ complete = _default_complete
100
+ if model is not None:
101
+ raw = await asyncio.wait_for(
102
+ complete(query, system_prompt=_SYSTEM_PROMPT, model=model),
103
+ timeout=timeout,
104
+ )
105
+ else:
106
+ raw = await asyncio.wait_for(
107
+ complete(query, system_prompt=_SYSTEM_PROMPT),
108
+ timeout=timeout,
109
+ )
110
+ rewritten = _clean_rewritten(raw)
111
+ except Exception as exc:
112
+ logger.warning("Query rewrite failed (%s); using the original query", type(exc).__name__)
113
+ return RewriteOutcome(
114
+ original=original,
115
+ rewritten=original,
116
+ strategy=strategy,
117
+ latency_ms=(time.monotonic() - started) * 1000.0,
118
+ error=f"{type(exc).__name__}: {exc}",
119
+ )
120
+
121
+ latency_ms = (time.monotonic() - started) * 1000.0
122
+ if not rewritten:
123
+ return RewriteOutcome(
124
+ original=original,
125
+ rewritten=original,
126
+ strategy=strategy,
127
+ latency_ms=latency_ms,
128
+ error="empty_rewrite",
129
+ )
130
+ return RewriteOutcome(
131
+ original=original,
132
+ rewritten=rewritten,
133
+ strategy=strategy,
134
+ latency_ms=latency_ms,
135
+ )
136
+
137
+
138
+ async def maybe_rewrite(
139
+ query: str,
140
+ *,
141
+ rewrite: bool | None = None,
142
+ settings: dict[str, Any] | None = None,
143
+ complete_fn: Callable[..., Awaitable[str]] | None = None,
144
+ ) -> tuple[str, Optional[RewriteOutcome]]:
145
+ """Return ``(effective_query, outcome)`` for :meth:`RAGService.search`.
146
+
147
+ ``rewrite`` is the per-call override: True forces the rewrite on, False
148
+ forces it off, None follows the runtime settings. Returns ``(query, None)``
149
+ when the layer is off so callers only attach provenance when active.
150
+ """
151
+ if rewrite is False:
152
+ return query, None
153
+ try:
154
+ cfg = settings
155
+ if cfg is None:
156
+ from deeptutor.services.config import get_runtime_settings_service
157
+
158
+ cfg = get_runtime_settings_service().load_query_rewrite()
159
+ cfg = dict(cfg)
160
+ if rewrite is not None:
161
+ cfg["enabled"] = bool(rewrite)
162
+ except Exception as exc:
163
+ logger.warning(
164
+ "Query rewrite settings failed (%s); using the original query", type(exc).__name__
165
+ )
166
+ return query, RewriteOutcome(
167
+ original=query,
168
+ rewritten=query,
169
+ strategy=STRATEGY_OFF,
170
+ error=f"{type(exc).__name__}: {exc}",
171
+ )
172
+
173
+ if not bool(cfg.get("enabled", False)):
174
+ return query, None
175
+ outcome = await rewrite_query(query, settings=cfg, complete_fn=complete_fn)
176
+ return outcome.rewritten, outcome
177
+
178
+
179
+ __all__ = [
180
+ "KNOWN_STRATEGIES",
181
+ "STRATEGY_OFF",
182
+ "STRATEGY_STANDALONE",
183
+ "RewriteOutcome",
184
+ "maybe_rewrite",
185
+ "rewrite_query",
186
+ ]