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,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
+ ]