ltcai 11.0.1 → 11.1.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 (95) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +28 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/PERFORMANCE.md +71 -18
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/embeddings.py +12 -37
  13. package/lattice_brain/graph/discovery_index.py +30 -32
  14. package/lattice_brain/graph/image_vectors.py +230 -0
  15. package/lattice_brain/graph/ingest.py +11 -5
  16. package/lattice_brain/graph/provenance.py +27 -2
  17. package/lattice_brain/graph/retrieval.py +113 -2
  18. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  19. package/lattice_brain/graph/schema.py +9 -0
  20. package/lattice_brain/ingestion.py +209 -4
  21. package/lattice_brain/multimodal.py +738 -0
  22. package/lattice_brain/runtime/multi_agent.py +1 -1
  23. package/lattice_brain/self_model.py +77 -22
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/memory.py +128 -1
  26. package/latticeai/core/agent.py +5 -1
  27. package/latticeai/core/agent_prompts.py +66 -0
  28. package/latticeai/core/context_builder.py +92 -8
  29. package/latticeai/core/embedding_providers.py +528 -0
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/messages.py +37 -0
  33. package/latticeai/core/workspace_os.py +43 -0
  34. package/latticeai/core/workspace_os_constants.py +1 -1
  35. package/latticeai/core/workspace_reorganization.py +335 -0
  36. package/latticeai/runtime/build_phases.py +5 -2
  37. package/latticeai/runtime/persistence_runtime.py +41 -4
  38. package/latticeai/runtime/runtime_context.py +1 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/change_proposals.py +50 -10
  41. package/latticeai/services/memory_service.py +34 -0
  42. package/latticeai/services/multimodal_ports.py +87 -0
  43. package/latticeai/services/product_readiness.py +1 -1
  44. package/latticeai/services/self_model_service.py +171 -0
  45. package/latticeai/services/voice_capture.py +27 -1
  46. package/package.json +1 -1
  47. package/scripts/check_current_release_docs.mjs +1 -1
  48. package/scripts/release_screen_claims.json +9 -0
  49. package/src-tauri/Cargo.lock +1 -1
  50. package/src-tauri/Cargo.toml +1 -1
  51. package/src-tauri/tauri.conf.json +1 -1
  52. package/static/app/asset-manifest.json +37 -37
  53. package/static/app/assets/{Act-D4zSxFR-.js → Act-D0HWqtn0.js} +1 -1
  54. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
  55. package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
  56. package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
  57. package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
  58. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
  59. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
  60. package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
  61. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
  62. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
  63. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
  64. package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
  65. package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
  66. package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
  67. package/static/app/assets/brain-uMb_5hnO.js +1 -0
  68. package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
  69. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
  70. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
  71. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
  72. package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
  73. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
  74. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
  75. package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
  76. package/static/app/assets/index-vtEfYvQY.css +2 -0
  77. package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
  78. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
  79. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
  80. package/static/app/assets/search-DkhnOKZt.js +1 -0
  81. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
  82. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
  83. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
  84. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
  85. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
  86. package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
  87. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.js} +1 -1
  88. package/static/app/index.html +4 -4
  89. package/static/sw.js +1 -1
  90. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  91. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  92. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  93. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  94. package/static/app/assets/index-CkzokZAj.css +0 -2
  95. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -0,0 +1,738 @@
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``.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import base64
41
+ import hashlib
42
+ import io
43
+ import mimetypes
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import Any, Callable, Dict, List, Optional
47
+
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"}
59
+ )
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"})
73
+
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
+
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
+ @dataclass
159
+ class ImageFacts:
160
+ """Everything observed about one image, and the status of each attempt."""
161
+
162
+ path: str
163
+ width: Optional[int] = None
164
+ height: Optional[int] = None
165
+ image_format: Optional[str] = None
166
+ mode: Optional[str] = None
167
+ #: ``ok`` | ``empty`` | ``unavailable`` | ``failed`` | ``skipped``
168
+ ocr_status: str = "skipped"
169
+ ocr_text: str = ""
170
+ ocr_detail: str = ""
171
+ #: ``ok`` | ``unavailable``
172
+ caption_status: str = "unavailable"
173
+ caption: Optional[str] = None
174
+ #: ``ok`` | ``unavailable`` | ``failed``
175
+ embedding_status: str = "unavailable"
176
+ embedding: Optional[List[float]] = None
177
+ embedding_detail: str = ""
178
+ thumbnail: Optional[str] = None
179
+ #: Set when the file could not be opened as an image at all.
180
+ error: str = ""
181
+
182
+ @property
183
+ def readable(self) -> bool:
184
+ return not self.error
185
+
186
+ def index_text(self) -> str:
187
+ """The text a search engine can actually match this image on."""
188
+ parts = [part for part in (self.caption, self.ocr_text) if part]
189
+ return "\n".join(parts).strip()[:MAX_INDEX_TEXT_CHARS]
190
+
191
+ def as_metadata(self) -> Dict[str, Any]:
192
+ """Flat, JSON-safe view stored on the graph node."""
193
+ payload: Dict[str, Any] = {
194
+ "modality": MODALITY_IMAGE,
195
+ "width": self.width,
196
+ "height": self.height,
197
+ "format": self.image_format,
198
+ "mode": self.mode,
199
+ "ocr_status": self.ocr_status,
200
+ "ocr_chars": len(self.ocr_text),
201
+ "caption_status": self.caption_status,
202
+ "vision_embedding": self.embedding_status,
203
+ }
204
+ if self.ocr_text:
205
+ payload["ocr_text"] = self.ocr_text
206
+ if self.ocr_detail:
207
+ payload["ocr_detail"] = self.ocr_detail
208
+ if self.caption:
209
+ payload["caption"] = self.caption
210
+ if self.embedding_detail:
211
+ payload["vision_embedding_detail"] = self.embedding_detail
212
+ if self.thumbnail:
213
+ payload["thumbnail"] = self.thumbnail
214
+ if self.error:
215
+ payload["image_error"] = self.error
216
+ return payload
217
+
218
+
219
+ def _open_image(path: str) -> Any:
220
+ """Pillow's ``Image.open`` behind a guarded import."""
221
+ from PIL import Image # local import: keeps the module importable without it
222
+
223
+ return Image.open(str(path))
224
+
225
+
226
+ def _thumbnail_data_uri(image: Any, edge: int = THUMBNAIL_EDGE) -> Optional[str]:
227
+ """A tiny inline PNG the Evidence panel can render with no new route.
228
+
229
+ Serving the original file would mean either a new static route over the
230
+ user's disk or reusing ``/local/serve``, which exists precisely to make
231
+ every read pass an explicit approval. A 96px data URI on the node dodges
232
+ both: it is already inside the graph the user is looking at.
233
+ """
234
+ try:
235
+ small = image.copy()
236
+ small.thumbnail((edge, edge))
237
+ if small.mode not in {"RGB", "L"}:
238
+ small = small.convert("RGB")
239
+ buffer = io.BytesIO()
240
+ small.save(buffer, format="PNG")
241
+ except Exception: # noqa: BLE001 — a missing thumbnail is not a failed ingest
242
+ quiet()
243
+ return None
244
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
245
+ if len(encoded) > MAX_THUMBNAIL_CHARS:
246
+ return None
247
+ return f"data:image/png;base64,{encoded}"
248
+
249
+
250
+ def _run_ocr(image: Any) -> Dict[str, str]:
251
+ """OCR through ``pytesseract`` when it is installed, honestly otherwise."""
252
+ try:
253
+ import pytesseract # optional local binary + wrapper
254
+ except Exception as exc: # noqa: BLE001 — absence is a state, not an error
255
+ return {"status": "unavailable", "text": "", "detail": str(exc)}
256
+ try:
257
+ text = str(pytesseract.image_to_string(image) or "").strip()
258
+ except Exception as exc: # noqa: BLE001 — a broken OCR runtime is a state
259
+ return {"status": "failed", "text": "", "detail": str(exc)}
260
+ if not text:
261
+ return {"status": "empty", "text": "", "detail": "no text found in the image"}
262
+ return {"status": "ok", "text": text[:MAX_INDEX_TEXT_CHARS], "detail": ""}
263
+
264
+
265
+ def extract_image_facts(
266
+ path: str,
267
+ *,
268
+ ports: Optional[MultimodalPorts] = None,
269
+ ocr: bool = True,
270
+ thumbnail: bool = True,
271
+ ) -> ImageFacts:
272
+ """Observe one image: size, OCR, caption, vector — each with its status.
273
+
274
+ Never raises. An unreadable file returns ``ImageFacts(error=...)`` so the
275
+ caller can record "we saw this file and could not read it" instead of
276
+ losing the memory entirely.
277
+ """
278
+ ports = ports or MultimodalPorts()
279
+ facts = ImageFacts(path=str(path))
280
+ try:
281
+ with _open_image(path) as image:
282
+ facts.width = int(image.width)
283
+ facts.height = int(image.height)
284
+ facts.image_format = image.format
285
+ facts.mode = image.mode
286
+ if ocr:
287
+ result = _run_ocr(image)
288
+ facts.ocr_status = result["status"]
289
+ facts.ocr_text = result["text"]
290
+ facts.ocr_detail = result["detail"]
291
+ if thumbnail:
292
+ facts.thumbnail = _thumbnail_data_uri(image)
293
+ except Exception as exc: # noqa: BLE001 — an unreadable image is a state
294
+ facts.error = str(exc)
295
+ return facts
296
+
297
+ if ports.captioner is not None:
298
+ caption = _safe_caption(ports.captioner, facts.path)
299
+ if caption:
300
+ facts.caption = caption
301
+ facts.caption_status = "ok"
302
+
303
+ if ports.vision_embedder is not None:
304
+ _apply_vision_embedding(facts, ports.vision_embedder)
305
+ return facts
306
+
307
+
308
+ def _safe_caption(
309
+ captioner: Callable[[str], Optional[str]], path: str
310
+ ) -> Optional[str]:
311
+ """Ask the VLM; a failure means *no caption*, never an invented one."""
312
+ try:
313
+ caption = captioner(path)
314
+ except Exception: # noqa: BLE001 — a broken captioner must not fail an ingest
315
+ quiet()
316
+ return None
317
+ cleaned = str(caption or "").strip()
318
+ return cleaned or None
319
+
320
+
321
+ def _apply_vision_embedding(
322
+ facts: ImageFacts, embedder: Callable[[str], List[float]]
323
+ ) -> None:
324
+ try:
325
+ vector = [float(value) for value in embedder(facts.path)]
326
+ except Exception as exc: # noqa: BLE001 — an absent model is not a failed ingest
327
+ facts.embedding_status = "failed"
328
+ facts.embedding_detail = str(exc)
329
+ return
330
+ if not vector:
331
+ facts.embedding_status = "failed"
332
+ facts.embedding_detail = "vision provider returned an empty vector"
333
+ return
334
+ facts.embedding = vector
335
+ facts.embedding_status = "ok"
336
+
337
+
338
+ # ── extraction quality for pictures ──────────────────────────────────────────
339
+ def image_quality_score(facts: ImageFacts) -> Dict[str, Any]:
340
+ """``{"score": float, "reasons": [...]}`` for an image memory.
341
+
342
+ Deliberately *not* a judgement about the photograph: it scores how much of
343
+ this image the Brain can actually retrieve later. Pixels alone are worth
344
+ little to a text query, OCR text is worth the most, a caption is worth a
345
+ lot, and a vector is worth something even without either.
346
+ """
347
+ if not facts.readable:
348
+ return {"score": 0.0, "reasons": ["image_unreadable"]}
349
+ reasons: List[str] = []
350
+ score = 0.15 # we know it is an image and how big it is
351
+ if facts.ocr_text:
352
+ # 400+ characters of recognized text is a page, not a label.
353
+ score += 0.45 * min(1.0, len(facts.ocr_text) / 400.0)
354
+ reasons.append("ocr_text")
355
+ elif facts.ocr_status == "unavailable":
356
+ reasons.append("ocr_unavailable")
357
+ elif facts.ocr_status == "skipped":
358
+ reasons.append("ocr_skipped")
359
+ else:
360
+ reasons.append("no_ocr_text")
361
+ if facts.caption:
362
+ score += 0.3
363
+ reasons.append("vision_caption")
364
+ else:
365
+ reasons.append("no_vision_caption")
366
+ if facts.embedding_status == "ok":
367
+ score += 0.1
368
+ reasons.append("vision_embedding")
369
+ return {"score": round(max(0.0, min(1.0, score)), 4), "reasons": reasons}
370
+
371
+
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
+ def image_node_id(content_hash: str, workspace_id: Optional[str] = None) -> str:
397
+ """Workspace-scoped, content-addressed id — re-ingesting is idempotent."""
398
+ scoped = f"{workspace_id or 'legacy-global'}|{content_hash}"
399
+ return f"image:{_sha256_text(scoped)[:24]}"
400
+
401
+
402
+ def write_image_memory(
403
+ store: Any,
404
+ *,
405
+ path: Path,
406
+ facts: ImageFacts,
407
+ title: str,
408
+ source_type: str = MODALITY_IMAGE,
409
+ source_uri: Optional[str] = None,
410
+ owner: Optional[str] = None,
411
+ workspace_id: Optional[str] = None,
412
+ conversation_id: Optional[str] = None,
413
+ captured_at: Optional[str] = None,
414
+ modified_at: Optional[str] = None,
415
+ permissions: Optional[Dict[str, Any]] = None,
416
+ extra_metadata: Optional[Dict[str, Any]] = None,
417
+ ) -> Dict[str, Any]:
418
+ """Write one ``Image`` node (plus ``ImageText``/chunks) into the graph.
419
+
420
+ The node is the image itself rather than a ``Document`` that happens to be
421
+ a picture, because that is what the rest of the product reasons about: the
422
+ graph already declares ``Image``/``ImageText``/``CONTAINS_IMAGE``, and
423
+ ``hybrid_search`` already ranks ``Image`` as a first-class result type.
424
+
425
+ Uses the same cross-mixin write door (``_upsert_node``/``_upsert_edge``/
426
+ ``_upsert_chunk``) that every other ingest path uses — the store exposes no
427
+ public node writer, and inventing a second one for images would be a
428
+ parallel write path to keep in sync forever.
429
+ """
430
+ captured_at = captured_at or utc_now_iso()
431
+ content_hash = _sha256_file(path)
432
+ node_id = image_node_id(content_hash, workspace_id)
433
+ index_text = facts.index_text()
434
+ metadata: Dict[str, Any] = {
435
+ "filename": path.name,
436
+ "file_path": str(path),
437
+ "ext": path.suffix.lower(),
438
+ "bytes": path.stat().st_size,
439
+ "sha256": content_hash,
440
+ "content_hash": content_hash,
441
+ "source_type": source_type,
442
+ "source_uri": source_uri or str(path),
443
+ "captured_at": captured_at,
444
+ "modified_at": modified_at,
445
+ "owner": owner,
446
+ "workspace_id": workspace_id,
447
+ "permissions": permissions or {},
448
+ "conversation_id": conversation_id,
449
+ **facts.as_metadata(),
450
+ **(extra_metadata or {}),
451
+ }
452
+ # Honest summary: when nothing could be read out of the picture, say so
453
+ # rather than leaving a blank card that looks like a failed render.
454
+ summary = index_text[:SUMMARY_CHARS] or f"[{MODALITY_IMAGE}] {path.name}"
455
+ chunk_ids: List[str] = []
456
+
457
+ with store._connect() as conn:
458
+ duplicate = (
459
+ conn.execute("SELECT 1 FROM nodes WHERE id=? LIMIT 1", (node_id,)).fetchone()
460
+ is not None
461
+ )
462
+ store._upsert_node(
463
+ conn,
464
+ node_id,
465
+ "Image",
466
+ title or path.name,
467
+ summary=summary,
468
+ metadata=metadata,
469
+ raw=metadata,
470
+ owner=owner,
471
+ workspace_id=workspace_id,
472
+ )
473
+ if facts.ocr_text:
474
+ image_text_id = f"imagetext:{_sha256_text(f'{node_id}:ocr')[:24]}"
475
+ store._upsert_node(
476
+ conn,
477
+ image_text_id,
478
+ "ImageText",
479
+ f"{path.name} OCR",
480
+ summary=facts.ocr_text[:700],
481
+ metadata={
482
+ "source_node": node_id,
483
+ "chars": len(facts.ocr_text),
484
+ "workspace_id": workspace_id,
485
+ },
486
+ owner=owner,
487
+ workspace_id=workspace_id,
488
+ )
489
+ store._upsert_edge(
490
+ conn,
491
+ node_id,
492
+ image_text_id,
493
+ "포함함",
494
+ weight=0.8,
495
+ metadata={"source": "ocr", "workspace_id": workspace_id},
496
+ )
497
+ for index, piece in enumerate(_split_index_text(index_text)):
498
+ chunk_id = f"chunk:{_sha256_text(f'{node_id}:{index}:{piece}')[:24]}"
499
+ chunk_ids.append(chunk_id)
500
+ chunk_meta = {
501
+ "index": index,
502
+ "source_node": node_id,
503
+ "workspace_id": workspace_id,
504
+ "modality": MODALITY_IMAGE,
505
+ }
506
+ store._upsert_node(
507
+ conn,
508
+ chunk_id,
509
+ "Chunk",
510
+ f"{path.name} chunk {index + 1}",
511
+ summary=piece[:SUMMARY_CHARS],
512
+ metadata=chunk_meta,
513
+ owner=owner,
514
+ workspace_id=workspace_id,
515
+ )
516
+ store._upsert_chunk(
517
+ conn,
518
+ chunk_id=chunk_id,
519
+ source_node=node_id,
520
+ text=piece,
521
+ metadata=chunk_meta,
522
+ )
523
+ store._upsert_edge(conn, node_id, chunk_id, "포함함")
524
+ # Concepts come from what is *in* the picture, never from its name:
525
+ # "IMG_2381" is not a topic, and turning filenames into concept nodes
526
+ # would fill the graph with hubs that mean nothing.
527
+ concept_ids = _attach_concepts(
528
+ store,
529
+ conn,
530
+ node_id=node_id,
531
+ text=index_text,
532
+ owner=owner,
533
+ workspace_id=workspace_id,
534
+ )
535
+ source_node_id = _attach_source(
536
+ store,
537
+ conn,
538
+ node_id=node_id,
539
+ source_type=source_type,
540
+ source_uri=source_uri or str(path),
541
+ title=title or path.name,
542
+ content_hash=content_hash,
543
+ captured_at=captured_at,
544
+ owner=owner,
545
+ workspace_id=workspace_id,
546
+ )
547
+ metadata["concepts"] = concept_ids
548
+ return {
549
+ "node_id": node_id,
550
+ "type": "Image",
551
+ "title": title or path.name,
552
+ "sha256": content_hash,
553
+ "content_hash": content_hash,
554
+ "source_node_id": source_node_id,
555
+ "chunk_ids": chunk_ids,
556
+ "chunk_count": len(chunk_ids),
557
+ "duplicate": duplicate,
558
+ "captured_at": captured_at,
559
+ "metadata": metadata,
560
+ }
561
+
562
+
563
+ def _attach_concepts(
564
+ store: Any,
565
+ conn: Any,
566
+ *,
567
+ node_id: str,
568
+ text: str,
569
+ owner: Optional[str],
570
+ workspace_id: Optional[str],
571
+ ) -> List[str]:
572
+ """Pull concepts out of the caption/OCR so the picture joins the graph.
573
+
574
+ This is what "the caption contributes to the graph" means concretely: the
575
+ same extractor every text door uses, over the same text, producing the same
576
+ ``Concept``/``Feature``/… nodes and ``포함함`` edges — so a photo of a
577
+ whiteboard about Q3 planning is one hop from every note about Q3 planning,
578
+ instead of being an island that only exact search can reach.
579
+
580
+ An image with nothing readable in it yields no concepts, which is correct:
581
+ there is nothing to say about it.
582
+ """
583
+ body = str(text or "").strip()
584
+ if not body:
585
+ return []
586
+ from .graph._kg_common import ( # local: keeps this module's import light
587
+ _classify_node_type,
588
+ _extract_concepts,
589
+ )
590
+
591
+ # The id derivation is imported rather than re-derived: two copies of a
592
+ # node-id rule diverge, and a diverged id is a duplicate concept nobody
593
+ # can see.
594
+ from .graph.ingest import _scoped_slug_id
595
+
596
+ concept_ids: List[str] = []
597
+ for concept in _extract_concepts(body, limit=10):
598
+ node_type = _classify_node_type(concept, body)
599
+ concept_id = _scoped_slug_id(node_type.lower(), concept, workspace_id)
600
+ store._upsert_node(
601
+ conn,
602
+ concept_id,
603
+ node_type,
604
+ concept,
605
+ metadata={
606
+ "auto_extracted": True,
607
+ "source_node": node_id,
608
+ "modality": MODALITY_IMAGE,
609
+ "workspace_id": workspace_id,
610
+ },
611
+ owner=owner,
612
+ workspace_id=workspace_id,
613
+ )
614
+ store._upsert_edge(conn, node_id, concept_id, "포함함", weight=0.8)
615
+ concept_ids.append(concept_id)
616
+ return concept_ids
617
+
618
+
619
+ def _attach_source(
620
+ store: Any,
621
+ conn: Any,
622
+ *,
623
+ node_id: str,
624
+ source_type: str,
625
+ source_uri: str,
626
+ title: str,
627
+ content_hash: str,
628
+ captured_at: str,
629
+ owner: Optional[str],
630
+ workspace_id: Optional[str],
631
+ ) -> Optional[str]:
632
+ """Link the image to a ``Source`` node when the store can make one."""
633
+ attach = getattr(store, "_attach_source_node", None)
634
+ if not callable(attach):
635
+ return None
636
+ return str(
637
+ attach(
638
+ conn,
639
+ node_id,
640
+ source_type=source_type,
641
+ source_uri=source_uri,
642
+ title=title,
643
+ content_hash=content_hash,
644
+ captured_at=captured_at,
645
+ extra={"owner": owner, "workspace_id": workspace_id, "modality": MODALITY_IMAGE},
646
+ )
647
+ )
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
+ ]