deeptutor-knowledge 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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
|
+
回答要基于你读到的内容,并标注文档或笔记标题。库里没有覆盖的内容,如实说明,不要猜。
|