ltcai 11.1.0 → 11.4.0

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 (289) hide show
  1. package/README.md +47 -54
  2. package/docs/CHANGELOG.md +94 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  8. package/docs/ONBOARDING.md +1 -1
  9. package/docs/OPERATIONS.md +6 -2
  10. package/docs/PERMISSION_MODE.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/architecture.md +6 -2
  14. package/docs/kg-schema.md +2 -2
  15. package/docs/v11.3.0_PLAN.md +202 -0
  16. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/gates.py +125 -0
  19. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  20. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  21. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  22. package/lattice_brain/graph/_kg_common/text.py +479 -0
  23. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  24. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  25. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  26. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  27. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  28. package/lattice_brain/graph/fusion.py +35 -4
  29. package/lattice_brain/graph/projection/__init__.py +42 -0
  30. package/lattice_brain/graph/projection/curation.py +500 -0
  31. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +81 -485
  32. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  33. package/lattice_brain/graph/retrieval/context.py +197 -0
  34. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  35. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  36. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  37. package/lattice_brain/graph/retrieval/signals.py +95 -0
  38. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  39. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  40. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  41. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  42. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  43. package/lattice_brain/graph/schema.py +9 -0
  44. package/lattice_brain/graph/store.py +9 -0
  45. package/lattice_brain/graph/vector_index/selector.py +32 -2
  46. package/lattice_brain/ingestion/__init__.py +130 -0
  47. package/lattice_brain/ingestion/_contract.py +90 -0
  48. package/lattice_brain/ingestion/constants.py +127 -0
  49. package/lattice_brain/ingestion/folder_scan.py +57 -0
  50. package/lattice_brain/ingestion/folders.py +258 -0
  51. package/lattice_brain/ingestion/hashing.py +26 -0
  52. package/lattice_brain/ingestion/jobs_api.py +107 -0
  53. package/lattice_brain/ingestion/models.py +80 -0
  54. package/lattice_brain/ingestion/pipeline.py +486 -0
  55. package/lattice_brain/ingestion/quality.py +209 -0
  56. package/lattice_brain/ingestion/routing.py +295 -0
  57. package/lattice_brain/multimodal/__init__.py +164 -0
  58. package/lattice_brain/multimodal/audio.py +77 -0
  59. package/lattice_brain/multimodal/common.py +118 -0
  60. package/lattice_brain/{multimodal.py → multimodal/images.py} +22 -262
  61. package/lattice_brain/multimodal/ports.py +169 -0
  62. package/lattice_brain/multimodal/video.py +410 -0
  63. package/lattice_brain/portability/__init__.py +90 -0
  64. package/lattice_brain/portability/_contract.py +42 -0
  65. package/lattice_brain/portability/backups.py +338 -0
  66. package/lattice_brain/portability/bundles.py +136 -0
  67. package/lattice_brain/portability/constants.py +93 -0
  68. package/lattice_brain/portability/fsops.py +138 -0
  69. package/lattice_brain/portability/service.py +41 -0
  70. package/lattice_brain/portability/sharing.py +714 -0
  71. package/lattice_brain/runtime/__init__.py +1 -1
  72. package/lattice_brain/runtime/multi_agent.py +1 -1
  73. package/lattice_brain/sealed_box.py +244 -0
  74. package/lattice_brain/synthesis.py +24 -1
  75. package/latticeai/__init__.py +1 -1
  76. package/latticeai/api/brain_intelligence.py +4 -0
  77. package/latticeai/api/chat.py +11 -0
  78. package/latticeai/api/chat_helpers.py +16 -3
  79. package/latticeai/api/chat_hybrid.py +32 -1
  80. package/latticeai/api/chronicle.py +63 -0
  81. package/latticeai/api/features.py +70 -0
  82. package/latticeai/api/local_files.py +102 -0
  83. package/latticeai/api/portability.py +39 -4
  84. package/latticeai/api/review_queue.py +126 -0
  85. package/latticeai/api/search.py +16 -2
  86. package/latticeai/core/agent/__init__.py +93 -0
  87. package/latticeai/core/agent/_contract.py +79 -0
  88. package/latticeai/core/agent/context.py +57 -0
  89. package/latticeai/core/agent/deps.py +125 -0
  90. package/latticeai/core/agent/execution.py +622 -0
  91. package/latticeai/core/agent/planning.py +145 -0
  92. package/latticeai/core/agent/recovery.py +157 -0
  93. package/latticeai/core/agent/runtime.py +210 -0
  94. package/latticeai/core/agent/verification.py +231 -0
  95. package/latticeai/core/config.py +4 -1
  96. package/latticeai/core/context_builder.py +6 -3
  97. package/latticeai/core/embedding_providers/__init__.py +151 -0
  98. package/latticeai/core/embedding_providers/base.py +199 -0
  99. package/latticeai/core/embedding_providers/captions.py +162 -0
  100. package/latticeai/core/embedding_providers/profiles.py +126 -0
  101. package/latticeai/core/embedding_providers/text.py +350 -0
  102. package/latticeai/core/embedding_providers/vision.py +352 -0
  103. package/latticeai/core/file_generation/__init__.py +115 -0
  104. package/latticeai/core/file_generation/bundles.py +76 -0
  105. package/latticeai/core/file_generation/extraction.py +154 -0
  106. package/latticeai/core/file_generation/inference.py +235 -0
  107. package/latticeai/core/file_generation/orchestration.py +152 -0
  108. package/latticeai/core/file_generation/prompting.py +117 -0
  109. package/latticeai/core/file_generation/repair.py +114 -0
  110. package/latticeai/core/file_generation/sanitize.py +61 -0
  111. package/latticeai/core/file_generation/validation.py +201 -0
  112. package/latticeai/core/legacy_compatibility.py +1 -1
  113. package/latticeai/core/marketplace.py +1 -1
  114. package/latticeai/core/messages.py +152 -0
  115. package/latticeai/core/model_compat.py +73 -2
  116. package/latticeai/core/workspace_os_constants.py +1 -1
  117. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  118. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  119. package/latticeai/integrations/telegram_bot/config.py +86 -0
  120. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  121. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  122. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  123. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  124. package/latticeai/models/model_providers.py +12 -4
  125. package/latticeai/models/router/__init__.py +88 -0
  126. package/latticeai/models/router/_contract.py +66 -0
  127. package/latticeai/models/router/branding.py +56 -0
  128. package/latticeai/models/router/catalog.py +69 -0
  129. package/latticeai/models/router/documents.py +199 -0
  130. package/latticeai/models/router/errors.py +37 -0
  131. package/latticeai/models/router/generation.py +258 -0
  132. package/latticeai/models/router/loading.py +291 -0
  133. package/latticeai/models/router/local_models.py +85 -0
  134. package/latticeai/models/router/registry.py +147 -0
  135. package/latticeai/runtime/build_phases/__init__.py +82 -0
  136. package/latticeai/runtime/build_phases/features.py +407 -0
  137. package/latticeai/runtime/build_phases/foundation.py +555 -0
  138. package/latticeai/runtime/build_phases/web.py +492 -0
  139. package/latticeai/runtime/chat_wiring.py +4 -0
  140. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  141. package/latticeai/runtime/router_registration.py +11 -0
  142. package/latticeai/runtime/runtime_context.py +1 -0
  143. package/latticeai/services/app_context.py +8 -0
  144. package/latticeai/services/architecture_readiness.py +48 -19
  145. package/latticeai/services/automation_intelligence.py +22 -2
  146. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  147. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  148. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  149. package/latticeai/services/brain_intelligence/constants.py +47 -0
  150. package/latticeai/services/brain_intelligence/digest.py +258 -0
  151. package/latticeai/services/brain_intelligence/health.py +331 -0
  152. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  153. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  154. package/latticeai/services/brain_intelligence/service.py +48 -0
  155. package/latticeai/services/chronicle.py +557 -0
  156. package/latticeai/services/command_center.py +10 -4
  157. package/latticeai/services/feature_toggles.py +502 -0
  158. package/latticeai/services/folder_watch.py +122 -1
  159. package/latticeai/services/hybrid_chat.py +56 -5
  160. package/latticeai/services/interop_bridges.py +978 -0
  161. package/latticeai/services/memory_service/__init__.py +52 -0
  162. package/latticeai/services/memory_service/_contract.py +100 -0
  163. package/latticeai/services/memory_service/brief.py +431 -0
  164. package/latticeai/services/memory_service/constants.py +57 -0
  165. package/latticeai/services/memory_service/maintenance.py +138 -0
  166. package/latticeai/services/memory_service/manager.py +186 -0
  167. package/latticeai/services/memory_service/proof.py +136 -0
  168. package/latticeai/services/memory_service/recall.py +225 -0
  169. package/latticeai/services/memory_service/service.py +48 -0
  170. package/latticeai/services/memory_service/stores.py +110 -0
  171. package/latticeai/services/model_capability_registry.py +434 -261
  172. package/latticeai/services/model_catalog.py +95 -61
  173. package/latticeai/services/model_recommendation.py +18 -11
  174. package/latticeai/services/model_runtime/__init__.py +322 -0
  175. package/latticeai/services/model_runtime/cloud.py +87 -0
  176. package/latticeai/services/model_runtime/download.py +282 -0
  177. package/latticeai/services/model_runtime/engines.py +341 -0
  178. package/latticeai/services/model_runtime/loading.py +178 -0
  179. package/latticeai/services/model_runtime/service.py +129 -0
  180. package/latticeai/services/model_runtime/state.py +131 -0
  181. package/latticeai/services/model_runtime/status.py +255 -0
  182. package/latticeai/services/multimodal_ports.py +26 -1
  183. package/latticeai/services/obsidian_bridge.py +16 -25
  184. package/latticeai/services/product_readiness.py +15 -7
  185. package/latticeai/services/search_service.py +149 -2
  186. package/latticeai/services/tool_dispatch.py +4 -0
  187. package/latticeai/setup/auto_setup.py +27 -30
  188. package/latticeai/setup/wizard/__init__.py +126 -0
  189. package/latticeai/setup/wizard/catalog.py +172 -0
  190. package/latticeai/setup/wizard/detect.py +323 -0
  191. package/latticeai/setup/wizard/install.py +348 -0
  192. package/latticeai/setup/wizard/paths.py +168 -0
  193. package/latticeai/setup/wizard/plans.py +74 -0
  194. package/latticeai/setup/wizard/recommend.py +320 -0
  195. package/package.json +6 -2
  196. package/scripts/bump_version.py +14 -0
  197. package/scripts/capture_release_evidence.mjs +33 -21
  198. package/scripts/check_current_release_docs.mjs +1 -1
  199. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  200. package/scripts/check_max_file_lines.mjs +102 -0
  201. package/scripts/check_release_evidence_bound.mjs +30 -15
  202. package/scripts/check_screenshot_pixel_delta.py +34 -4
  203. package/scripts/check_server_i18n.mjs +2 -0
  204. package/scripts/generate_rust_parity_fixtures.py +562 -0
  205. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  206. package/scripts/release_screen_claims.json +44 -2
  207. package/scripts/verify_hf_model_registry.py +253 -218
  208. package/src-tauri/Cargo.lock +361 -3
  209. package/src-tauri/Cargo.toml +6 -1
  210. package/src-tauri/src/backend.rs +349 -0
  211. package/src-tauri/src/folder.rs +33 -0
  212. package/src-tauri/src/main.rs +97 -399
  213. package/src-tauri/tauri.conf.json +1 -1
  214. package/static/app/asset-manifest.json +41 -37
  215. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  216. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  217. package/static/app/assets/{Brain-CzCsI1mi.js → Brain-C1HBN0Wf.js} +2 -2
  218. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  219. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  220. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  221. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  222. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  223. package/static/app/assets/Library-DwO3yZST.js +1 -0
  224. package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-Jn1GK0-S.js} +1 -1
  225. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  226. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  227. package/static/app/assets/System-DW8F-2xL.js +1 -0
  228. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  229. package/static/app/assets/{bot-4BvN07ux.js → bot-IM_E_Y12.js} +1 -1
  230. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  231. package/static/app/assets/{button-CDjtnAoU.js → button-COwyqfHM.js} +1 -1
  232. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  233. package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-DEM4A1Y5.js} +1 -1
  234. package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-C9djDuLd.js} +1 -1
  235. package/static/app/assets/{cpu-BIlWInHf.js → cpu-DFdo1gw-.js} +1 -1
  236. package/static/app/assets/{download-BtjXfL3z.js → download-SnJL6oqk.js} +1 -1
  237. package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-CqZeDkjE.js} +1 -1
  238. package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-j1jJXYYf.js} +1 -1
  239. package/static/app/assets/{index-vtEfYvQY.css → index-BLPb5lmE.css} +1 -1
  240. package/static/app/assets/index-_u5iUHDr.js +10 -0
  241. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  242. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  243. package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-BsyLxtao.js} +1 -1
  244. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  245. package/static/app/assets/search-BybIWPNd.js +1 -0
  246. package/static/app/assets/{share-2-D5zg_0fY.js → share-2-CVtZ_ewX.js} +1 -1
  247. package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-CBi2GNWM.js} +1 -1
  248. package/static/app/assets/{textarea-nEVIweKY.js → textarea-DNMpB5ih.js} +1 -1
  249. package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-C83t3GXF.js} +1 -1
  250. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  251. package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-Dcp1OChy.js} +1 -1
  252. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  253. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  254. package/static/app/index.html +4 -4
  255. package/static/sw.js +1 -1
  256. package/lattice_brain/graph/_kg_common.py +0 -1331
  257. package/lattice_brain/graph/discovery_index.py +0 -1141
  258. package/lattice_brain/graph/retrieval.py +0 -1120
  259. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  260. package/lattice_brain/ingestion.py +0 -1377
  261. package/lattice_brain/portability.py +0 -1210
  262. package/latticeai/core/agent.py +0 -1412
  263. package/latticeai/core/embedding_providers.py +0 -1196
  264. package/latticeai/core/file_generation.py +0 -1047
  265. package/latticeai/integrations/telegram_bot.py +0 -1390
  266. package/latticeai/models/router.py +0 -1007
  267. package/latticeai/runtime/build_phases.py +0 -1422
  268. package/latticeai/services/brain_intelligence.py +0 -967
  269. package/latticeai/services/memory_service.py +0 -1177
  270. package/latticeai/services/model_runtime.py +0 -1281
  271. package/latticeai/setup/wizard.py +0 -1277
  272. package/static/app/assets/Act-D0HWqtn0.js +0 -1
  273. package/static/app/assets/AdminConsole-D-QDW-A4.js +0 -1
  274. package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
  275. package/static/app/assets/BrainSignals-2dHQNkns.js +0 -1
  276. package/static/app/assets/Capture-CT8v1StE.js +0 -1
  277. package/static/app/assets/CommandPalette-DoLXC2KH.js +0 -1
  278. package/static/app/assets/Library-DDoxFE5c.js +0 -1
  279. package/static/app/assets/ProductFlow-DOYf7JIs.js +0 -1
  280. package/static/app/assets/ReviewCard-COQsqidK.js +0 -3
  281. package/static/app/assets/System-BRllvYXd.js +0 -1
  282. package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
  283. package/static/app/assets/brain-uMb_5hnO.js +0 -1
  284. package/static/app/assets/index-0AvoEBzJ.js +0 -10
  285. package/static/app/assets/input-B_5ZJ9oy.js +0 -1
  286. package/static/app/assets/primitives-CVwew78r.js +0 -1
  287. package/static/app/assets/search-DkhnOKZt.js +0 -1
  288. package/static/app/assets/utils-DcDMoZIe.js +0 -4
  289. package/static/app/assets/workspace-LtRRSKTf.js +0 -1
@@ -0,0 +1,77 @@
1
+ """A recording as a memory — transcribed through the injected port, or not.
2
+
3
+ The whole module is three small pieces because that is all an audio memory is:
4
+ the recording's facts, one attempt at turning it into words, and an honest
5
+ score for how much of it a typed question can reach afterwards. Without a
6
+ transcriber the memory is still kept and simply says so.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .ports import MultimodalPorts
15
+
16
+
17
+ @dataclass
18
+ class AudioFacts:
19
+ """A recording, its transcript, and how honestly we got one."""
20
+
21
+ path: str
22
+ #: ``ok`` | ``unavailable`` | ``failed`` | ``supplied``
23
+ transcription_status: str = "unavailable"
24
+ transcript: str = ""
25
+ detail: str = ""
26
+ segments: List[Dict[str, Any]] = field(default_factory=list)
27
+
28
+ @property
29
+ def searchable(self) -> bool:
30
+ return bool(self.transcript)
31
+
32
+
33
+ def transcribe_audio(
34
+ path: str,
35
+ *,
36
+ ports: Optional[MultimodalPorts] = None,
37
+ transcript: Optional[str] = None,
38
+ ) -> AudioFacts:
39
+ """Transcribe a recording through the injected port, or say why not.
40
+
41
+ ``transcript`` lets a caller that already has text (a phone's own
42
+ dictation, ``VoiceCaptureService``) skip the local model entirely. An
43
+ absent transcriber yields ``transcription_status="unavailable"`` and an
44
+ empty transcript — the recording is still remembered by title and path,
45
+ and the result never claims it is searchable.
46
+ """
47
+ ports = ports or MultimodalPorts()
48
+ supplied = str(transcript or "").strip()
49
+ if supplied:
50
+ return AudioFacts(path=str(path), transcription_status="supplied", transcript=supplied)
51
+ if ports.transcriber is None:
52
+ return AudioFacts(
53
+ path=str(path),
54
+ transcription_status="unavailable",
55
+ detail="no local transcriber is configured",
56
+ )
57
+ try:
58
+ text = str(ports.transcriber(str(path)) or "").strip()
59
+ except Exception as exc: # noqa: BLE001 — a broken transcriber is a state
60
+ return AudioFacts(path=str(path), transcription_status="failed", detail=str(exc))
61
+ if not text:
62
+ return AudioFacts(
63
+ path=str(path),
64
+ transcription_status="failed",
65
+ detail="the transcriber returned no text",
66
+ )
67
+ return AudioFacts(path=str(path), transcription_status="ok", transcript=text)
68
+
69
+
70
+ def audio_quality_score(facts: AudioFacts) -> Dict[str, Any]:
71
+ """How much of this recording is actually retrievable later."""
72
+ if not facts.transcript:
73
+ return {"score": 0.0, "reasons": ["no_transcript"]}
74
+ # A transcript is text: length is the only honest extra signal here, and
75
+ # the text pipeline scores the wording itself downstream.
76
+ score = 0.5 + 0.5 * min(1.0, len(facts.transcript) / 400.0)
77
+ return {"score": round(score, 4), "reasons": ["transcript"]}
@@ -0,0 +1,118 @@
1
+ """Modality taxonomy, size budgets, and the pure helpers every door shares.
2
+
3
+ The tables here are the module's own decisions (``.mp4`` is video unless the
4
+ capture surface says otherwise), deliberately kept ahead of the platform's
5
+ ``mimetypes`` answer. Nothing in this file touches a model, a decoder, or the
6
+ graph, which is what lets images, audio and video all import it without
7
+ importing each other.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import mimetypes
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ # ── modality taxonomy ────────────────────────────────────────────────────────
18
+ MODALITY_TEXT = "text"
19
+ MODALITY_IMAGE = "image"
20
+ MODALITY_AUDIO = "audio"
21
+ MODALITY_VIDEO = "video"
22
+
23
+ IMAGE_EXTENSIONS = frozenset(
24
+ {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".heic"}
25
+ )
26
+ # Containers that are *only* ever audio. ``.mp4``/``.webm`` are deliberately
27
+ # absent: by extension alone they are video, and a voice memo recorded in one
28
+ # of them arrives through an explicit audio MIME type (or through
29
+ # ``VoiceCaptureService``, where the user already said "this is a memo").
30
+ # ``.mid``/``.midi`` are listed for a second reason: CPython's *built-in* mime
31
+ # table has neither, so ``mimetypes`` answers "audio/midi" only on a host that
32
+ # ships a system mime file (macOS reads /etc/apache2/mime.types; a slim Linux
33
+ # container has nothing). Leaving them to the fallback let the platform decide
34
+ # what a MIDI file is — a module table exists precisely so it does not.
35
+ AUDIO_EXTENSIONS = frozenset(
36
+ {".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg", ".opus", ".mid", ".midi"}
37
+ )
38
+ VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"})
39
+ #: Subtitle/caption files a video may arrive with. Same basename, so a
40
+ #: ``standup.mp4`` next to a ``standup.srt`` is one memory, not two.
41
+ SUBTITLE_EXTENSIONS = ("srt", "vtt")
42
+
43
+ #: Why a video is recognized and still refused — surfaced to the caller. In
44
+ #: 11.1.0 the reason was *scope* (nothing was implemented). Since 11.2.0 the
45
+ #: implementation exists and the only remaining reason is a **runtime** one:
46
+ #: this machine has no ``ffmpeg``, and inventing frames is not an option.
47
+ VIDEO_UNAVAILABLE_DETAIL = (
48
+ "video ingestion needs ffmpeg on this machine and none was found; the file "
49
+ "was not stored (install ffmpeg to enable keyframe extraction)"
50
+ )
51
+ #: Kept under its 11.1.0 name so existing importers keep working; the reason it
52
+ #: carries has changed from "out of scope" to "unavailable on this machine".
53
+ VIDEO_OUT_OF_SCOPE = VIDEO_UNAVAILABLE_DETAIL
54
+
55
+ #: Longest OCR/caption body kept on the node (a screenshot is not a novel).
56
+ MAX_INDEX_TEXT_CHARS = 20_000
57
+ #: Summary column budget, matching every other ingest door in the graph.
58
+ SUMMARY_CHARS = 500
59
+ #: Fixed-width chunking for OCR bodies that outgrow the summary.
60
+ IMAGE_CHUNK_CHARS = 900
61
+ #: Longest edge of the stored thumbnail, in pixels.
62
+ THUMBNAIL_EDGE = 96
63
+ #: A thumbnail is a UI affordance, not an archive — drop it past this size.
64
+ MAX_THUMBNAIL_CHARS = 24_000
65
+
66
+
67
+ def detect_modality(
68
+ path: Optional[str] = None, mime_type: Optional[str] = None
69
+ ) -> str:
70
+ """``text`` | ``image`` | ``audio`` | ``video`` for one candidate file.
71
+
72
+ The declared MIME type wins when it carries a usable top-level type: the
73
+ capture surface saw the bytes, this function only sees a name. Otherwise
74
+ the extension decides, and an unknown extension is ``text`` so existing
75
+ behaviour is untouched.
76
+ """
77
+ declared = str(mime_type or "").strip().lower().split(";")[0].split("/")[0]
78
+ if declared in {MODALITY_IMAGE, MODALITY_AUDIO, MODALITY_VIDEO}:
79
+ return declared
80
+ # The extension tables come before ``mimetypes`` on purpose: they are where
81
+ # this module's decisions live (``.mp4`` is video unless someone who saw
82
+ # the bytes says otherwise), and the stdlib table varies by platform.
83
+ suffix = Path(str(path or "")).suffix.lower()
84
+ if suffix in IMAGE_EXTENSIONS:
85
+ return MODALITY_IMAGE
86
+ if suffix in AUDIO_EXTENSIONS:
87
+ return MODALITY_AUDIO
88
+ if suffix in VIDEO_EXTENSIONS:
89
+ return MODALITY_VIDEO
90
+ if path:
91
+ guessed, _ = mimetypes.guess_type(str(path))
92
+ top = str(guessed or "").split("/")[0]
93
+ if top in {MODALITY_IMAGE, MODALITY_AUDIO, MODALITY_VIDEO}:
94
+ return top
95
+ return MODALITY_TEXT
96
+
97
+
98
+ def _sha256_file(path: Path) -> str:
99
+ digest = hashlib.sha256()
100
+ with path.open("rb") as handle:
101
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
102
+ digest.update(block)
103
+ return digest.hexdigest()
104
+
105
+
106
+ def _sha256_text(text: str) -> str:
107
+ return hashlib.sha256(str(text).encode("utf-8", "ignore")).hexdigest()
108
+
109
+
110
+ def _split_index_text(text: str) -> List[str]:
111
+ """Fixed-width split for OCR bodies — no markdown or code structure here."""
112
+ body = str(text or "").strip()
113
+ if len(body) <= SUMMARY_CHARS:
114
+ return []
115
+ return [
116
+ body[start : start + IMAGE_CHUNK_CHARS]
117
+ for start in range(0, len(body), IMAGE_CHUNK_CHARS)
118
+ ]
@@ -1,160 +1,35 @@
1
- """Images and audio as first-class memories (v11.1.0, Track 3).
2
-
3
- Before this module the Brain could only remember things that arrived as text.
4
- A screenshot of a whiteboard, a photo of a receipt, a voice memo — all of them
5
- either bounced off the ingestion pipeline or landed as an opaque ``Document``
6
- node whose only searchable content was its filename.
7
-
8
- What routes here
9
- ----------------
10
- :func:`detect_modality` reads the MIME type first and the extension second, and
11
- answers with one of ``text`` / ``image`` / ``audio`` / ``video``. ``video`` is
12
- deliberately a *recognized but unsupported* answer in this release: keyframe
13
- extraction needs a decoder this project does not ship, and returning "video,
14
- out of scope" is worth more than pretending a ``.mov`` is a picture.
15
-
16
- What an image memory contains
17
- -----------------------------
18
- :func:`extract_image_facts` gathers only what it can actually observe:
19
-
20
- * **dimensions/format** from Pillow (a core dependency);
21
- * **ocr_text** from ``pytesseract`` when it is installed — otherwise
22
- ``ocr_status="unavailable"`` and no text, never an empty string dressed up as
23
- a successful read;
24
- * **caption** from an injected vision-language port, and *only* from there. No
25
- VLM means ``caption is None``. Composing "Image IMG_2381.png (JPEG 3024x4032)"
26
- and storing it in the caption field would make metadata indistinguishable
27
- from a model's description forever after;
28
- * **embedding** from an injected vision port, which lives in its own vector
29
- space (see :mod:`latticeai.core.embedding_providers`) and therefore its own
30
- index — text queries reach images through OCR/caption text, not by scoring a
31
- BGE vector against CLIP vectors.
32
-
33
- Brain Core owns none of those models. Every heavy dependency arrives as an
34
- injected callable (:class:`MultimodalPorts`), which is also why this module
35
- imports nothing from ``latticeai``.
1
+ """A picture as a first-class memory: what was observed, and what was written.
2
+
3
+ Two halves, in order: :func:`extract_image_facts` observes one file (size, OCR,
4
+ caption, vector — each with its own status), and :func:`write_image_memory`
5
+ turns those facts into an ``Image`` node with its chunks, concepts and source
6
+ link. Video reuses both verbatim for its keyframes, which is why nothing here
7
+ knows what a video is.
36
8
  """
37
9
 
38
10
  from __future__ import annotations
39
11
 
40
12
  import base64
41
- import hashlib
42
13
  import io
43
- import mimetypes
44
- from dataclasses import dataclass, field
14
+ from dataclasses import dataclass
45
15
  from pathlib import Path
46
16
  from typing import Any, Callable, Dict, List, Optional
47
17
 
48
- from .quiet import quiet
49
- from .utils import utc_now_iso
50
-
51
- # ── modality taxonomy ────────────────────────────────────────────────────────
52
- MODALITY_TEXT = "text"
53
- MODALITY_IMAGE = "image"
54
- MODALITY_AUDIO = "audio"
55
- MODALITY_VIDEO = "video"
56
-
57
- IMAGE_EXTENSIONS = frozenset(
58
- {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".heic"}
18
+ from ..quiet import quiet
19
+ from ..utils import utc_now_iso
20
+ from .common import (
21
+ MAX_INDEX_TEXT_CHARS,
22
+ MAX_THUMBNAIL_CHARS,
23
+ MODALITY_IMAGE,
24
+ SUMMARY_CHARS,
25
+ THUMBNAIL_EDGE,
26
+ _sha256_file,
27
+ _sha256_text,
28
+ _split_index_text,
59
29
  )
60
- # Containers that are *only* ever audio. ``.mp4``/``.webm`` are deliberately
61
- # absent: by extension alone they are video, and a voice memo recorded in one
62
- # of them arrives through an explicit audio MIME type (or through
63
- # ``VoiceCaptureService``, where the user already said "this is a memo").
64
- # ``.mid``/``.midi`` are listed for a second reason: CPython's *built-in* mime
65
- # table has neither, so ``mimetypes`` answers "audio/midi" only on a host that
66
- # ships a system mime file (macOS reads /etc/apache2/mime.types; a slim Linux
67
- # container has nothing). Leaving them to the fallback let the platform decide
68
- # what a MIDI file is — a module table exists precisely so it does not.
69
- AUDIO_EXTENSIONS = frozenset(
70
- {".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg", ".opus", ".mid", ".midi"}
71
- )
72
- VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"})
30
+ from .ports import MultimodalPorts
73
31
 
74
- #: Why ``video`` is recognized and still refused — surfaced to the caller.
75
- VIDEO_OUT_OF_SCOPE = (
76
- "video ingestion is out of scope in this release (keyframe extraction is "
77
- "not implemented); the file was not stored"
78
- )
79
32
 
80
- #: Longest OCR/caption body kept on the node (a screenshot is not a novel).
81
- MAX_INDEX_TEXT_CHARS = 20_000
82
- #: Summary column budget, matching every other ingest door in the graph.
83
- SUMMARY_CHARS = 500
84
- #: Fixed-width chunking for OCR bodies that outgrow the summary.
85
- IMAGE_CHUNK_CHARS = 900
86
- #: Longest edge of the stored thumbnail, in pixels.
87
- THUMBNAIL_EDGE = 96
88
- #: A thumbnail is a UI affordance, not an archive — drop it past this size.
89
- MAX_THUMBNAIL_CHARS = 24_000
90
-
91
-
92
- def detect_modality(
93
- path: Optional[str] = None, mime_type: Optional[str] = None
94
- ) -> str:
95
- """``text`` | ``image`` | ``audio`` | ``video`` for one candidate file.
96
-
97
- The declared MIME type wins when it carries a usable top-level type: the
98
- capture surface saw the bytes, this function only sees a name. Otherwise
99
- the extension decides, and an unknown extension is ``text`` so existing
100
- behaviour is untouched.
101
- """
102
- declared = str(mime_type or "").strip().lower().split(";")[0].split("/")[0]
103
- if declared in {MODALITY_IMAGE, MODALITY_AUDIO, MODALITY_VIDEO}:
104
- return declared
105
- # The extension tables come before ``mimetypes`` on purpose: they are where
106
- # this module's decisions live (``.mp4`` is video unless someone who saw
107
- # the bytes says otherwise), and the stdlib table varies by platform.
108
- suffix = Path(str(path or "")).suffix.lower()
109
- if suffix in IMAGE_EXTENSIONS:
110
- return MODALITY_IMAGE
111
- if suffix in AUDIO_EXTENSIONS:
112
- return MODALITY_AUDIO
113
- if suffix in VIDEO_EXTENSIONS:
114
- return MODALITY_VIDEO
115
- if path:
116
- guessed, _ = mimetypes.guess_type(str(path))
117
- top = str(guessed or "").split("/")[0]
118
- if top in {MODALITY_IMAGE, MODALITY_AUDIO, MODALITY_VIDEO}:
119
- return top
120
- return MODALITY_TEXT
121
-
122
-
123
- # ── injected capability ports ────────────────────────────────────────────────
124
- @dataclass
125
- class MultimodalPorts:
126
- """The optional model-backed capabilities Brain Core cannot ship itself.
127
-
128
- Every field is a plain callable so the app layer can build it from
129
- ``latticeai.core.embedding_providers`` without Brain Core ever importing
130
- that package. ``None`` everywhere is the honest default: OCR still runs
131
- (``pytesseract`` is a local binary, not a model download), and everything
132
- else reports itself as unavailable.
133
- """
134
-
135
- #: ``(image_path) -> caption or None`` — a loaded VLM, or nothing.
136
- captioner: Optional[Callable[[str], Optional[str]]] = None
137
- #: ``(image_path) -> vector`` in the image space (raises when it cannot).
138
- vision_embedder: Optional[Callable[[str], List[float]]] = None
139
- #: ``(audio_path) -> transcript`` (raises/returns empty when it cannot).
140
- transcriber: Optional[Callable[[str], str]] = None
141
- #: Identity of the vision model, recorded next to every image vector.
142
- vision_model_id: str = ""
143
- #: ``image`` (own index + late fusion) or ``shared`` (same space as text).
144
- vision_space: str = MODALITY_IMAGE
145
-
146
- def describe(self) -> Dict[str, Any]:
147
- """What this install can honestly do with a picture or a recording."""
148
- return {
149
- "caption": self.captioner is not None,
150
- "vision_embedding": self.vision_embedder is not None,
151
- "transcription": self.transcriber is not None,
152
- "vision_model_id": self.vision_model_id,
153
- "vision_space": self.vision_space,
154
- }
155
-
156
-
157
- # ── image facts ──────────────────────────────────────────────────────────────
158
33
  @dataclass
159
34
  class ImageFacts:
160
35
  """Everything observed about one image, and the status of each attempt."""
@@ -369,30 +244,6 @@ def image_quality_score(facts: ImageFacts) -> Dict[str, Any]:
369
244
  return {"score": round(max(0.0, min(1.0, score)), 4), "reasons": reasons}
370
245
 
371
246
 
372
- # ── graph write ──────────────────────────────────────────────────────────────
373
- def _sha256_file(path: Path) -> str:
374
- digest = hashlib.sha256()
375
- with path.open("rb") as handle:
376
- for block in iter(lambda: handle.read(1024 * 1024), b""):
377
- digest.update(block)
378
- return digest.hexdigest()
379
-
380
-
381
- def _sha256_text(text: str) -> str:
382
- return hashlib.sha256(str(text).encode("utf-8", "ignore")).hexdigest()
383
-
384
-
385
- def _split_index_text(text: str) -> List[str]:
386
- """Fixed-width split for OCR bodies — no markdown or code structure here."""
387
- body = str(text or "").strip()
388
- if len(body) <= SUMMARY_CHARS:
389
- return []
390
- return [
391
- body[start : start + IMAGE_CHUNK_CHARS]
392
- for start in range(0, len(body), IMAGE_CHUNK_CHARS)
393
- ]
394
-
395
-
396
247
  def image_node_id(content_hash: str, workspace_id: Optional[str] = None) -> str:
397
248
  """Workspace-scoped, content-addressed id — re-ingesting is idempotent."""
398
249
  scoped = f"{workspace_id or 'legacy-global'}|{content_hash}"
@@ -583,7 +434,7 @@ def _attach_concepts(
583
434
  body = str(text or "").strip()
584
435
  if not body:
585
436
  return []
586
- from .graph._kg_common import ( # local: keeps this module's import light
437
+ from ..graph._kg_common import ( # local: keeps this module's import light
587
438
  _classify_node_type,
588
439
  _extract_concepts,
589
440
  )
@@ -591,7 +442,7 @@ def _attach_concepts(
591
442
  # The id derivation is imported rather than re-derived: two copies of a
592
443
  # node-id rule diverge, and a diverged id is a duplicate concept nobody
593
444
  # can see.
594
- from .graph.ingest import _scoped_slug_id
445
+ from ..graph.ingest import _scoped_slug_id
595
446
 
596
447
  concept_ids: List[str] = []
597
448
  for concept in _extract_concepts(body, limit=10):
@@ -645,94 +496,3 @@ def _attach_source(
645
496
  extra={"owner": owner, "workspace_id": workspace_id, "modality": MODALITY_IMAGE},
646
497
  )
647
498
  )
648
-
649
-
650
- # ── audio ────────────────────────────────────────────────────────────────────
651
- @dataclass
652
- class AudioFacts:
653
- """A recording, its transcript, and how honestly we got one."""
654
-
655
- path: str
656
- #: ``ok`` | ``unavailable`` | ``failed`` | ``supplied``
657
- transcription_status: str = "unavailable"
658
- transcript: str = ""
659
- detail: str = ""
660
- segments: List[Dict[str, Any]] = field(default_factory=list)
661
-
662
- @property
663
- def searchable(self) -> bool:
664
- return bool(self.transcript)
665
-
666
-
667
- def transcribe_audio(
668
- path: str,
669
- *,
670
- ports: Optional[MultimodalPorts] = None,
671
- transcript: Optional[str] = None,
672
- ) -> AudioFacts:
673
- """Transcribe a recording through the injected port, or say why not.
674
-
675
- ``transcript`` lets a caller that already has text (a phone's own
676
- dictation, ``VoiceCaptureService``) skip the local model entirely. An
677
- absent transcriber yields ``transcription_status="unavailable"`` and an
678
- empty transcript — the recording is still remembered by title and path,
679
- and the result never claims it is searchable.
680
- """
681
- ports = ports or MultimodalPorts()
682
- supplied = str(transcript or "").strip()
683
- if supplied:
684
- return AudioFacts(path=str(path), transcription_status="supplied", transcript=supplied)
685
- if ports.transcriber is None:
686
- return AudioFacts(
687
- path=str(path),
688
- transcription_status="unavailable",
689
- detail="no local transcriber is configured",
690
- )
691
- try:
692
- text = str(ports.transcriber(str(path)) or "").strip()
693
- except Exception as exc: # noqa: BLE001 — a broken transcriber is a state
694
- return AudioFacts(path=str(path), transcription_status="failed", detail=str(exc))
695
- if not text:
696
- return AudioFacts(
697
- path=str(path),
698
- transcription_status="failed",
699
- detail="the transcriber returned no text",
700
- )
701
- return AudioFacts(path=str(path), transcription_status="ok", transcript=text)
702
-
703
-
704
- def audio_quality_score(facts: AudioFacts) -> Dict[str, Any]:
705
- """How much of this recording is actually retrievable later."""
706
- if not facts.transcript:
707
- return {"score": 0.0, "reasons": ["no_transcript"]}
708
- # A transcript is text: length is the only honest extra signal here, and
709
- # the text pipeline scores the wording itself downstream.
710
- score = 0.5 + 0.5 * min(1.0, len(facts.transcript) / 400.0)
711
- return {"score": round(score, 4), "reasons": ["transcript"]}
712
-
713
-
714
- __all__ = [
715
- "AUDIO_EXTENSIONS",
716
- "IMAGE_CHUNK_CHARS",
717
- "IMAGE_EXTENSIONS",
718
- "MAX_INDEX_TEXT_CHARS",
719
- "MAX_THUMBNAIL_CHARS",
720
- "MODALITY_AUDIO",
721
- "MODALITY_IMAGE",
722
- "MODALITY_TEXT",
723
- "MODALITY_VIDEO",
724
- "SUMMARY_CHARS",
725
- "THUMBNAIL_EDGE",
726
- "VIDEO_EXTENSIONS",
727
- "VIDEO_OUT_OF_SCOPE",
728
- "AudioFacts",
729
- "ImageFacts",
730
- "MultimodalPorts",
731
- "audio_quality_score",
732
- "detect_modality",
733
- "extract_image_facts",
734
- "image_node_id",
735
- "image_quality_score",
736
- "transcribe_audio",
737
- "write_image_memory",
738
- ]