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,278 @@
1
+ """HTTP bridge for MarginNote 4 Add-on devices.
2
+
3
+ The MN4 Add-on (JavaScript running inside MarginNote 4) calls these endpoints
4
+ to pair with this DeepTutor instance and push synced study data.
5
+
6
+ Authentication layers:
7
+ * ``/pair``, ``/devices``, ``/status`` -- DeepTutor session auth (the logged-in
8
+ user manages their own devices). The router is mounted with ``_auth`` in
9
+ ``main.py``.
10
+ * ``/sync``, ``/heartbeat`` -- device-token auth via
11
+ ``Authorization: MarginNote <device_id>:<token>``. The Add-on stores the
12
+ token received at pairing time.
13
+
14
+ Phase 1 scope: device pairing, incremental sync, heartbeat. Write-back to MN4
15
+ (propose / apply / verify) is Phase 2.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
25
+ from pydantic import BaseModel, Field
26
+
27
+ from deeptutor.api.routers.auth import require_auth
28
+ from deeptutor.capabilities.marginnote4.models import MarginNoteObject, SyncBatch
29
+ from deeptutor.capabilities.marginnote4.store import MarginNoteStore, resolve_db_path
30
+ from deeptutor.services.path_service import PathService
31
+
32
+ logger = logging.getLogger(__name__)
33
+ router = APIRouter()
34
+ _auth = [Depends(require_auth)]
35
+
36
+ # Upper bound on objects (and tombstones) accepted from one sync request.
37
+ MAX_SYNC_BATCH = 2000
38
+
39
+
40
+ def _requested_kb(request: Request) -> str:
41
+ """The MN4 library this request addresses.
42
+
43
+ In Phase 1 a single default store serves all devices; ``X-MN4-KB`` selects
44
+ a dedicated database instead. The header is only a *selector* — the device
45
+ token is the credential, and it is checked against whichever store the
46
+ header names, so naming another library grants nothing.
47
+ """
48
+ return request.headers.get("x-mn4-kb", "default")
49
+
50
+
51
+ def _device_db_path(kb_name: str) -> Path:
52
+ """The database the device-token endpoints address.
53
+
54
+ Those endpoints carry no session, so ``get_path_service()`` would hand them
55
+ whatever the ambient context happens to be — the default workspace in
56
+ practice. Naming it explicitly keeps the sync path from drifting away from
57
+ the store pairing wrote to, and gives :func:`pair_device` something to
58
+ check itself against.
59
+ """
60
+ return resolve_db_path(kb_name, path_service=PathService.get_instance())
61
+
62
+
63
+ def _store_for(request: Request) -> MarginNoteStore:
64
+ """Resolve (creating if absent) the store for a session-authenticated call."""
65
+ return MarginNoteStore(resolve_db_path(_requested_kb(request)))
66
+
67
+
68
+ def _auth_device(request: Request, authorization: str | None) -> tuple[str, MarginNoteStore]:
69
+ """Validate the device token and return ``(device_id, store)``.
70
+
71
+ Reached with no session, so nothing here may create state from
72
+ caller-supplied input: ``open_existing`` keeps an unauthenticated request
73
+ from materialising a directory and a schema'd database per distinct
74
+ ``X-MN4-KB`` value.
75
+ """
76
+ if not authorization or not authorization.startswith("MarginNote "):
77
+ raise HTTPException(401, "Missing or malformed Authorization header.")
78
+ raw = authorization[len("MarginNote ") :]
79
+ if ":" not in raw:
80
+ raise HTTPException(401, "Invalid Authorization format.")
81
+ device_id, token = raw.split(":", 1)
82
+ store = MarginNoteStore.open_existing(_device_db_path(_requested_kb(request)))
83
+ if store is None or not store.verify_token(device_id, token):
84
+ raise HTTPException(403, "Invalid device credentials.")
85
+ store.touch_device(device_id)
86
+ return device_id, store
87
+
88
+
89
+ # -- request / response models ---------------------------------------------
90
+
91
+
92
+ class PairRequest(BaseModel):
93
+ device_name: str = Field("", max_length=128)
94
+ device_kind: str = Field("macos", max_length=32)
95
+
96
+
97
+ class PairResponse(BaseModel):
98
+ device_id: str
99
+ token: str
100
+ device_name: str
101
+ device_kind: str
102
+
103
+
104
+ class SyncObjectIn(BaseModel):
105
+ object_id: str
106
+ object_type: str
107
+ title: str = ""
108
+ content: str = ""
109
+ excerpt: str | None = None
110
+ document_id: str | None = None
111
+ document_title: str | None = None
112
+ page: int | None = None
113
+ tags: list[str] = Field(default_factory=list)
114
+ links: list[str] = Field(default_factory=list)
115
+ color: str | None = None
116
+ created_at: str = ""
117
+ updated_at: str = ""
118
+ raw: dict[str, Any] = Field(default_factory=dict)
119
+
120
+
121
+ class SyncRequest(BaseModel):
122
+ # One batch, not one library: the Add-on pages through its backlog, so an
123
+ # unbounded list only ever meant a token holder could pin the event loop
124
+ # and the database on a single request.
125
+ cursor: str = Field("", max_length=256)
126
+ objects: list[SyncObjectIn] = Field(default_factory=list, max_length=MAX_SYNC_BATCH)
127
+ deleted_ids: list[str] = Field(default_factory=list, max_length=MAX_SYNC_BATCH)
128
+
129
+
130
+ class SyncResponse(BaseModel):
131
+ stored: int
132
+ updated: int
133
+ deleted: int
134
+ new_cursor: str
135
+
136
+
137
+ class DeviceInfo(BaseModel):
138
+ device_id: str
139
+ device_name: str
140
+ device_kind: str
141
+ paired_at: str
142
+ last_seen: str
143
+ active: bool
144
+
145
+
146
+ # -- session-authenticated endpoints (DeepTutor user) -----------------------
147
+
148
+
149
+ @router.post("/pair", response_model=PairResponse, dependencies=_auth)
150
+ async def pair_device(body: PairRequest, request: Request) -> PairResponse:
151
+ """Pair a new MN4 device. Requires a DeepTutor session.
152
+
153
+ Returns a one-time token the Add-on stores and presents on every sync.
154
+ """
155
+ kb_name = _requested_kb(request)
156
+ if resolve_db_path(kb_name) != _device_db_path(kb_name):
157
+ # Pairing runs under a session and resolves the caller's own
158
+ # workspace; /sync does not and resolves the default one. Where those
159
+ # differ, pairing would hand out a token that 403s on every sync
160
+ # forever, so refuse instead of issuing a dead credential.
161
+ raise HTTPException(
162
+ 501,
163
+ "MN4 device sync is not available for this account yet: pairing and "
164
+ "sync would resolve different workspaces.",
165
+ )
166
+ store = _store_for(request)
167
+ device, token = store.pair_device(device_name=body.device_name, device_kind=body.device_kind)
168
+ logger.info("Paired MN4 device %s (%s)", device.device_id, device.device_name)
169
+ return PairResponse(
170
+ device_id=device.device_id,
171
+ token=token,
172
+ device_name=device.device_name,
173
+ device_kind=device.device_kind,
174
+ )
175
+
176
+
177
+ @router.get("/devices", response_model=list[DeviceInfo], dependencies=_auth)
178
+ async def list_devices(request: Request) -> list[DeviceInfo]:
179
+ """List all paired devices."""
180
+ store = _store_for(request)
181
+ return [
182
+ DeviceInfo(
183
+ device_id=d.device_id,
184
+ device_name=d.device_name,
185
+ device_kind=d.device_kind,
186
+ paired_at=d.paired_at,
187
+ last_seen=d.last_seen,
188
+ active=d.active,
189
+ )
190
+ for d in store.list_devices()
191
+ ]
192
+
193
+
194
+ @router.delete("/devices/{device_id}", dependencies=_auth)
195
+ async def revoke_device(device_id: str, request: Request) -> dict[str, str]:
196
+ """Revoke a paired device."""
197
+ store = _store_for(request)
198
+ if not store.revoke_device(device_id):
199
+ raise HTTPException(404, f"Device {device_id} not found.")
200
+ return {"status": "revoked", "device_id": device_id}
201
+
202
+
203
+ @router.get("/status", dependencies=_auth)
204
+ async def status(request: Request) -> dict[str, Any]:
205
+ """Health check and summary stats."""
206
+ store = _store_for(request)
207
+ return {
208
+ "status": "ok",
209
+ "devices": len(store.list_devices()),
210
+ "objects": store.count(),
211
+ }
212
+
213
+
214
+ # -- device-token-authenticated endpoints (MN4 Add-on) ---------------------
215
+
216
+
217
+ @router.post("/sync", response_model=SyncResponse)
218
+ async def sync_objects(
219
+ body: SyncRequest,
220
+ request: Request,
221
+ authorization: str | None = Header(None),
222
+ ) -> SyncResponse:
223
+ """Receive an incremental sync batch from a paired MN4 device."""
224
+ device_id, store = _auth_device(request, authorization)
225
+ objects = [
226
+ MarginNoteObject(
227
+ object_id=o.object_id,
228
+ object_type=o.object_type,
229
+ title=o.title,
230
+ content=o.content,
231
+ excerpt=o.excerpt,
232
+ document_id=o.document_id,
233
+ document_title=o.document_title,
234
+ page=o.page,
235
+ tags=o.tags,
236
+ links=o.links,
237
+ color=o.color,
238
+ created_at=o.created_at,
239
+ updated_at=o.updated_at,
240
+ device_id=device_id,
241
+ raw=o.raw,
242
+ )
243
+ for o in body.objects
244
+ ]
245
+ batch = SyncBatch(
246
+ device_id=device_id,
247
+ cursor=body.cursor,
248
+ objects=objects,
249
+ deleted_ids=body.deleted_ids,
250
+ )
251
+ result = store.ingest(batch)
252
+ logger.info(
253
+ "MN4 sync from %s: +%d ~%d -%d",
254
+ device_id,
255
+ result.stored,
256
+ result.updated,
257
+ result.deleted,
258
+ )
259
+ return SyncResponse(
260
+ stored=result.stored,
261
+ updated=result.updated,
262
+ deleted=result.deleted,
263
+ new_cursor=result.new_cursor,
264
+ )
265
+
266
+
267
+ @router.post("/heartbeat")
268
+ async def heartbeat(
269
+ request: Request,
270
+ authorization: str | None = Header(None),
271
+ ) -> dict[str, Any]:
272
+ """Lightweight liveness check. Updates last_seen for the device."""
273
+ device_id, store = _auth_device(request, authorization)
274
+ return {
275
+ "status": "ok",
276
+ "device_id": device_id,
277
+ "object_count": store.count(device_id=device_id),
278
+ }
@@ -0,0 +1,6 @@
1
+ """IMA loop capability — inventory, source reading and authoring over a live library."""
2
+
3
+ from deeptutor.capabilities.ima.capability import ImaCapability
4
+ from deeptutor.capabilities.ima.tools import IMA_TOOL_NAMES, IMA_TOOL_TYPES
5
+
6
+ __all__ = ["IMA_TOOL_NAMES", "IMA_TOOL_TYPES", "ImaCapability"]
@@ -0,0 +1,118 @@
1
+ """Resolve which connected Tencent IMA libraries the current turn can reach.
2
+
3
+ The binding is derived once per turn from the user's selected knowledge bases:
4
+ every selection whose KB metadata is ``type == ima`` becomes a library the IMA
5
+ tools may operate on. Unlike the Obsidian binding (one live vault per turn) all
6
+ selected IMA libraries are kept, because a turn can legitimately browse one and
7
+ read from another — the tools take an explicit ``kb_name`` when there is more
8
+ than one.
9
+
10
+ Credentials are deliberately *not* part of a binding. What a tool call carries is
11
+ the KB reference; the credential pair is loaded from ``kb_config.json`` at call
12
+ time by :func:`resolve_client`, which also re-runs the per-user access check. So
13
+ no secret is ever placed in tool kwargs (where a trace or a log could pick it up),
14
+ and a model cannot reach a library by naming one that was not selected.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from deeptutor.core.context import UnifiedContext
22
+ from deeptutor.knowledge.kb_types import IMA_KB_TYPE
23
+
24
+ # Cached per extension: a tuple of bindings, empty once we have looked and
25
+ # found none. Absence of the key means "not resolved yet".
26
+ _CACHE_KEY = "_ima_bindings"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ImaBinding:
31
+ """One selected knowledge base that points at a Tencent IMA library."""
32
+
33
+ kb_ref: str
34
+ """The reference as the user selected it (name or id) — what tools pass back."""
35
+
36
+ name: str
37
+ """Display name, used to match a model-supplied ``kb_name``."""
38
+
39
+ knowledge_base_id: str
40
+ """Which IMA library this KB reads. Not a secret (it is shown in the UI)."""
41
+
42
+
43
+ def ima_bindings(context: UnifiedContext) -> tuple[ImaBinding, ...]:
44
+ """Every connected IMA library among this turn's selected knowledge bases."""
45
+ state = context.extension("ima")
46
+ cached = state.get(_CACHE_KEY)
47
+ if cached is not None:
48
+ return tuple(cached)
49
+ resolved = _resolve(context)
50
+ state[_CACHE_KEY] = resolved
51
+ return resolved
52
+
53
+
54
+ def select_binding(
55
+ bindings: tuple[ImaBinding, ...],
56
+ kb_name: str | None = None,
57
+ ) -> ImaBinding | None:
58
+ """The binding a tool call targets, chosen from what the turn made available.
59
+
60
+ With one selected library the argument is optional (and ignored when it does
61
+ not match, since there is no ambiguity to resolve). With several, an
62
+ unmatched name resolves to ``None`` so the tool can ask for a valid one
63
+ instead of silently operating on the wrong library. A pure function over the
64
+ turn's bindings — the model can only ever reach a library that was selected.
65
+ """
66
+ if not bindings:
67
+ return None
68
+ requested = str(kb_name or "").strip()
69
+ if not requested:
70
+ return bindings[0] if len(bindings) == 1 else None
71
+ for binding in bindings:
72
+ if requested in {binding.kb_ref, binding.name, binding.knowledge_base_id}:
73
+ return binding
74
+ return bindings[0] if len(bindings) == 1 else None
75
+
76
+
77
+ def resolve_client(kb_ref: str, *, for_write: bool = False):
78
+ """An :class:`ImaClient` for *kb_ref*, after re-checking the user's access.
79
+
80
+ Raises ``ImaNotConfiguredError`` when the KB (or the account settings) lacks
81
+ a complete credential pair, and ``fastapi.HTTPException`` when the reference
82
+ is not accessible to the current user — writes require write access, since
83
+ they modify the user's own IMA library.
84
+ """
85
+ from deeptutor.multi_user.knowledge_access import resolve_kb
86
+ from deeptutor.services.rag.pipelines.ima.client import ImaClient
87
+ from deeptutor.services.rag.pipelines.ima.config import resolve_kb_config
88
+ from deeptutor.services.rag.provider_binding import load_kb_config_entry
89
+
90
+ resource = resolve_kb(str(kb_ref), require_write=for_write)
91
+ entry = load_kb_config_entry(resource.base_dir, resource.name)
92
+ return ImaClient(resolve_kb_config(entry))
93
+
94
+
95
+ def _resolve(context: UnifiedContext) -> tuple[ImaBinding, ...]:
96
+ from deeptutor.multi_user.knowledge_access import resolve_kb_metadata
97
+
98
+ bindings: list[ImaBinding] = []
99
+ seen: set[str] = set()
100
+ for raw in context.knowledge_bases or []:
101
+ ref = str(raw).strip()
102
+ if not ref or ref in seen:
103
+ continue
104
+ seen.add(ref)
105
+ meta = resolve_kb_metadata(ref)
106
+ if not meta or meta.get("type") != IMA_KB_TYPE:
107
+ continue
108
+ bindings.append(
109
+ ImaBinding(
110
+ kb_ref=ref,
111
+ name=str(meta.get("name") or ref),
112
+ knowledge_base_id=str(meta.get("knowledge_base_id") or ""),
113
+ )
114
+ )
115
+ return tuple(bindings)
116
+
117
+
118
+ __all__ = ["ImaBinding", "ima_bindings", "resolve_client", "select_binding"]
@@ -0,0 +1,92 @@
1
+ """IMA loop capability — inventory, source reading and authoring over a live library.
2
+
3
+ Active whenever one of the turn's selected knowledge bases is a connected Tencent
4
+ IMA library (resolved by :mod:`deeptutor.capabilities.ima.binding`).
5
+
6
+ Unlike the Obsidian and subagent capabilities this is a *plain*
7
+ :class:`~deeptutor.capabilities.protocol.LoopExtension`, not a
8
+ :class:`~deeptutor.capabilities.protocol.KnowledgeCapability`: it **adds** its
9
+ tools to chat's normal surface instead of replacing it. That difference is not
10
+ stylistic — it follows from what the KB actually is. An Obsidian vault has no
11
+ index at all, so ``rag`` is useless against it and the capability must own the
12
+ turn. An IMA library is genuinely searchable over HTTP: the ``ima`` RAG provider
13
+ answers ``rag`` calls for it. Retrieval therefore stays where it belongs, and
14
+ these tools add only what retrieval cannot do — enumerate the library, read a
15
+ whole source, search notes by recency, and write back.
16
+
17
+ Keeping the surface additive also means a turn that attaches an IMA library
18
+ alongside other knowledge bases (or that needs web search) does not lose those
19
+ abilities, which an exclusive capability would strip.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from importlib import resources
25
+ from typing import Any
26
+
27
+ from deeptutor.capabilities.ima.binding import ima_bindings
28
+ from deeptutor.capabilities.ima.tools import BINDINGS_KWARG, IMA_TOOL_NAMES
29
+ from deeptutor.capabilities.protocol import PromptBlock
30
+ from deeptutor.core.context import UnifiedContext
31
+ from deeptutor.services.prompt.language import is_chinese as _is_zh
32
+ from deeptutor.services.prompt.lookup import prompt_text as _prompt_text
33
+
34
+
35
+ class ImaCapability:
36
+ """Turn-scoped integration for connected Tencent IMA knowledge bases."""
37
+
38
+ name = "ima"
39
+ owned_tools = IMA_TOOL_NAMES
40
+
41
+ def is_active(self, context: UnifiedContext) -> bool:
42
+ return bool(ima_bindings(context))
43
+
44
+ def system_block(
45
+ self,
46
+ context: UnifiedContext,
47
+ *,
48
+ language: str,
49
+ prompts: dict[str, Any],
50
+ ) -> PromptBlock | None:
51
+ bindings = ima_bindings(context)
52
+ if not bindings:
53
+ return None
54
+ override = _prompt_text(prompts, ("ima", "system"))
55
+ content = override or _load_system_prompt(language)
56
+ names = (
57
+ "、".join(binding.name for binding in bindings)
58
+ if _is_zh(language)
59
+ else ", ".join(binding.name for binding in bindings)
60
+ )
61
+ return PromptBlock("ima", content.replace("{kb_names}", names))
62
+
63
+ def augment_kwargs(
64
+ self,
65
+ tool_name: str,
66
+ kwargs: dict[str, Any],
67
+ context: UnifiedContext,
68
+ ) -> dict[str, Any]:
69
+ if tool_name not in IMA_TOOL_NAMES:
70
+ return kwargs
71
+ bindings = ima_bindings(context)
72
+ if not bindings:
73
+ return kwargs
74
+ # Server-owned: overwrite any model-supplied value so a turn can only
75
+ # reach the libraries the user actually attached. Credentials are not
76
+ # part of this payload — the tools load them per call.
77
+ updated = dict(kwargs)
78
+ updated[BINDINGS_KWARG] = bindings
79
+ return updated
80
+
81
+ def pre_loop_seed(self, context: UnifiedContext) -> str:
82
+ _ = context
83
+ return ""
84
+
85
+
86
+ def _load_system_prompt(language: str) -> str:
87
+ lang = "zh" if _is_zh(language) else "en"
88
+ prompt = resources.files(__package__).joinpath("prompts", lang, "system.md")
89
+ return prompt.read_text(encoding="utf-8").strip()
90
+
91
+
92
+ __all__ = ["ImaCapability"]
@@ -0,0 +1,33 @@
1
+ # Tencent IMA Library
2
+
3
+ The user has attached their Tencent IMA knowledge base **{kb_names}** to this
4
+ turn. Its documents live in IMA, not on this machine: everything below is an API
5
+ call against the user's own library. Your normal tools stay available — the IMA
6
+ tools are additional.
7
+
8
+ ## Which tool answers which question
9
+
10
+ - **"What does the material say about X?"** → `rag` with this knowledge base.
11
+ That is IMA's semantic search, and it stays the way to find content.
12
+ - **"What's in here?" / "Is document X in it?" / "What's in that folder?"** →
13
+ `ima_list`. Retrieval only reports what a query happened to match, so it can
14
+ never establish what the library contains. Never infer the inventory from
15
+ retrieved passages, and never claim the document list cannot be read.
16
+ - **"Give me the whole document" / a retrieved snippet is too thin** →
17
+ `ima_read` with the item's `media_id` (from `ima_list` or a citation).
18
+ - **"My latest notes" / "what did I write about X?"** → `ima_note_search`.
19
+ Notes carry created/updated timestamps; knowledge-base documents do not, so
20
+ recency questions about documents can only be answered by the order `ima_list`
21
+ returns them in — say so rather than inventing dates.
22
+
23
+ ## Writing (only when asked)
24
+
25
+ `ima_add_url` collects web pages into the library and `ima_write_note` saves a
26
+ note. Both modify the user's own IMA account, so use them only when the user
27
+ asked to save, collect, or record something — never as a side effect of
28
+ answering. Appending to an existing note cannot be undone: only append to a note
29
+ the user actually named, otherwise create a new one. Nothing you can call
30
+ deletes or overwrites their material.
31
+
32
+ Answer grounded in what you read, citing document or note titles. If the library
33
+ does not cover something, say so instead of guessing.
@@ -0,0 +1,24 @@
1
+ # 腾讯 IMA 知识库
2
+
3
+ 用户已把腾讯 IMA 知识库 **{kb_names}** 挂载到本轮对话。它的文档存放在 IMA 而不在本机,
4
+ 下面每一项都是对用户自己那个库的接口调用。你原有的工具依然可用,IMA 工具是额外增加的。
5
+
6
+ ## 什么问题用什么工具
7
+
8
+ - **「资料里怎么说 X」** → 对该知识库调用 `rag`。那就是 IMA 的语义检索,找内容仍然走它。
9
+ - **「这里面有什么」「有没有某个文档」「那个文件夹里是什么」** → `ima_list`。
10
+ 检索只能反映某次查询命中了什么,永远无法说明库里到底有哪些东西。
11
+ 不要用检索到的片段去推断文档构成,也不要声称读不到文档清单。
12
+ - **「把整篇给我」或检索片段太薄** → 用条目的 `media_id`(来自 `ima_list` 或引用)调 `ima_read`。
13
+ - **「我最近的笔记」「我写过什么关于 X 的」** → `ima_note_search`。
14
+ 笔记带创建/更新时间,知识库文档没有时间字段;因此关于文档「最新」的问题只能依据
15
+ `ima_list` 的返回顺序,如实说明,不要编造日期。
16
+
17
+ ## 写入(仅在用户要求时)
18
+
19
+ `ima_add_url` 把网页收进知识库,`ima_write_note` 保存笔记。两者都会改动用户自己的 IMA 账号,
20
+ 所以只有在用户明确要求保存、收藏、记录时才使用,绝不作为回答的副作用。
21
+ 往已有笔记追加内容不可撤销:只追加到用户明确指名的那篇,否则新建一篇。
22
+ 你能调用的工具都不会删除或覆盖用户的资料。
23
+
24
+ 回答要基于你读到的内容,并标注文档或笔记标题。库里没有覆盖的内容,如实说明,不要猜。