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,348 @@
|
|
|
1
|
+
"""Typed views over IMA's wire payloads, and the parsers that produce them.
|
|
2
|
+
|
|
3
|
+
Every IMA list response is a bag of loosely-typed dicts whose field names differ
|
|
4
|
+
between the documented reference and live responses, and whose entries mix
|
|
5
|
+
*documents* with *folders* — ``get_knowledge_list`` returns both by design, and
|
|
6
|
+
``search_knowledge`` also matches folder names. Parsing that in the client would
|
|
7
|
+
scatter the same defensive ``str(raw.get(...) or "")`` over every method and let
|
|
8
|
+
a folder leak into retrieval results as a document with no content (which is how
|
|
9
|
+
a folder used to end up cited as a source).
|
|
10
|
+
|
|
11
|
+
So the wire shapes are decoded exactly once, here:
|
|
12
|
+
|
|
13
|
+
* :func:`parse_knowledge_page` splits a page into documents and folders,
|
|
14
|
+
discarding entries that identify as neither;
|
|
15
|
+
* the notes payloads are deeply nested (``docs[].doc.basic_info.docid``), which
|
|
16
|
+
:func:`parse_note` flattens into something a tool can render directly.
|
|
17
|
+
|
|
18
|
+
These types carry no behaviour and no I/O, so the client, the inventory reader,
|
|
19
|
+
the retrieval pipeline and the capability's tools can all share them without a
|
|
20
|
+
cycle.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Mapping
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ImaDocument:
|
|
31
|
+
"""One document in a knowledge base (a file, a web page, or a linked note)."""
|
|
32
|
+
|
|
33
|
+
media_id: str
|
|
34
|
+
title: str
|
|
35
|
+
parent_folder_id: str = ""
|
|
36
|
+
highlight: str = ""
|
|
37
|
+
"""Matched snippet — search results only; empty for a plain listing."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ImaFolder:
|
|
42
|
+
"""One folder in a knowledge base's tree."""
|
|
43
|
+
|
|
44
|
+
folder_id: str
|
|
45
|
+
name: str
|
|
46
|
+
file_number: int = 0
|
|
47
|
+
folder_number: int = 0
|
|
48
|
+
parent_folder_id: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class ImaKnowledgePage:
|
|
53
|
+
"""One cursor page of a knowledge base's contents."""
|
|
54
|
+
|
|
55
|
+
documents: tuple[ImaDocument, ...] = ()
|
|
56
|
+
folders: tuple[ImaFolder, ...] = ()
|
|
57
|
+
path: tuple[str, ...] = ()
|
|
58
|
+
"""Breadcrumb folder names for the listed location, outermost first."""
|
|
59
|
+
|
|
60
|
+
next_cursor: str = ""
|
|
61
|
+
is_end: bool = False
|
|
62
|
+
unverified_documents: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ImaKnowledgeBase:
|
|
67
|
+
"""One knowledge base available to a credential pair."""
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
name: str
|
|
71
|
+
description: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class ImaNote:
|
|
76
|
+
"""One note, flattened from the notes API's nested payload."""
|
|
77
|
+
|
|
78
|
+
note_id: str
|
|
79
|
+
title: str
|
|
80
|
+
summary: str = ""
|
|
81
|
+
folder_name: str = ""
|
|
82
|
+
created_at: int = 0
|
|
83
|
+
"""Unix milliseconds, or ``0`` when absent."""
|
|
84
|
+
|
|
85
|
+
updated_at: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class ImaNotebook:
|
|
90
|
+
"""One notebook (a notes folder)."""
|
|
91
|
+
|
|
92
|
+
folder_id: str
|
|
93
|
+
name: str
|
|
94
|
+
note_number: int = 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class ImaImportedUrl:
|
|
99
|
+
"""The per-URL verdict ``import_urls`` reports."""
|
|
100
|
+
|
|
101
|
+
url: str
|
|
102
|
+
ok: bool
|
|
103
|
+
media_id: str = ""
|
|
104
|
+
code: int = 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class ImaPageCursor:
|
|
109
|
+
"""Cursor state shared by every paginated notes/knowledge response."""
|
|
110
|
+
|
|
111
|
+
next_cursor: str = ""
|
|
112
|
+
is_end: bool = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Knowledge base parsing
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
# ``get_knowledge_list`` names the document array ``knowledge_list`` while
|
|
120
|
+
# ``search_knowledge`` names it ``info_list``; folders have been observed under
|
|
121
|
+
# both ``folder_list`` and ``folders``. Accepting each spelling keeps one parser
|
|
122
|
+
# for both calls.
|
|
123
|
+
_DOCUMENT_KEYS: tuple[str, ...] = ("knowledge_list", "info_list")
|
|
124
|
+
_FOLDER_KEYS: tuple[str, ...] = ("folder_list", "folders")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_knowledge_page(data: Mapping[str, Any]) -> ImaKnowledgePage:
|
|
128
|
+
"""Decode one page of ``get_knowledge_list`` / ``search_knowledge``."""
|
|
129
|
+
documents: list[ImaDocument] = []
|
|
130
|
+
folders: list[ImaFolder] = []
|
|
131
|
+
seen_documents: set[str] = set()
|
|
132
|
+
seen_folders: set[str] = set()
|
|
133
|
+
unverified = 0
|
|
134
|
+
|
|
135
|
+
for raw in _entries(data, _DOCUMENT_KEYS):
|
|
136
|
+
# A folder can appear inside the document array (search matches folder
|
|
137
|
+
# names, and a listing mixes both), identified by carrying a folder id
|
|
138
|
+
# and no media id. Route it to the folder list instead of inventing a
|
|
139
|
+
# content-less document.
|
|
140
|
+
folder = _folder_from(raw)
|
|
141
|
+
if folder is not None and not _text(raw, "media_id"):
|
|
142
|
+
if folder.folder_id not in seen_folders:
|
|
143
|
+
seen_folders.add(folder.folder_id)
|
|
144
|
+
folders.append(folder)
|
|
145
|
+
continue
|
|
146
|
+
document = _document_from(raw)
|
|
147
|
+
if document is None:
|
|
148
|
+
unverified += 1
|
|
149
|
+
if document is not None and document.media_id not in seen_documents:
|
|
150
|
+
seen_documents.add(document.media_id)
|
|
151
|
+
documents.append(document)
|
|
152
|
+
|
|
153
|
+
for raw in _entries(data, _FOLDER_KEYS):
|
|
154
|
+
folder = _folder_from(raw)
|
|
155
|
+
if folder is not None and folder.folder_id not in seen_folders:
|
|
156
|
+
seen_folders.add(folder.folder_id)
|
|
157
|
+
folders.append(folder)
|
|
158
|
+
|
|
159
|
+
return ImaKnowledgePage(
|
|
160
|
+
documents=tuple(documents),
|
|
161
|
+
folders=tuple(folders),
|
|
162
|
+
path=_breadcrumb(data.get("current_path")),
|
|
163
|
+
next_cursor=str(data.get("next_cursor") or ""),
|
|
164
|
+
is_end=bool(data.get("is_end")),
|
|
165
|
+
unverified_documents=unverified,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_knowledge_bases(data: Mapping[str, Any]) -> tuple[list[ImaKnowledgeBase], ImaPageCursor]:
|
|
170
|
+
"""Decode one page of ``search_knowledge_base``.
|
|
171
|
+
|
|
172
|
+
The reference names the fields ``id`` / ``name``; live responses have also
|
|
173
|
+
used ``kb_id`` / ``kb_name``. Both are accepted.
|
|
174
|
+
"""
|
|
175
|
+
bases: list[ImaKnowledgeBase] = []
|
|
176
|
+
seen: set[str] = set()
|
|
177
|
+
for raw in _entries(data, ("info_list",)):
|
|
178
|
+
kb_id = _text(raw, "id") or _text(raw, "kb_id")
|
|
179
|
+
name = _text(raw, "name") or _text(raw, "kb_name")
|
|
180
|
+
if not kb_id or not name or kb_id in seen:
|
|
181
|
+
continue
|
|
182
|
+
seen.add(kb_id)
|
|
183
|
+
bases.append(ImaKnowledgeBase(id=kb_id, name=name))
|
|
184
|
+
cursor = ImaPageCursor(
|
|
185
|
+
next_cursor=str(data.get("next_cursor") or ""),
|
|
186
|
+
is_end=bool(data.get("is_end")),
|
|
187
|
+
)
|
|
188
|
+
return bases, cursor
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_imported_urls(data: Mapping[str, Any]) -> list[ImaImportedUrl]:
|
|
192
|
+
"""Decode ``import_urls``' per-URL results."""
|
|
193
|
+
results: list[ImaImportedUrl] = []
|
|
194
|
+
for raw in _entries(data, ("url_data_list", "info_list", "data_list")):
|
|
195
|
+
url = _text(raw, "url")
|
|
196
|
+
if not url:
|
|
197
|
+
continue
|
|
198
|
+
code = _int(raw.get("ret_code"))
|
|
199
|
+
results.append(
|
|
200
|
+
ImaImportedUrl(
|
|
201
|
+
url=url,
|
|
202
|
+
ok=code == 0,
|
|
203
|
+
media_id=_text(raw, "media_id"),
|
|
204
|
+
code=code,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return results
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Notes parsing
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_note(raw: Any) -> ImaNote | None:
|
|
216
|
+
"""Flatten one note entry from any of the notes list/search payloads.
|
|
217
|
+
|
|
218
|
+
Handles the three nestings the notes API uses for the same object:
|
|
219
|
+
``{"doc": {"basic_info": {...}}}`` (search), ``{"basic_info": {...}}``
|
|
220
|
+
(listing) and a bare basic-info dict.
|
|
221
|
+
"""
|
|
222
|
+
basic = _basic_info(raw, outer_keys=("doc",))
|
|
223
|
+
if basic is None:
|
|
224
|
+
return None
|
|
225
|
+
note_id = _text(basic, "docid") or _text(basic, "doc_id")
|
|
226
|
+
if not note_id:
|
|
227
|
+
return None
|
|
228
|
+
if _int(basic.get("status")) == 1: # deleted
|
|
229
|
+
return None
|
|
230
|
+
return ImaNote(
|
|
231
|
+
note_id=note_id,
|
|
232
|
+
title=_text(basic, "title") or "(untitled)",
|
|
233
|
+
summary=_text(basic, "summary"),
|
|
234
|
+
folder_name=_text(basic, "folder_name"),
|
|
235
|
+
created_at=_int(basic.get("create_time")),
|
|
236
|
+
updated_at=_int(basic.get("modify_time")),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_notebook(raw: Any) -> ImaNotebook | None:
|
|
241
|
+
"""Flatten one notebook entry from ``list_note_folder_by_cursor``."""
|
|
242
|
+
basic = _basic_info(raw, outer_keys=("folder",))
|
|
243
|
+
if basic is None:
|
|
244
|
+
return None
|
|
245
|
+
folder_id = _text(basic, "folder_id")
|
|
246
|
+
if not folder_id or _int(basic.get("status")) == 1:
|
|
247
|
+
return None
|
|
248
|
+
return ImaNotebook(
|
|
249
|
+
folder_id=folder_id,
|
|
250
|
+
name=_text(basic, "name") or "(unnamed)",
|
|
251
|
+
note_number=_int(basic.get("note_number")),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# helpers
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _entries(data: Mapping[str, Any], keys: tuple[str, ...]) -> list[Mapping[str, Any]]:
|
|
261
|
+
for key in keys:
|
|
262
|
+
value = data.get(key)
|
|
263
|
+
if isinstance(value, list):
|
|
264
|
+
return [item for item in value if isinstance(item, Mapping)]
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _document_from(raw: Mapping[str, Any]) -> ImaDocument | None:
|
|
269
|
+
media_id = _text(raw, "media_id")
|
|
270
|
+
title = _text(raw, "title")
|
|
271
|
+
if not media_id and not title:
|
|
272
|
+
return None
|
|
273
|
+
return ImaDocument(
|
|
274
|
+
media_id=media_id,
|
|
275
|
+
title=title or media_id,
|
|
276
|
+
parent_folder_id=_text(raw, "parent_folder_id"),
|
|
277
|
+
highlight=_text(raw, "highlight_content"),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _folder_from(raw: Mapping[str, Any]) -> ImaFolder | None:
|
|
282
|
+
folder_id = _text(raw, "folder_id")
|
|
283
|
+
if not folder_id:
|
|
284
|
+
return None
|
|
285
|
+
return ImaFolder(
|
|
286
|
+
folder_id=folder_id,
|
|
287
|
+
name=_text(raw, "name") or _text(raw, "title") or folder_id,
|
|
288
|
+
file_number=_int(raw.get("file_number")),
|
|
289
|
+
folder_number=_int(raw.get("folder_number")),
|
|
290
|
+
parent_folder_id=_text(raw, "parent_folder_id"),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _breadcrumb(raw: Any) -> tuple[str, ...]:
|
|
295
|
+
if not isinstance(raw, list):
|
|
296
|
+
return ()
|
|
297
|
+
names = []
|
|
298
|
+
for entry in raw:
|
|
299
|
+
if not isinstance(entry, Mapping):
|
|
300
|
+
continue
|
|
301
|
+
name = _text(entry, "name")
|
|
302
|
+
if name:
|
|
303
|
+
names.append(name)
|
|
304
|
+
return tuple(names)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _basic_info(raw: Any, *, outer_keys: tuple[str, ...]) -> Mapping[str, Any] | None:
|
|
308
|
+
"""Peel the notes API's wrapper layers down to the basic-info dict."""
|
|
309
|
+
if not isinstance(raw, Mapping):
|
|
310
|
+
return None
|
|
311
|
+
node: Mapping[str, Any] = raw
|
|
312
|
+
for key in outer_keys:
|
|
313
|
+
inner = node.get(key)
|
|
314
|
+
if isinstance(inner, Mapping):
|
|
315
|
+
node = inner
|
|
316
|
+
inner = node.get("basic_info")
|
|
317
|
+
if isinstance(inner, Mapping):
|
|
318
|
+
node = inner
|
|
319
|
+
return node
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _text(raw: Mapping[str, Any], key: str) -> str:
|
|
323
|
+
value = raw.get(key)
|
|
324
|
+
return str(value).strip() if value is not None else ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _int(value: Any) -> int:
|
|
328
|
+
try:
|
|
329
|
+
return int(value)
|
|
330
|
+
except (TypeError, ValueError):
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
__all__ = [
|
|
335
|
+
"ImaDocument",
|
|
336
|
+
"ImaFolder",
|
|
337
|
+
"ImaImportedUrl",
|
|
338
|
+
"ImaKnowledgeBase",
|
|
339
|
+
"ImaKnowledgePage",
|
|
340
|
+
"ImaNote",
|
|
341
|
+
"ImaNotebook",
|
|
342
|
+
"ImaPageCursor",
|
|
343
|
+
"parse_imported_urls",
|
|
344
|
+
"parse_knowledge_bases",
|
|
345
|
+
"parse_knowledge_page",
|
|
346
|
+
"parse_note",
|
|
347
|
+
"parse_notebook",
|
|
348
|
+
]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Client for Tencent IMA's notes OpenAPI (``/openapi/note/v1``).
|
|
2
|
+
|
|
3
|
+
Notes are the other half of a user's IMA account: a knowledge base holds files
|
|
4
|
+
and web pages, while notes are the text the user writes, and a note can also be
|
|
5
|
+
linked *into* a knowledge base (media type 11) — which is why retrieval already
|
|
6
|
+
needed this module to read a matched note's body.
|
|
7
|
+
|
|
8
|
+
Exposed here: searching notes (with IMA's own sort orders, the only place in the
|
|
9
|
+
API where "my most recent items" is answerable, since notes carry create/modify
|
|
10
|
+
timestamps and knowledge items do not), browsing notebooks, reading a note's
|
|
11
|
+
plain text, and the two additive writes — create a note, append to a note.
|
|
12
|
+
Nothing deletes or overwrites.
|
|
13
|
+
|
|
14
|
+
Field-name compatibility
|
|
15
|
+
------------------------
|
|
16
|
+
The reference names the note identifier ``doc_id`` while other IMA surfaces have
|
|
17
|
+
used ``note_id`` for the same value. Requests send both spellings: unknown body
|
|
18
|
+
fields are ignored by the API, so sending both is strictly safer than betting on
|
|
19
|
+
one and having every call 404.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from .config import ImaConfig
|
|
29
|
+
from .models import ImaNote, ImaNotebook, ImaPageCursor, parse_note, parse_notebook
|
|
30
|
+
from .transport import DEFAULT_TIMEOUT, NOTE_PREFIX, ImaTransport
|
|
31
|
+
|
|
32
|
+
# ``content_format`` / ``target_content_format`` enum values IMA documents.
|
|
33
|
+
MARKDOWN_FORMAT = 1
|
|
34
|
+
PLAIN_TEXT_FORMAT = 0
|
|
35
|
+
|
|
36
|
+
# ``sort_type`` values. Recency is the default because "my latest notes" is the
|
|
37
|
+
# question this module exists to answer.
|
|
38
|
+
SORT_BY_UPDATED = 0
|
|
39
|
+
SORT_BY_CREATED = 1
|
|
40
|
+
SORT_BY_TITLE = 2
|
|
41
|
+
SORT_BY_SIZE = 3
|
|
42
|
+
SORT_TYPES = frozenset({SORT_BY_UPDATED, SORT_BY_CREATED, SORT_BY_TITLE, SORT_BY_SIZE})
|
|
43
|
+
|
|
44
|
+
# ``search_type`` values: match titles only, or full text.
|
|
45
|
+
SEARCH_BY_TITLE = 0
|
|
46
|
+
SEARCH_BY_CONTENT = 1
|
|
47
|
+
|
|
48
|
+
# The notes list endpoints are cursor-paginated; the notebook listing starts at
|
|
49
|
+
# the literal cursor "0" rather than an empty string.
|
|
50
|
+
NOTEBOOK_FIRST_CURSOR = "0"
|
|
51
|
+
|
|
52
|
+
MAX_NOTE_PAGE_LIMIT = 50
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ImaNotesClient:
|
|
56
|
+
"""Stateless wrapper over the IMA notes OpenAPI."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
config: ImaConfig,
|
|
61
|
+
*,
|
|
62
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
63
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._wire = ImaTransport(config, timeout=timeout, transport=transport)
|
|
66
|
+
|
|
67
|
+
async def _post(self, method: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
return await self._wire.post(method, body, prefix=NOTE_PREFIX)
|
|
69
|
+
|
|
70
|
+
# ----- reading --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
async def search_notes(
|
|
73
|
+
self,
|
|
74
|
+
query: str = "",
|
|
75
|
+
*,
|
|
76
|
+
by_content: bool = False,
|
|
77
|
+
sort_type: int = SORT_BY_UPDATED,
|
|
78
|
+
limit: int = 20,
|
|
79
|
+
start: int = 0,
|
|
80
|
+
) -> tuple[list[ImaNote], bool]:
|
|
81
|
+
"""Search notes, newest-updated first by default.
|
|
82
|
+
|
|
83
|
+
An empty *query* lists notes in ``sort_type`` order, which is how "my
|
|
84
|
+
latest notes" is answered. Returns the notes plus whether IMA reported
|
|
85
|
+
the end of the result set. Pagination is a half-open ``[start, end)``
|
|
86
|
+
window rather than a cursor.
|
|
87
|
+
"""
|
|
88
|
+
size = _note_limit(limit)
|
|
89
|
+
text = str(query or "").strip()
|
|
90
|
+
search_type = SEARCH_BY_CONTENT if by_content else SEARCH_BY_TITLE
|
|
91
|
+
data = await self._post(
|
|
92
|
+
"search_note_book",
|
|
93
|
+
{
|
|
94
|
+
"search_type": search_type,
|
|
95
|
+
"sort_type": sort_type if sort_type in SORT_TYPES else SORT_BY_UPDATED,
|
|
96
|
+
"query_info": ({"content": text} if by_content else {"title": text}),
|
|
97
|
+
"start": max(0, int(start)),
|
|
98
|
+
"end": max(0, int(start)) + size,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
notes = _notes_from(data, ("docs", "note_book_list"))
|
|
102
|
+
return notes[:size], bool(data.get("is_end"))
|
|
103
|
+
|
|
104
|
+
async def list_notebooks(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
cursor: str = NOTEBOOK_FIRST_CURSOR,
|
|
108
|
+
limit: int = MAX_NOTE_PAGE_LIMIT,
|
|
109
|
+
) -> tuple[list[ImaNotebook], ImaPageCursor]:
|
|
110
|
+
"""Return one page of the user's notebooks."""
|
|
111
|
+
data = await self._post(
|
|
112
|
+
"list_note_folder_by_cursor",
|
|
113
|
+
{"cursor": str(cursor or NOTEBOOK_FIRST_CURSOR), "limit": _note_limit(limit)},
|
|
114
|
+
)
|
|
115
|
+
notebooks: list[ImaNotebook] = []
|
|
116
|
+
raw_list = data.get("note_book_folders")
|
|
117
|
+
if isinstance(raw_list, list):
|
|
118
|
+
for raw in raw_list:
|
|
119
|
+
notebook = parse_notebook(raw)
|
|
120
|
+
if notebook is not None:
|
|
121
|
+
notebooks.append(notebook)
|
|
122
|
+
return notebooks, _cursor_of(data)
|
|
123
|
+
|
|
124
|
+
async def list_notes(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
folder_id: str = "",
|
|
128
|
+
cursor: str = "",
|
|
129
|
+
limit: int = MAX_NOTE_PAGE_LIMIT,
|
|
130
|
+
) -> tuple[list[ImaNote], ImaPageCursor]:
|
|
131
|
+
"""Return one page of notes inside *folder_id* (root when omitted)."""
|
|
132
|
+
body: dict[str, Any] = {"cursor": str(cursor or ""), "limit": _note_limit(limit)}
|
|
133
|
+
folder = str(folder_id or "").strip()
|
|
134
|
+
if folder:
|
|
135
|
+
body["folder_id"] = folder
|
|
136
|
+
data = await self._post("list_note_by_folder_id", body)
|
|
137
|
+
return _notes_from(data, ("note_book_list", "docs")), _cursor_of(data)
|
|
138
|
+
|
|
139
|
+
async def get_note_content(self, note_id: str, *, as_markdown: bool = False) -> str:
|
|
140
|
+
"""Return one note's text, or ``""`` when it has none.
|
|
141
|
+
|
|
142
|
+
Plain text is IMA's recommended (and only reliably supported) target
|
|
143
|
+
format; *as_markdown* is accepted for callers that want to try the
|
|
144
|
+
Markdown variant.
|
|
145
|
+
"""
|
|
146
|
+
identifier = str(note_id or "").strip()
|
|
147
|
+
if not identifier:
|
|
148
|
+
return ""
|
|
149
|
+
data = await self._post(
|
|
150
|
+
"get_doc_content",
|
|
151
|
+
{
|
|
152
|
+
"doc_id": identifier,
|
|
153
|
+
"note_id": identifier,
|
|
154
|
+
"target_content_format": MARKDOWN_FORMAT if as_markdown else PLAIN_TEXT_FORMAT,
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
return str(data.get("content") or "").strip()
|
|
158
|
+
|
|
159
|
+
# ----- additive writes ------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async def create_note(self, content: str, *, folder_id: str = "") -> str:
|
|
162
|
+
"""Create a note from Markdown and return its id.
|
|
163
|
+
|
|
164
|
+
IMA takes the title from the Markdown body's leading heading, so callers
|
|
165
|
+
that want a specific title should include one.
|
|
166
|
+
"""
|
|
167
|
+
body_text = str(content or "").strip()
|
|
168
|
+
if not body_text:
|
|
169
|
+
raise ValueError("Note content is required.")
|
|
170
|
+
body: dict[str, Any] = {"content_format": MARKDOWN_FORMAT, "content": body_text}
|
|
171
|
+
folder = str(folder_id or "").strip()
|
|
172
|
+
if folder:
|
|
173
|
+
body["folder_id"] = folder
|
|
174
|
+
data = await self._post("import_doc", body)
|
|
175
|
+
return str(data.get("doc_id") or data.get("note_id") or "").strip()
|
|
176
|
+
|
|
177
|
+
async def append_note(self, note_id: str, content: str) -> str:
|
|
178
|
+
"""Append Markdown to an existing note and return its id.
|
|
179
|
+
|
|
180
|
+
Appending mutates a note the user owns and cannot be undone, so callers
|
|
181
|
+
must have an explicitly identified target — never a guessed one.
|
|
182
|
+
"""
|
|
183
|
+
identifier = str(note_id or "").strip()
|
|
184
|
+
if not identifier:
|
|
185
|
+
raise ValueError("A target note id is required.")
|
|
186
|
+
body_text = str(content or "").strip()
|
|
187
|
+
if not body_text:
|
|
188
|
+
raise ValueError("Content to append is required.")
|
|
189
|
+
data = await self._post(
|
|
190
|
+
"append_doc",
|
|
191
|
+
{
|
|
192
|
+
"doc_id": identifier,
|
|
193
|
+
"note_id": identifier,
|
|
194
|
+
"content_format": MARKDOWN_FORMAT,
|
|
195
|
+
"content": body_text,
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
return str(data.get("doc_id") or data.get("note_id") or identifier).strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _notes_from(data: dict[str, Any], keys: tuple[str, ...]) -> list[ImaNote]:
|
|
202
|
+
for key in keys:
|
|
203
|
+
raw_list = data.get(key)
|
|
204
|
+
if not isinstance(raw_list, list):
|
|
205
|
+
continue
|
|
206
|
+
notes = [note for note in (parse_note(raw) for raw in raw_list) if note is not None]
|
|
207
|
+
if notes or raw_list:
|
|
208
|
+
return notes
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _cursor_of(data: dict[str, Any]) -> ImaPageCursor:
|
|
213
|
+
return ImaPageCursor(
|
|
214
|
+
next_cursor=str(data.get("next_cursor") or ""),
|
|
215
|
+
is_end=bool(data.get("is_end")),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _note_limit(limit: Any) -> int:
|
|
220
|
+
try:
|
|
221
|
+
value = int(limit)
|
|
222
|
+
except (TypeError, ValueError):
|
|
223
|
+
return MAX_NOTE_PAGE_LIMIT
|
|
224
|
+
return max(1, min(value, MAX_NOTE_PAGE_LIMIT))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = [
|
|
228
|
+
"MARKDOWN_FORMAT",
|
|
229
|
+
"MAX_NOTE_PAGE_LIMIT",
|
|
230
|
+
"NOTEBOOK_FIRST_CURSOR",
|
|
231
|
+
"PLAIN_TEXT_FORMAT",
|
|
232
|
+
"SEARCH_BY_CONTENT",
|
|
233
|
+
"SEARCH_BY_TITLE",
|
|
234
|
+
"SORT_BY_CREATED",
|
|
235
|
+
"SORT_BY_SIZE",
|
|
236
|
+
"SORT_BY_TITLE",
|
|
237
|
+
"SORT_BY_UPDATED",
|
|
238
|
+
"SORT_TYPES",
|
|
239
|
+
"ImaNotesClient",
|
|
240
|
+
]
|