ltcai 11.1.0 → 11.2.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.
- package/README.md +53 -53
- package/docs/CHANGELOG.md +33 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +175 -27
- package/lattice_brain/multimodal.py +525 -5
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +55 -2
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +143 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +28 -0
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +13 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
- package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
- package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
|
@@ -46,22 +46,30 @@ from dataclasses import dataclass, field
|
|
|
46
46
|
from pathlib import Path
|
|
47
47
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
48
48
|
|
|
49
|
+
from .gates import FeatureGate
|
|
49
50
|
from .graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
|
|
50
51
|
from .multimodal import (
|
|
51
52
|
AUDIO_EXTENSIONS,
|
|
53
|
+
DEFAULT_KEYFRAMES,
|
|
52
54
|
IMAGE_EXTENSIONS,
|
|
53
55
|
MODALITY_AUDIO,
|
|
54
56
|
MODALITY_IMAGE,
|
|
55
57
|
MODALITY_VIDEO,
|
|
56
|
-
|
|
58
|
+
VIDEO_EXTENSIONS,
|
|
59
|
+
VIDEO_UNAVAILABLE_DETAIL,
|
|
57
60
|
ImageFacts,
|
|
58
61
|
MultimodalPorts,
|
|
59
62
|
audio_quality_score,
|
|
60
63
|
detect_modality,
|
|
61
64
|
extract_image_facts,
|
|
65
|
+
ffmpeg_available,
|
|
62
66
|
image_quality_score,
|
|
67
|
+
read_video_facts,
|
|
63
68
|
transcribe_audio,
|
|
69
|
+
video_frame_dir,
|
|
70
|
+
video_quality_score,
|
|
64
71
|
write_image_memory,
|
|
72
|
+
write_video_memory,
|
|
65
73
|
)
|
|
66
74
|
from .runtime.hooks import dispatch_tool
|
|
67
75
|
from .utils import utc_now_iso
|
|
@@ -127,6 +135,17 @@ DEFAULT_MAX_FILE_BYTES = 4_000_000 # matches the local-index text/code budget
|
|
|
127
135
|
LATTICEIGNORE_FILENAME = ".latticeignore"
|
|
128
136
|
# Opt-out escape hatch for the post-ingest incremental vector sync.
|
|
129
137
|
AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
|
|
138
|
+
#: Default *on*, unlike every other gate here: new material has always been made
|
|
139
|
+
#: searchable straight away, and this exists so a settings surface can turn that
|
|
140
|
+
#: off (batch reindex later) without a restart. ``FeatureGate`` parses the env
|
|
141
|
+
#: var with the same words the hand-written opt-out check used, so an untouched
|
|
142
|
+
#: install — including one with a nonsense value — answers exactly as before.
|
|
143
|
+
AUTO_VECTOR_INDEX_GATE = FeatureGate(
|
|
144
|
+
AUTO_VECTOR_INDEX_ENV,
|
|
145
|
+
default=True,
|
|
146
|
+
name="auto_vector_index",
|
|
147
|
+
detail="New material is prepared for semantic search as soon as it lands.",
|
|
148
|
+
)
|
|
130
149
|
|
|
131
150
|
# ── Multi-modal ingestion (v11.1.0 Track 3) ──────────────────────────────────
|
|
132
151
|
# Opt-in, default off, on purpose. Turning it on changes what a folder scan
|
|
@@ -135,11 +154,36 @@ AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
|
|
|
135
154
|
# flag off every routing decision below is skipped and behaviour is byte-for-
|
|
136
155
|
# byte what it was before this release.
|
|
137
156
|
ALLOW_MULTIMODAL_ENV = "LATTICEAI_ALLOW_MULTIMODAL"
|
|
157
|
+
#: The multi-modal switch, resolved when it is asked rather than frozen into
|
|
158
|
+
#: ``self`` at construction (v11.2.0). The environment variable is still the
|
|
159
|
+
#: answer for an untouched install — same var, same words, same default off —
|
|
160
|
+
#: but a settings surface can bind a resolver and move it without a restart.
|
|
161
|
+
MULTIMODAL_GATE = FeatureGate(
|
|
162
|
+
ALLOW_MULTIMODAL_ENV,
|
|
163
|
+
default=False,
|
|
164
|
+
name="allow_multimodal",
|
|
165
|
+
detail="Pictures and recordings are only ingested when this is turned on.",
|
|
166
|
+
)
|
|
167
|
+
#: Video is a *sub-switch* of the one above: with multi-modal off nothing about
|
|
168
|
+
#: video happens at all, and with it on video is included unless this is
|
|
169
|
+
#: explicitly turned off. The effective default is therefore still "no video",
|
|
170
|
+
#: and the seam exists so a settings screen can offer pictures without films.
|
|
171
|
+
ALLOW_VIDEO_ENV = "LATTICEAI_ALLOW_VIDEO"
|
|
172
|
+
VIDEO_GATE = FeatureGate(
|
|
173
|
+
ALLOW_VIDEO_ENV,
|
|
174
|
+
default=True,
|
|
175
|
+
name="allow_video",
|
|
176
|
+
detail="Videos are ingested as keyframes plus subtitles when multi-modal is on.",
|
|
177
|
+
)
|
|
138
178
|
#: Source types that name a modality outright (a caller who already knows).
|
|
139
179
|
IMAGE_SOURCE_TYPES = frozenset({"image", "screenshot", "photo"})
|
|
140
180
|
AUDIO_SOURCE_TYPES = frozenset({"audio", "voice_memo", "recording"})
|
|
181
|
+
VIDEO_SOURCE_TYPES = frozenset({"video", "screen_recording", "movie"})
|
|
141
182
|
#: Added to the folder-scan allow-list only while multimodal is enabled.
|
|
142
183
|
FOLDER_MULTIMODAL_EXTENSIONS = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS
|
|
184
|
+
#: Videos join the folder allow-list only when this machine can decode one —
|
|
185
|
+
#: scanning a folder into a pile of refusals is not a feature.
|
|
186
|
+
FOLDER_VIDEO_EXTENSIONS = VIDEO_EXTENSIONS
|
|
143
187
|
#: Graph node type for a recording. ``NodeType.AUDIO`` normalizes this on the
|
|
144
188
|
#: KG v2 write side; the legacy tables keep the label verbatim, which is what
|
|
145
189
|
#: every type-aware read (graph view, context sections, doc-gen) matches on.
|
|
@@ -499,19 +543,38 @@ class IngestionPipeline:
|
|
|
499
543
|
db_path=getattr(knowledge_graph, "db_path", None)
|
|
500
544
|
)
|
|
501
545
|
# Incremental vector sync after each successful non-duplicate ingest.
|
|
502
|
-
# Constructor opt-out AND
|
|
503
|
-
# both disable it; a vector failure
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
#
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
).strip().lower() in {"1", "true", "yes", "on"}
|
|
546
|
+
# Constructor opt-out AND gate opt-out (LATTICEAI_AUTO_VECTOR_INDEX=0,
|
|
547
|
+
# or the settings toggle bound to it) both disable it; a vector failure
|
|
548
|
+
# never fails the ingest. The gate half is asked per ingest rather than
|
|
549
|
+
# frozen here, so turning it off takes effect on the next item.
|
|
550
|
+
self._auto_vector_index_opt_in = bool(auto_vector_index)
|
|
551
|
+
# Multi-modal routing. Off unless the caller asks for it *or* the gate
|
|
552
|
+
# says yes — the env behind that gate is the escape hatch for an
|
|
553
|
+
# install with no code path to the constructor (CLI, background
|
|
554
|
+
# worker), and the gate is now asked per call so a runtime toggle can
|
|
555
|
+
# reach it. A constructor ``True`` is still a permanent yes.
|
|
556
|
+
self._multimodal_opt_in = bool(allow_multimodal)
|
|
514
557
|
self._multimodal = multimodal or MultimodalPorts()
|
|
558
|
+
self._keyframes = DEFAULT_KEYFRAMES
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def _auto_vector_index(self) -> bool:
|
|
562
|
+
"""Whether a landed ingest also syncs its vector, asked *now*."""
|
|
563
|
+
return self._auto_vector_index_opt_in and AUTO_VECTOR_INDEX_GATE.enabled()
|
|
564
|
+
|
|
565
|
+
@property
|
|
566
|
+
def _allow_multimodal(self) -> bool:
|
|
567
|
+
"""Whether pictures and recordings route by modality, asked *now*."""
|
|
568
|
+
return self._multimodal_opt_in or MULTIMODAL_GATE.enabled()
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def _allow_video(self) -> bool:
|
|
572
|
+
"""Video needs multi-modal on, its own sub-switch on, and a decoder."""
|
|
573
|
+
return self._allow_multimodal and VIDEO_GATE.enabled() and self._can_decode_video()
|
|
574
|
+
|
|
575
|
+
def _can_decode_video(self) -> bool:
|
|
576
|
+
"""An injected keyframe port counts as a decoder; otherwise, ffmpeg."""
|
|
577
|
+
return self._multimodal.keyframe_extractor is not None or ffmpeg_available()
|
|
515
578
|
|
|
516
579
|
def available(self) -> bool:
|
|
517
580
|
return self._enable and self._kg is not None
|
|
@@ -520,18 +583,39 @@ class IngestionPipeline:
|
|
|
520
583
|
"""What this pipeline will do with a picture or a recording, honestly.
|
|
521
584
|
|
|
522
585
|
``enabled`` is the flag; the rest is which model-backed capabilities
|
|
523
|
-
were actually injected. Video
|
|
524
|
-
|
|
586
|
+
were actually injected. Video reports whether it can really run — the
|
|
587
|
+
answer is no on a machine with no ffmpeg, and it says which of the two
|
|
588
|
+
reasons applies rather than leaving the surface to guess.
|
|
525
589
|
"""
|
|
590
|
+
allowed = self._allow_multimodal
|
|
591
|
+
video = self._allow_video
|
|
526
592
|
return {
|
|
527
|
-
"enabled":
|
|
528
|
-
"image":
|
|
529
|
-
"audio":
|
|
530
|
-
"video":
|
|
531
|
-
"video_detail":
|
|
593
|
+
"enabled": allowed,
|
|
594
|
+
"image": allowed,
|
|
595
|
+
"audio": allowed,
|
|
596
|
+
"video": video,
|
|
597
|
+
"video_detail": None if video else self._video_refusal(),
|
|
598
|
+
"gates": {
|
|
599
|
+
"multimodal": MULTIMODAL_GATE.describe(),
|
|
600
|
+
"video": VIDEO_GATE.describe(),
|
|
601
|
+
},
|
|
532
602
|
**self._multimodal.describe(),
|
|
533
603
|
}
|
|
534
604
|
|
|
605
|
+
def _video_refusal(self) -> str:
|
|
606
|
+
"""Why a video would be refused right now — never a stale reason."""
|
|
607
|
+
if not self._allow_multimodal:
|
|
608
|
+
return (
|
|
609
|
+
"multi-modal ingestion is off; pictures, recordings and videos "
|
|
610
|
+
f"are only stored when {ALLOW_MULTIMODAL_ENV} is on"
|
|
611
|
+
)
|
|
612
|
+
if not VIDEO_GATE.enabled():
|
|
613
|
+
return (
|
|
614
|
+
"video ingestion is turned off for this install "
|
|
615
|
+
f"({ALLOW_VIDEO_ENV}); pictures and recordings are unaffected"
|
|
616
|
+
)
|
|
617
|
+
return VIDEO_UNAVAILABLE_DETAIL
|
|
618
|
+
|
|
535
619
|
# ── public API ───────────────────────────────────────────────────────────
|
|
536
620
|
def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
|
|
537
621
|
"""Normalize, hash, route through dispatch_tool, and record provenance."""
|
|
@@ -546,10 +630,13 @@ class IngestionPipeline:
|
|
|
546
630
|
# Modality routing is a no-op while the flag is off: ``modality`` stays
|
|
547
631
|
# "text" and every branch below behaves exactly as it did before.
|
|
548
632
|
modality = self._modality_for(item, source_type)
|
|
549
|
-
if modality == MODALITY_VIDEO:
|
|
633
|
+
if modality == MODALITY_VIDEO and not self._allow_video:
|
|
634
|
+
# Recognized and refused, with the reason that actually applies
|
|
635
|
+
# right now — a missing decoder is not the same answer as a
|
|
636
|
+
# switched-off feature, and the caller can act on the difference.
|
|
550
637
|
return IngestionResult(
|
|
551
638
|
status="unavailable", source_type=source_type,
|
|
552
|
-
indexing_status="skipped", detail=
|
|
639
|
+
indexing_status="skipped", detail=self._video_refusal(),
|
|
553
640
|
)
|
|
554
641
|
|
|
555
642
|
captured_at = item.captured_at or utc_now_iso()
|
|
@@ -572,6 +659,8 @@ class IngestionPipeline:
|
|
|
572
659
|
return self._ingest_image(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
573
660
|
if modality == MODALITY_AUDIO:
|
|
574
661
|
return self._ingest_audio(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
662
|
+
if modality == MODALITY_VIDEO:
|
|
663
|
+
return self._ingest_video(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
575
664
|
if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
|
|
576
665
|
return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
577
666
|
return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
@@ -1111,7 +1200,7 @@ class IngestionPipeline:
|
|
|
1111
1200
|
summary["truncated"] = True
|
|
1112
1201
|
break
|
|
1113
1202
|
item_metadata: Dict[str, Any] = {"relative_path": rel}
|
|
1114
|
-
if ext in FOLDER_MULTIMODAL_EXTENSIONS and self._allow_multimodal:
|
|
1203
|
+
if ext in (FOLDER_MULTIMODAL_EXTENSIONS | FOLDER_VIDEO_EXTENSIONS) and self._allow_multimodal:
|
|
1115
1204
|
# Routed by modality inside ``ingest``; reading the bytes as
|
|
1116
1205
|
# UTF-8 here would only produce mojibake.
|
|
1117
1206
|
source_type = "file"
|
|
@@ -1160,10 +1249,17 @@ class IngestionPipeline:
|
|
|
1160
1249
|
return summary
|
|
1161
1250
|
|
|
1162
1251
|
def _folder_extensions(self) -> frozenset:
|
|
1163
|
-
"""Folder-scan allow-list — pictures and
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1252
|
+
"""Folder-scan allow-list — pictures, recordings and films when enabled.
|
|
1253
|
+
|
|
1254
|
+
Video joins only when this machine can actually decode one, so a scan
|
|
1255
|
+
never fills the error list with files it was always going to refuse.
|
|
1256
|
+
"""
|
|
1257
|
+
if not self._allow_multimodal:
|
|
1258
|
+
return DEFAULT_FOLDER_EXTENSIONS
|
|
1259
|
+
allowed = DEFAULT_FOLDER_EXTENSIONS | FOLDER_MULTIMODAL_EXTENSIONS
|
|
1260
|
+
if self._allow_video:
|
|
1261
|
+
return allowed | FOLDER_VIDEO_EXTENSIONS
|
|
1262
|
+
return allowed
|
|
1167
1263
|
|
|
1168
1264
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
1169
1265
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
@@ -1240,6 +1336,8 @@ class IngestionPipeline:
|
|
|
1240
1336
|
return MODALITY_IMAGE
|
|
1241
1337
|
if source_type in AUDIO_SOURCE_TYPES:
|
|
1242
1338
|
return MODALITY_AUDIO
|
|
1339
|
+
if source_type in VIDEO_SOURCE_TYPES:
|
|
1340
|
+
return MODALITY_VIDEO
|
|
1243
1341
|
if not item.path:
|
|
1244
1342
|
return "text"
|
|
1245
1343
|
return detect_modality(item.path, item.mime_type)
|
|
@@ -1353,6 +1451,47 @@ class IngestionPipeline:
|
|
|
1353
1451
|
}
|
|
1354
1452
|
return result
|
|
1355
1453
|
|
|
1454
|
+
def _ingest_video(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1455
|
+
"""Store one video as keyframes through the image door plus subtitles.
|
|
1456
|
+
|
|
1457
|
+
Nothing here is a new retrieval path: the stills become ordinary
|
|
1458
|
+
``Image`` nodes (OCR, caption, vector, thumbnail) joined by
|
|
1459
|
+
``CONTAINS_IMAGE``, and the subtitle text becomes ordinary chunks. What
|
|
1460
|
+
the ``Video`` node adds is the thing they belong to — and an honest
|
|
1461
|
+
body when there were no subtitles to read.
|
|
1462
|
+
"""
|
|
1463
|
+
path = self._resolve_file_path(item)
|
|
1464
|
+
facts = read_video_facts(
|
|
1465
|
+
str(path),
|
|
1466
|
+
video_frame_dir(getattr(self._kg, "blob_dir", path.parent), _file_digest(path)),
|
|
1467
|
+
count=self._keyframes,
|
|
1468
|
+
ports=self._multimodal,
|
|
1469
|
+
subtitle_text=item.text,
|
|
1470
|
+
)
|
|
1471
|
+
result = write_video_memory(
|
|
1472
|
+
self._kg,
|
|
1473
|
+
path=path,
|
|
1474
|
+
facts=facts,
|
|
1475
|
+
title=item.title or path.stem,
|
|
1476
|
+
source_type=source_type if source_type in VIDEO_SOURCE_TYPES else MODALITY_VIDEO,
|
|
1477
|
+
source_uri=item.source_uri,
|
|
1478
|
+
owner=owner,
|
|
1479
|
+
workspace_id=item.workspace_id,
|
|
1480
|
+
conversation_id=item.conversation_id,
|
|
1481
|
+
captured_at=captured_at,
|
|
1482
|
+
modified_at=item.modified_at,
|
|
1483
|
+
permissions=item.permissions,
|
|
1484
|
+
extra_metadata={"mime_type": item.mime_type, **(item.metadata or {})},
|
|
1485
|
+
ports=self._multimodal,
|
|
1486
|
+
)
|
|
1487
|
+
quality = video_quality_score(facts)
|
|
1488
|
+
result["extraction_quality"] = {
|
|
1489
|
+
"score": quality["score"],
|
|
1490
|
+
"level": _quality_level(quality["score"]),
|
|
1491
|
+
"reasons": quality["reasons"],
|
|
1492
|
+
}
|
|
1493
|
+
return result
|
|
1494
|
+
|
|
1356
1495
|
def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1357
1496
|
path = self._resolve_file_path(item)
|
|
1358
1497
|
return self._kg.ingest_document(
|
|
@@ -1375,3 +1514,12 @@ class IngestionPipeline:
|
|
|
1375
1514
|
def content_hash_text(text: str) -> str:
|
|
1376
1515
|
"""Canonical content hash for a text payload (matches store hashing scheme)."""
|
|
1377
1516
|
return hashlib.sha256((text or "").encode("utf-8", "ignore")).hexdigest()
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _file_digest(path: Path) -> str:
|
|
1520
|
+
"""Streaming sha256 of a file — the key a video's frame folder is named by."""
|
|
1521
|
+
digest = hashlib.sha256()
|
|
1522
|
+
with path.open("rb") as handle:
|
|
1523
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1524
|
+
digest.update(block)
|
|
1525
|
+
return digest.hexdigest()
|