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.
- package/README.md +55 -43
- package/docs/CHANGELOG.md +28 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +71 -18
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +12 -37
- package/lattice_brain/graph/discovery_index.py +30 -32
- package/lattice_brain/graph/image_vectors.py +230 -0
- package/lattice_brain/graph/ingest.py +11 -5
- package/lattice_brain/graph/provenance.py +27 -2
- package/lattice_brain/graph/retrieval.py +113 -2
- package/lattice_brain/graph/retrieval_docgen.py +6 -6
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/ingestion.py +209 -4
- package/lattice_brain/multimodal.py +738 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/self_model.py +77 -22
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/memory.py +128 -1
- package/latticeai/core/agent.py +5 -1
- package/latticeai/core/agent_prompts.py +66 -0
- package/latticeai/core/context_builder.py +92 -8
- package/latticeai/core/embedding_providers.py +528 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +37 -0
- package/latticeai/core/workspace_os.py +43 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_reorganization.py +335 -0
- package/latticeai/runtime/build_phases.py +5 -2
- package/latticeai/runtime/persistence_runtime.py +41 -4
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/change_proposals.py +50 -10
- package/latticeai/services/memory_service.py +34 -0
- package/latticeai/services/multimodal_ports.py +87 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/self_model_service.py +171 -0
- package/latticeai/services/voice_capture.py +27 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/release_screen_claims.json +9 -0
- 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-D4zSxFR-.js → Act-D0HWqtn0.js} +1 -1
- package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D-QDW-A4.js} +1 -1
- package/static/app/assets/{Brain-C2EqQg74.js → Brain-CzCsI1mi.js} +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +2 -0
- package/static/app/assets/BrainSignals-2dHQNkns.js +1 -0
- package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CT8v1StE.js} +1 -1
- package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DoLXC2KH.js} +1 -1
- package/static/app/assets/{Library-BN0HYOfc.js → Library-DDoxFE5c.js} +1 -1
- package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-BXMWIK_2.js} +1 -1
- package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-DOYf7JIs.js} +1 -1
- package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-COQsqidK.js} +1 -1
- package/static/app/assets/{System-BElUcSSw.js → System-BRllvYXd.js} +1 -1
- package/static/app/assets/arrow-left-DnyMzss-.js +1 -0
- package/static/app/assets/{bot--qYHMtkP.js → bot-4BvN07ux.js} +1 -1
- package/static/app/assets/brain-uMb_5hnO.js +1 -0
- package/static/app/assets/{button-51Z3rsuv.js → button-CDjtnAoU.js} +1 -1
- package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-D_RMn7tp.js} +1 -1
- package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-B5OpB8ae.js} +1 -1
- package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-BIlWInHf.js} +1 -1
- package/static/app/assets/{download-G-2olkWz.js → download-BtjXfL3z.js} +1 -1
- package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-DefMpxI2.js} +1 -1
- package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-BQ8NZVkw.js} +1 -1
- package/static/app/assets/{index-D7Rr-J2Y.js → index-0AvoEBzJ.js} +3 -3
- package/static/app/assets/index-vtEfYvQY.css +2 -0
- package/static/app/assets/{input-D4w_BZWl.js → input-B_5ZJ9oy.js} +1 -1
- package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-BqZ5tsgL.js} +1 -1
- package/static/app/assets/{primitives-d0g9pvzS.js → primitives-CVwew78r.js} +1 -1
- package/static/app/assets/search-DkhnOKZt.js +1 -0
- package/static/app/assets/{share-2-NmD7e_oV.js → share-2-D5zg_0fY.js} +1 -1
- package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-B5pZzkUb.js} +1 -1
- package/static/app/assets/{textarea-BPAJDc-0.js → textarea-nEVIweKY.js} +1 -1
- package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-Cm99AHlz.js} +1 -1
- package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-Dm__N6bL.js} +1 -1
- package/static/app/assets/{utils-DG1_ExrP.js → utils-DcDMoZIe.js} +1 -1
- package/static/app/assets/{workspace-CWVf3gsI.js → workspace-LtRRSKTf.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
- package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
- package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
- package/static/app/assets/brain-DDCLjRqO.js +0 -1
- package/static/app/assets/index-CkzokZAj.css +0 -2
- package/static/app/assets/search-BLCYt75v.js +0 -1
|
@@ -47,6 +47,22 @@ from pathlib import Path
|
|
|
47
47
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
48
48
|
|
|
49
49
|
from .graph.vector_index import DEFAULT_TICK_LIMIT as VECTOR_TICK_LIMIT
|
|
50
|
+
from .multimodal import (
|
|
51
|
+
AUDIO_EXTENSIONS,
|
|
52
|
+
IMAGE_EXTENSIONS,
|
|
53
|
+
MODALITY_AUDIO,
|
|
54
|
+
MODALITY_IMAGE,
|
|
55
|
+
MODALITY_VIDEO,
|
|
56
|
+
VIDEO_OUT_OF_SCOPE,
|
|
57
|
+
ImageFacts,
|
|
58
|
+
MultimodalPorts,
|
|
59
|
+
audio_quality_score,
|
|
60
|
+
detect_modality,
|
|
61
|
+
extract_image_facts,
|
|
62
|
+
image_quality_score,
|
|
63
|
+
transcribe_audio,
|
|
64
|
+
write_image_memory,
|
|
65
|
+
)
|
|
50
66
|
from .runtime.hooks import dispatch_tool
|
|
51
67
|
from .utils import utc_now_iso
|
|
52
68
|
|
|
@@ -112,6 +128,23 @@ LATTICEIGNORE_FILENAME = ".latticeignore"
|
|
|
112
128
|
# Opt-out escape hatch for the post-ingest incremental vector sync.
|
|
113
129
|
AUTO_VECTOR_INDEX_ENV = "LATTICEAI_AUTO_VECTOR_INDEX"
|
|
114
130
|
|
|
131
|
+
# ── Multi-modal ingestion (v11.1.0 Track 3) ──────────────────────────────────
|
|
132
|
+
# Opt-in, default off, on purpose. Turning it on changes what a folder scan
|
|
133
|
+
# *stores* (pictures and recordings, with OCR and — if a model is loaded —
|
|
134
|
+
# captions and vectors), and that is the user's call, not a default. With the
|
|
135
|
+
# flag off every routing decision below is skipped and behaviour is byte-for-
|
|
136
|
+
# byte what it was before this release.
|
|
137
|
+
ALLOW_MULTIMODAL_ENV = "LATTICEAI_ALLOW_MULTIMODAL"
|
|
138
|
+
#: Source types that name a modality outright (a caller who already knows).
|
|
139
|
+
IMAGE_SOURCE_TYPES = frozenset({"image", "screenshot", "photo"})
|
|
140
|
+
AUDIO_SOURCE_TYPES = frozenset({"audio", "voice_memo", "recording"})
|
|
141
|
+
#: Added to the folder-scan allow-list only while multimodal is enabled.
|
|
142
|
+
FOLDER_MULTIMODAL_EXTENSIONS = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS
|
|
143
|
+
#: Graph node type for a recording. ``NodeType.AUDIO`` normalizes this on the
|
|
144
|
+
#: KG v2 write side; the legacy tables keep the label verbatim, which is what
|
|
145
|
+
#: every type-aware read (graph view, context sections, doc-gen) matches on.
|
|
146
|
+
AUDIO_NODE_TYPE = "Audio"
|
|
147
|
+
|
|
115
148
|
# ── Extraction quality heuristics (v9.8.0 A1) ────────────────────────────────
|
|
116
149
|
# Pure heuristics over the extracted text — no model calls, no network. The
|
|
117
150
|
# score is *advisory*: it never blocks an ingest, it only annotates the result
|
|
@@ -448,6 +481,8 @@ class IngestionPipeline:
|
|
|
448
481
|
pipeline_name: str = "unified-ingestion",
|
|
449
482
|
bg_queue: Optional[BackgroundIngestionQueue] = None,
|
|
450
483
|
auto_vector_index: bool = True,
|
|
484
|
+
allow_multimodal: bool = False,
|
|
485
|
+
multimodal: Optional[MultimodalPorts] = None,
|
|
451
486
|
) -> None:
|
|
452
487
|
self._kg = knowledge_graph
|
|
453
488
|
self._hooks = hooks
|
|
@@ -470,10 +505,33 @@ class IngestionPipeline:
|
|
|
470
505
|
"0", "false", "no", "off",
|
|
471
506
|
}
|
|
472
507
|
self._auto_vector_index = bool(auto_vector_index) and env_flag
|
|
508
|
+
# Multi-modal routing. Off unless the caller asks for it *or* the env
|
|
509
|
+
# flag is set — the env is the escape hatch for an install that has no
|
|
510
|
+
# code path to the constructor (CLI, background worker).
|
|
511
|
+
self._allow_multimodal = bool(allow_multimodal) or os.getenv(
|
|
512
|
+
ALLOW_MULTIMODAL_ENV, "0"
|
|
513
|
+
).strip().lower() in {"1", "true", "yes", "on"}
|
|
514
|
+
self._multimodal = multimodal or MultimodalPorts()
|
|
473
515
|
|
|
474
516
|
def available(self) -> bool:
|
|
475
517
|
return self._enable and self._kg is not None
|
|
476
518
|
|
|
519
|
+
def multimodal_status(self) -> Dict[str, Any]:
|
|
520
|
+
"""What this pipeline will do with a picture or a recording, honestly.
|
|
521
|
+
|
|
522
|
+
``enabled`` is the flag; the rest is which model-backed capabilities
|
|
523
|
+
were actually injected. Video is listed as unsupported rather than
|
|
524
|
+
omitted, so the surface can say *why* a ``.mov`` was refused.
|
|
525
|
+
"""
|
|
526
|
+
return {
|
|
527
|
+
"enabled": self._allow_multimodal,
|
|
528
|
+
"image": self._allow_multimodal,
|
|
529
|
+
"audio": self._allow_multimodal,
|
|
530
|
+
"video": False,
|
|
531
|
+
"video_detail": VIDEO_OUT_OF_SCOPE,
|
|
532
|
+
**self._multimodal.describe(),
|
|
533
|
+
}
|
|
534
|
+
|
|
477
535
|
# ── public API ───────────────────────────────────────────────────────────
|
|
478
536
|
def ingest(self, item: IngestionItem, *, user_email: Optional[str] = None) -> IngestionResult:
|
|
479
537
|
"""Normalize, hash, route through dispatch_tool, and record provenance."""
|
|
@@ -485,6 +543,15 @@ class IngestionPipeline:
|
|
|
485
543
|
detail="Knowledge Graph is disabled (LATTICEAI_ENABLE_GRAPH).",
|
|
486
544
|
)
|
|
487
545
|
|
|
546
|
+
# Modality routing is a no-op while the flag is off: ``modality`` stays
|
|
547
|
+
# "text" and every branch below behaves exactly as it did before.
|
|
548
|
+
modality = self._modality_for(item, source_type)
|
|
549
|
+
if modality == MODALITY_VIDEO:
|
|
550
|
+
return IngestionResult(
|
|
551
|
+
status="unavailable", source_type=source_type,
|
|
552
|
+
indexing_status="skipped", detail=VIDEO_OUT_OF_SCOPE,
|
|
553
|
+
)
|
|
554
|
+
|
|
488
555
|
captured_at = item.captured_at or utc_now_iso()
|
|
489
556
|
owner = item.owner or user_email
|
|
490
557
|
tool_name = f"kg_ingest.{source_type}"
|
|
@@ -501,6 +568,10 @@ class IngestionPipeline:
|
|
|
501
568
|
return self._ingest_chat(item, source_type=source_type, owner=owner)
|
|
502
569
|
if source_type in MEMORY_SOURCE_TYPES:
|
|
503
570
|
return self._ingest_memory_record(item, source_type=source_type, owner=owner)
|
|
571
|
+
if modality == MODALITY_IMAGE:
|
|
572
|
+
return self._ingest_image(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
573
|
+
if modality == MODALITY_AUDIO:
|
|
574
|
+
return self._ingest_audio(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
504
575
|
if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
|
|
505
576
|
return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
506
577
|
return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
|
|
@@ -590,7 +661,11 @@ class IngestionPipeline:
|
|
|
590
661
|
except Exception: # noqa: BLE001 — audit must never break ingestion
|
|
591
662
|
quiet()
|
|
592
663
|
|
|
593
|
-
|
|
664
|
+
# A modality-aware door scores its own extraction (a picture's quality
|
|
665
|
+
# is "how much of it can be retrieved", not "does the text read well"),
|
|
666
|
+
# so its verdict wins. Text/file doors never set the key and keep the
|
|
667
|
+
# historical scoring untouched.
|
|
668
|
+
extraction_quality = raw.get("extraction_quality") or self._assess_item_quality(
|
|
594
669
|
item, source_type=source_type, text=quality_text, chunk_ids=chunk_ids,
|
|
595
670
|
)
|
|
596
671
|
warnings: List[str] = []
|
|
@@ -980,7 +1055,7 @@ class IngestionPipeline:
|
|
|
980
1055
|
allowed_exts = (
|
|
981
1056
|
frozenset(str(e).lower() if str(e).startswith(".") else f".{str(e).lower()}" for e in extensions)
|
|
982
1057
|
if extensions
|
|
983
|
-
else
|
|
1058
|
+
else self._folder_extensions()
|
|
984
1059
|
)
|
|
985
1060
|
patterns = _load_latticeignore(root)
|
|
986
1061
|
errors: List[Dict[str, Any]] = summary["errors"]
|
|
@@ -1036,7 +1111,11 @@ class IngestionPipeline:
|
|
|
1036
1111
|
summary["truncated"] = True
|
|
1037
1112
|
break
|
|
1038
1113
|
item_metadata: Dict[str, Any] = {"relative_path": rel}
|
|
1039
|
-
if ext in
|
|
1114
|
+
if ext in FOLDER_MULTIMODAL_EXTENSIONS and self._allow_multimodal:
|
|
1115
|
+
# Routed by modality inside ``ingest``; reading the bytes as
|
|
1116
|
+
# UTF-8 here would only produce mojibake.
|
|
1117
|
+
source_type = "file"
|
|
1118
|
+
elif ext in FOLDER_DOCUMENT_EXTENSIONS:
|
|
1040
1119
|
source_type = "pdf"
|
|
1041
1120
|
else:
|
|
1042
1121
|
source_type = "file"
|
|
@@ -1080,6 +1159,12 @@ class IngestionPipeline:
|
|
|
1080
1159
|
summary["status"] = "ok" if summary["failed"] == 0 else "partial"
|
|
1081
1160
|
return summary
|
|
1082
1161
|
|
|
1162
|
+
def _folder_extensions(self) -> frozenset:
|
|
1163
|
+
"""Folder-scan allow-list — pictures and recordings only when enabled."""
|
|
1164
|
+
if self._allow_multimodal:
|
|
1165
|
+
return DEFAULT_FOLDER_EXTENSIONS | FOLDER_MULTIMODAL_EXTENSIONS
|
|
1166
|
+
return DEFAULT_FOLDER_EXTENSIONS
|
|
1167
|
+
|
|
1083
1168
|
# ── routing helpers ──────────────────────────────────────────────────────
|
|
1084
1169
|
def _ingest_text(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1085
1170
|
text = item.text or ""
|
|
@@ -1142,7 +1227,24 @@ class IngestionPipeline:
|
|
|
1142
1227
|
result.setdefault("title", item.title)
|
|
1143
1228
|
return result
|
|
1144
1229
|
|
|
1145
|
-
|
|
1230
|
+
# ── multi-modal routing (v11.1.0 Track 3) ────────────────────────────────
|
|
1231
|
+
def _modality_for(self, item: IngestionItem, source_type: str) -> str:
|
|
1232
|
+
"""``image`` / ``audio`` / ``video`` / ``text`` for this item.
|
|
1233
|
+
|
|
1234
|
+
Always ``"text"`` while the flag is off, which is what makes "off" mean
|
|
1235
|
+
*unchanged* rather than *slightly different*.
|
|
1236
|
+
"""
|
|
1237
|
+
if not self._allow_multimodal:
|
|
1238
|
+
return "text"
|
|
1239
|
+
if source_type in IMAGE_SOURCE_TYPES:
|
|
1240
|
+
return MODALITY_IMAGE
|
|
1241
|
+
if source_type in AUDIO_SOURCE_TYPES:
|
|
1242
|
+
return MODALITY_AUDIO
|
|
1243
|
+
if not item.path:
|
|
1244
|
+
return "text"
|
|
1245
|
+
return detect_modality(item.path, item.mime_type)
|
|
1246
|
+
|
|
1247
|
+
def _resolve_file_path(self, item: IngestionItem) -> Path:
|
|
1146
1248
|
if not item.path:
|
|
1147
1249
|
raise ValueError("File ingestion requires a path.")
|
|
1148
1250
|
path = Path(item.path)
|
|
@@ -1150,6 +1252,109 @@ class IngestionPipeline:
|
|
|
1150
1252
|
raise FileNotFoundError(f"File not found: {path}")
|
|
1151
1253
|
if path.is_dir():
|
|
1152
1254
|
raise ValueError(f"File ingestion requires a file, got a directory: {path}")
|
|
1255
|
+
return path
|
|
1256
|
+
|
|
1257
|
+
def _ingest_image(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1258
|
+
"""Store one picture as an ``Image`` node — OCR, caption, vector.
|
|
1259
|
+
|
|
1260
|
+
The image vector (when a vision model produced one) goes to its own
|
|
1261
|
+
index; the OCR/caption text rides the ordinary text index. That split
|
|
1262
|
+
is what lets a typed question find a screenshot without ever comparing
|
|
1263
|
+
a text vector to an image vector.
|
|
1264
|
+
"""
|
|
1265
|
+
path = self._resolve_file_path(item)
|
|
1266
|
+
facts = extract_image_facts(str(path), ports=self._multimodal)
|
|
1267
|
+
result = write_image_memory(
|
|
1268
|
+
self._kg,
|
|
1269
|
+
path=path,
|
|
1270
|
+
facts=facts,
|
|
1271
|
+
title=item.title or path.name,
|
|
1272
|
+
source_type=source_type if source_type in IMAGE_SOURCE_TYPES else MODALITY_IMAGE,
|
|
1273
|
+
source_uri=item.source_uri,
|
|
1274
|
+
owner=owner,
|
|
1275
|
+
workspace_id=item.workspace_id,
|
|
1276
|
+
conversation_id=item.conversation_id,
|
|
1277
|
+
captured_at=captured_at,
|
|
1278
|
+
modified_at=item.modified_at,
|
|
1279
|
+
permissions=item.permissions,
|
|
1280
|
+
extra_metadata={"mime_type": item.mime_type, **(item.metadata or {})},
|
|
1281
|
+
)
|
|
1282
|
+
self._record_image_vector(result["node_id"], facts)
|
|
1283
|
+
quality = image_quality_score(facts)
|
|
1284
|
+
result["extraction_quality"] = {
|
|
1285
|
+
"score": quality["score"],
|
|
1286
|
+
"level": _quality_level(quality["score"]),
|
|
1287
|
+
"reasons": quality["reasons"],
|
|
1288
|
+
}
|
|
1289
|
+
return result
|
|
1290
|
+
|
|
1291
|
+
def _record_image_vector(self, node_id: str, facts: ImageFacts) -> None:
|
|
1292
|
+
"""File the image-space vector, if a vision model actually made one."""
|
|
1293
|
+
if facts.embedding is None:
|
|
1294
|
+
return
|
|
1295
|
+
from .graph.image_vectors import record_image_vector
|
|
1296
|
+
|
|
1297
|
+
record_image_vector(
|
|
1298
|
+
self._kg,
|
|
1299
|
+
node_id=node_id,
|
|
1300
|
+
vector=facts.embedding,
|
|
1301
|
+
model_id=self._multimodal.vision_model_id or "vision:unnamed",
|
|
1302
|
+
space=self._multimodal.vision_space,
|
|
1303
|
+
updated_at=utc_now_iso(),
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
def _ingest_audio(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1307
|
+
"""Store one recording as an ``Audio`` node, transcribed when possible.
|
|
1308
|
+
|
|
1309
|
+
The transcript is text and rides the ordinary text index — chunks,
|
|
1310
|
+
concepts, provenance, dedupe all unchanged — but the node itself is a
|
|
1311
|
+
recording, because that is what it is whether or not anyone could hear
|
|
1312
|
+
it. The recording's own facts stay in the metadata (``modality``,
|
|
1313
|
+
``audio_path``, ``transcription``, ``searchable``). Without a
|
|
1314
|
+
transcriber the memory is still kept, and its body says plainly that
|
|
1315
|
+
the words were never recognized instead of leaving a blank note.
|
|
1316
|
+
"""
|
|
1317
|
+
path = self._resolve_file_path(item)
|
|
1318
|
+
facts = transcribe_audio(str(path), ports=self._multimodal, transcript=item.text)
|
|
1319
|
+
title = item.title or path.stem
|
|
1320
|
+
body = facts.transcript or (
|
|
1321
|
+
f"[{MODALITY_AUDIO}] {title}\n"
|
|
1322
|
+
"이 녹음은 아직 글로 바뀌지 않았습니다 — 음성 인식기가 없어 내용 검색은 되지 않습니다."
|
|
1323
|
+
)
|
|
1324
|
+
result = self._kg.ingest_source(
|
|
1325
|
+
source_type=source_type,
|
|
1326
|
+
title=title,
|
|
1327
|
+
text=body,
|
|
1328
|
+
source_uri=item.source_uri or str(path),
|
|
1329
|
+
owner=owner,
|
|
1330
|
+
workspace_id=item.workspace_id,
|
|
1331
|
+
permissions=item.permissions,
|
|
1332
|
+
captured_at=captured_at,
|
|
1333
|
+
modified_at=item.modified_at,
|
|
1334
|
+
conversation_id=item.conversation_id,
|
|
1335
|
+
node_type=AUDIO_NODE_TYPE,
|
|
1336
|
+
metadata={
|
|
1337
|
+
"mime_type": item.mime_type,
|
|
1338
|
+
"modality": MODALITY_AUDIO,
|
|
1339
|
+
"audio_path": str(path),
|
|
1340
|
+
"audio_bytes": path.stat().st_size,
|
|
1341
|
+
"transcription": facts.transcription_status,
|
|
1342
|
+
"searchable": facts.searchable,
|
|
1343
|
+
**({"transcription_detail": facts.detail} if facts.detail else {}),
|
|
1344
|
+
**(item.metadata or {}),
|
|
1345
|
+
},
|
|
1346
|
+
)
|
|
1347
|
+
result.setdefault("title", title)
|
|
1348
|
+
quality = audio_quality_score(facts)
|
|
1349
|
+
result["extraction_quality"] = {
|
|
1350
|
+
"score": quality["score"],
|
|
1351
|
+
"level": _quality_level(quality["score"]),
|
|
1352
|
+
"reasons": quality["reasons"],
|
|
1353
|
+
}
|
|
1354
|
+
return result
|
|
1355
|
+
|
|
1356
|
+
def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
|
|
1357
|
+
path = self._resolve_file_path(item)
|
|
1153
1358
|
return self._kg.ingest_document(
|
|
1154
1359
|
path,
|
|
1155
1360
|
original_filename=item.title or path.name,
|