npcpy 1.2.33__tar.gz → 1.2.35__tar.gz

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 (76) hide show
  1. {npcpy-1.2.33/npcpy.egg-info → npcpy-1.2.35}/PKG-INFO +1 -1
  2. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/audio.py +35 -1
  3. npcpy-1.2.35/npcpy/data/load.py +296 -0
  4. npcpy-1.2.35/npcpy/data/video.py +100 -0
  5. npcpy-1.2.35/npcpy/ft/diff.py +371 -0
  6. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/image_gen.py +120 -23
  7. npcpy-1.2.35/npcpy/gen/ocr.py +187 -0
  8. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/command_history.py +257 -41
  9. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/npc_compiler.py +102 -157
  10. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/serve.py +1469 -739
  11. {npcpy-1.2.33 → npcpy-1.2.35/npcpy.egg-info}/PKG-INFO +1 -1
  12. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy.egg-info/SOURCES.txt +1 -0
  13. {npcpy-1.2.33 → npcpy-1.2.35}/setup.py +1 -1
  14. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_load.py +54 -33
  15. npcpy-1.2.33/npcpy/data/load.py +0 -154
  16. npcpy-1.2.33/npcpy/data/video.py +0 -28
  17. npcpy-1.2.33/npcpy/ft/diff.py +0 -110
  18. {npcpy-1.2.33 → npcpy-1.2.35}/LICENSE +0 -0
  19. {npcpy-1.2.33 → npcpy-1.2.35}/MANIFEST.in +0 -0
  20. {npcpy-1.2.33 → npcpy-1.2.35}/README.md +0 -0
  21. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/__init__.py +0 -0
  22. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/__init__.py +0 -0
  23. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/data_models.py +0 -0
  24. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/image.py +0 -0
  25. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/text.py +0 -0
  26. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/data/web.py +0 -0
  27. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/__init__.py +0 -0
  28. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/ge.py +0 -0
  29. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/memory_trainer.py +0 -0
  30. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/model_ensembler.py +0 -0
  31. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/rl.py +0 -0
  32. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/sft.py +0 -0
  33. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/ft/usft.py +0 -0
  34. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/__init__.py +0 -0
  35. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/audio_gen.py +0 -0
  36. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/embeddings.py +0 -0
  37. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/response.py +0 -0
  38. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/gen/video_gen.py +0 -0
  39. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/llm_funcs.py +0 -0
  40. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/main.py +0 -0
  41. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/__init__.py +0 -0
  42. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/kg_vis.py +0 -0
  43. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/knowledge_graph.py +0 -0
  44. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/memory_processor.py +0 -0
  45. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/memory/search.py +0 -0
  46. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/mix/__init__.py +0 -0
  47. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/mix/debate.py +0 -0
  48. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/npc_sysenv.py +0 -0
  49. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/npcs.py +0 -0
  50. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/__init__.py +0 -0
  51. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/ai_function_tools.py +0 -0
  52. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/database_ai_adapters.py +0 -0
  53. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/database_ai_functions.py +0 -0
  54. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/model_runner.py +0 -0
  55. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/npcsql.py +0 -0
  56. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/sql/sql_model_compiler.py +0 -0
  57. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/tools.py +0 -0
  58. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/work/__init__.py +0 -0
  59. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/work/desktop.py +0 -0
  60. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/work/plan.py +0 -0
  61. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy/work/trigger.py +0 -0
  62. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy.egg-info/dependency_links.txt +0 -0
  63. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy.egg-info/requires.txt +0 -0
  64. {npcpy-1.2.33 → npcpy-1.2.35}/npcpy.egg-info/top_level.txt +0 -0
  65. {npcpy-1.2.33 → npcpy-1.2.35}/setup.cfg +0 -0
  66. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_audio.py +0 -0
  67. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_command_history.py +0 -0
  68. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_image.py +0 -0
  69. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_llm_funcs.py +0 -0
  70. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_npc_compiler.py +0 -0
  71. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_npcsql.py +0 -0
  72. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_response.py +0 -0
  73. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_serve.py +0 -0
  74. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_text.py +0 -0
  75. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_tools.py +0 -0
  76. {npcpy-1.2.33 → npcpy-1.2.35}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.2.33
3
+ Version: 1.2.35
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -175,6 +175,41 @@ def run_transcription(audio_np):
175
175
  return None
176
176
 
177
177
 
178
+ def transcribe_audio_file(file_path: str, language=None) -> str:
179
+ """
180
+ File-based transcription helper that prefers the local faster-whisper/whisper
181
+ setup used elsewhere in this module.
182
+ """
183
+ # Try faster-whisper first
184
+ try:
185
+ from faster_whisper import WhisperModel # type: ignore
186
+ try:
187
+ import torch # type: ignore
188
+ device = "cuda" if torch.cuda.is_available() else "cpu"
189
+ except Exception:
190
+ device = "cpu"
191
+ model = WhisperModel("small", device=device)
192
+ segments, _ = model.transcribe(file_path, language=language, beam_size=5)
193
+ text = " ".join(seg.text.strip() for seg in segments if seg.text).strip()
194
+ if text:
195
+ return text
196
+ except Exception:
197
+ pass
198
+
199
+ # Fallback to openai/whisper if available
200
+ try:
201
+ import whisper # type: ignore
202
+ model = whisper.load_model("small")
203
+ result = model.transcribe(file_path, language=language)
204
+ text = result.get("text", "").strip()
205
+ if text:
206
+ return text
207
+ except Exception:
208
+ pass
209
+
210
+ return ""
211
+
212
+
178
213
 
179
214
  def load_history():
180
215
  global history
@@ -431,4 +466,3 @@ def process_text_for_tts(text):
431
466
  text = re.sub(r"([.!?])(\w)", r"\1 \2", text)
432
467
  return text
433
468
 
434
-
@@ -0,0 +1,296 @@
1
+ import fitz
2
+ import pandas as pd
3
+ import json
4
+ import io
5
+ from PIL import Image
6
+ import numpy as np
7
+ from typing import Optional, List
8
+ import os
9
+ import tempfile
10
+ import subprocess
11
+
12
+ try:
13
+ from docx import Document
14
+ except ImportError:
15
+ Document = None
16
+
17
+ try:
18
+ from pptx import Presentation
19
+ except ImportError:
20
+ Presentation = None
21
+
22
+ try:
23
+ from bs4 import BeautifulSoup
24
+ except ImportError:
25
+ BeautifulSoup = None
26
+
27
+ def load_csv(file_path):
28
+ df = pd.read_csv(file_path)
29
+ return df
30
+
31
+ def load_json(file_path):
32
+ with open(file_path, "r", encoding='utf-8') as f:
33
+ data = json.load(f)
34
+ return data
35
+
36
+ def load_txt(file_path):
37
+ with open(file_path, "r", encoding='utf-8') as f:
38
+ text = f.read()
39
+ return text
40
+
41
+ def load_excel(file_path):
42
+ df = pd.read_excel(file_path)
43
+ return df
44
+
45
+ def load_image(file_path):
46
+ img = Image.open(file_path)
47
+ img_array = np.array(img)
48
+ df = pd.DataFrame(
49
+ {
50
+ "image_array": [img_array.tobytes()],
51
+ "shape": [img_array.shape],
52
+ "dtype": [img_array.dtype.str],
53
+ }
54
+ )
55
+ return df
56
+
57
+ def load_pdf(file_path):
58
+ pdf_document = fitz.open(file_path)
59
+ full_text = ""
60
+ for page in pdf_document:
61
+ full_text += page.get_text() + "\n"
62
+ return full_text
63
+
64
+ def load_docx(file_path):
65
+ if Document is None:
66
+ raise ImportError("Please install python-docx to load .docx files.")
67
+ doc = Document(file_path)
68
+ full_text = "\n".join([para.text for para in doc.paragraphs])
69
+ return full_text
70
+
71
+ def load_pptx(file_path):
72
+ if Presentation is None:
73
+ raise ImportError("Please install python-pptx to load .pptx files.")
74
+ prs = Presentation(file_path)
75
+ full_text = ""
76
+ for slide in prs.slides:
77
+ for shape in slide.shapes:
78
+ if hasattr(shape, "text"):
79
+ full_text += shape.text + "\n"
80
+ return full_text
81
+
82
+ def load_html(file_path):
83
+ if BeautifulSoup is None:
84
+ raise ImportError("Please install beautifulsoup4 to load .html files.")
85
+ with open(file_path, 'r', encoding='utf-8') as f:
86
+ soup = BeautifulSoup(f, 'html.parser')
87
+ return soup.get_text(separator='\n', strip=True)
88
+
89
+ extension_map = {
90
+ "PNG": "images",
91
+ "JPG": "images",
92
+ "JPEG": "images",
93
+ "GIF": "images",
94
+ "SVG": "images",
95
+ "WEBP": "images",
96
+ "BMP": "images",
97
+ "TIFF": "images",
98
+ "MP4": "videos",
99
+ "AVI": "videos",
100
+ "MOV": "videos",
101
+ "WMV": "videos",
102
+ "MPG": "videos",
103
+ "MPEG": "videos",
104
+ "WEBM": "videos",
105
+ "MKV": "videos",
106
+ "DOCX": "documents",
107
+ "PPTX": "documents",
108
+ "PDF": "documents",
109
+ "XLSX": "documents",
110
+ "TXT": "documents",
111
+ "CSV": "documents",
112
+ "MD": "documents",
113
+ "HTML": "documents",
114
+ "HTM": "documents",
115
+ "MP3": "audio",
116
+ "WAV": "audio",
117
+ "M4A": "audio",
118
+ "AAC": "audio",
119
+ "FLAC": "audio",
120
+ "OGG": "audio",
121
+ "ZIP": "archives",
122
+ "RAR": "archives",
123
+ "7Z": "archives",
124
+ "TAR": "archives",
125
+ "GZ": "archives",
126
+ }
127
+
128
+ def _chunk_text(full_content: str, chunk_size: int) -> List[str]:
129
+ """Split long content into reasonably sized chunks for model input."""
130
+ chunks = []
131
+ for i in range(0, len(full_content), chunk_size):
132
+ chunk = full_content[i:i+chunk_size].strip()
133
+ if chunk:
134
+ chunks.append(chunk)
135
+ return chunks
136
+
137
+ def _transcribe_audio(file_path: str, language: Optional[str] = None) -> str:
138
+ """
139
+ Best-effort audio transcription using optional dependencies.
140
+ Tries faster-whisper, then openai/whisper. Falls back to metadata only.
141
+ """
142
+ # Prefer the existing audio module helper if present
143
+ try:
144
+ from npcpy.data.audio import transcribe_audio_file # type: ignore
145
+ text = transcribe_audio_file(file_path, language=language)
146
+ if text:
147
+ return text
148
+ except Exception:
149
+ pass
150
+
151
+ # Try faster-whisper first
152
+ try:
153
+ from faster_whisper import WhisperModel
154
+ try:
155
+ import torch
156
+ device = "cuda" if torch.cuda.is_available() else "cpu"
157
+ except Exception:
158
+ device = "cpu"
159
+ model = WhisperModel("small", device=device)
160
+ segments, _ = model.transcribe(file_path, language=language, beam_size=5)
161
+ return " ".join(seg.text.strip() for seg in segments if seg.text).strip()
162
+ except Exception:
163
+ pass
164
+
165
+ # Fallback: openai/whisper
166
+ try:
167
+ import whisper
168
+ model = whisper.load_model("small")
169
+ result = model.transcribe(file_path, language=language)
170
+ return result.get("text", "").strip()
171
+ except Exception:
172
+ pass
173
+
174
+ # Last resort metadata message
175
+ return f"[Audio file at {file_path}; install faster-whisper or whisper for transcription]"
176
+
177
+ def load_audio(file_path: str, language: Optional[str] = None) -> str:
178
+ """Load and transcribe an audio file into text."""
179
+ transcript = _transcribe_audio(file_path, language=language)
180
+ if transcript:
181
+ return transcript
182
+ return f"[Audio file at {file_path}; no transcript available]"
183
+
184
+ def _extract_audio_from_video(file_path: str, max_duration: int = 600) -> Optional[str]:
185
+ """
186
+ Use ffmpeg to dump the audio track from a video into a temp wav for transcription.
187
+ Returns the temp path or None.
188
+ """
189
+ try:
190
+ temp_audio = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
191
+ temp_audio.close()
192
+ cmd = [
193
+ "ffmpeg",
194
+ "-y",
195
+ "-i",
196
+ file_path,
197
+ "-vn",
198
+ "-ac",
199
+ "1",
200
+ "-ar",
201
+ "16000",
202
+ "-t",
203
+ str(max_duration),
204
+ temp_audio.name,
205
+ ]
206
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
207
+ return temp_audio.name
208
+ except Exception:
209
+ return None
210
+
211
+ def load_video(file_path: str, language: Optional[str] = None, max_audio_seconds: int = 600) -> str:
212
+ """
213
+ Summarize a video by reporting metadata and (optionally) transcribing its audio track.
214
+ """
215
+ # Prefer the video module helper if present
216
+ try:
217
+ from npcpy.data.video import summarize_video_file # type: ignore
218
+ return summarize_video_file(file_path, language=language, max_audio_seconds=max_audio_seconds)
219
+ except Exception:
220
+ pass
221
+
222
+ # Fallback to minimal summary/transcription
223
+ meta_bits = []
224
+ try:
225
+ import cv2
226
+ video = cv2.VideoCapture(file_path)
227
+ fps = video.get(cv2.CAP_PROP_FPS)
228
+ frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
229
+ width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH))
230
+ height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
231
+ duration = frame_count / fps if fps else 0
232
+ meta_bits.append(
233
+ f"Video file: {os.path.basename(file_path)} | {width}x{height} | {fps:.2f} fps | {frame_count} frames | ~{duration:.1f}s"
234
+ )
235
+ video.release()
236
+ except Exception:
237
+ meta_bits.append(f"Video file: {os.path.basename(file_path)}")
238
+
239
+ audio_path = _extract_audio_from_video(file_path, max_duration=max_audio_seconds)
240
+ transcript = ""
241
+ if audio_path:
242
+ try:
243
+ transcript = _transcribe_audio(audio_path, language=language)
244
+ finally:
245
+ try:
246
+ os.remove(audio_path)
247
+ except Exception:
248
+ pass
249
+
250
+ if transcript:
251
+ meta_bits.append("Audio transcript:")
252
+ meta_bits.append(transcript)
253
+ else:
254
+ meta_bits.append("[No transcript extracted; ensure ffmpeg and faster-whisper/whisper are installed]")
255
+
256
+ return "\n".join(meta_bits)
257
+
258
+ def load_file_contents(file_path, chunk_size=None):
259
+ file_ext = os.path.splitext(file_path)[1].upper().lstrip('.')
260
+ full_content = ""
261
+ if not isinstance(chunk_size, int):
262
+ chunk_size=250
263
+ try:
264
+ if file_ext == 'PDF':
265
+ full_content = load_pdf(file_path)
266
+ elif file_ext == 'DOCX':
267
+ full_content = load_docx(file_path)
268
+ elif file_ext == 'PPTX':
269
+ full_content = load_pptx(file_path)
270
+ elif file_ext in ['HTML', 'HTM']:
271
+ full_content = load_html(file_path)
272
+ elif file_ext == 'CSV':
273
+ df = load_csv(file_path)
274
+ full_content = df.to_string()
275
+ elif file_ext in ['XLS', 'XLSX']:
276
+ df = load_excel(file_path)
277
+ full_content = df.to_string()
278
+ elif file_ext in ['TXT', 'MD', 'PY', 'JSX', 'TSX', 'TS', 'JS', 'JSON', 'SQL', 'NPC', 'JINX', 'LINE', 'YAML', 'DART', 'JAVA']:
279
+ full_content = load_txt(file_path)
280
+ elif file_ext == 'JSON':
281
+ data = load_json(file_path)
282
+ full_content = json.dumps(data, indent=2)
283
+ elif file_ext in ['MP3', 'WAV', 'M4A', 'AAC', 'FLAC', 'OGG']:
284
+ full_content = load_audio(file_path)
285
+ elif file_ext in ['MP4', 'AVI', 'MOV', 'WMV', 'MPG', 'MPEG', 'WEBM', 'MKV']:
286
+ full_content = load_video(file_path)
287
+ else:
288
+ return [f"Unsupported file format for content loading: {file_ext}"]
289
+
290
+ if not full_content:
291
+ return []
292
+
293
+ return _chunk_text(full_content, chunk_size)
294
+
295
+ except Exception as e:
296
+ return [f"Error loading file {file_path}: {str(e)}"]
@@ -0,0 +1,100 @@
1
+
2
+ import os
3
+ import tempfile
4
+ import subprocess
5
+
6
+
7
+ def process_video(file_path, table_name):
8
+
9
+ import cv2
10
+ import base64
11
+
12
+ embeddings = []
13
+ texts = []
14
+ try:
15
+ video = cv2.VideoCapture(file_path)
16
+ fps = video.get(cv2.CAP_PROP_FPS)
17
+ frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
18
+
19
+ for i in range(frame_count):
20
+ ret, frame = video.read()
21
+ if not ret:
22
+ break
23
+
24
+
25
+ n = 10
26
+
27
+ return embeddings, texts
28
+
29
+ except Exception as e:
30
+ print(f"Error processing video: {e}")
31
+ return [], []
32
+
33
+
34
+ def summarize_video_file(file_path: str, language: str = None, max_audio_seconds: int = 600) -> str:
35
+ """
36
+ Summarize a video using lightweight metadata plus optional audio transcript.
37
+ Prefers the audio transcription helper in npcpy.data.audio when available.
38
+ """
39
+ meta_bits = []
40
+ try:
41
+ import cv2 # type: ignore
42
+
43
+ video = cv2.VideoCapture(file_path)
44
+ fps = video.get(cv2.CAP_PROP_FPS)
45
+ frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))
46
+ width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH))
47
+ height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
48
+ duration = frame_count / fps if fps else 0
49
+ meta_bits.append(
50
+ f"Video file: {os.path.basename(file_path)} | {width}x{height} | {fps:.2f} fps | {frame_count} frames | ~{duration:.1f}s"
51
+ )
52
+ video.release()
53
+ except Exception:
54
+ meta_bits.append(f"Video file: {os.path.basename(file_path)}")
55
+
56
+ # Extract audio track with ffmpeg if available
57
+ audio_path = None
58
+ try:
59
+ temp_audio = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
60
+ temp_audio.close()
61
+ cmd = [
62
+ "ffmpeg",
63
+ "-y",
64
+ "-i",
65
+ file_path,
66
+ "-vn",
67
+ "-ac",
68
+ "1",
69
+ "-ar",
70
+ "16000",
71
+ "-t",
72
+ str(max_audio_seconds),
73
+ temp_audio.name,
74
+ ]
75
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
76
+ audio_path = temp_audio.name
77
+ except Exception:
78
+ audio_path = None
79
+
80
+ transcript = ""
81
+ if audio_path:
82
+ try:
83
+ try:
84
+ from npcpy.data.audio import transcribe_audio_file
85
+ transcript = transcribe_audio_file(audio_path, language=language) # type: ignore
86
+ except Exception:
87
+ transcript = ""
88
+ finally:
89
+ try:
90
+ os.remove(audio_path)
91
+ except Exception:
92
+ pass
93
+
94
+ if transcript:
95
+ meta_bits.append("Audio transcript:")
96
+ meta_bits.append(transcript)
97
+ else:
98
+ meta_bits.append("[No transcript extracted; ensure ffmpeg and a transcription backend are installed]")
99
+
100
+ return "\n".join(meta_bits)