lattifai 1.2.1__py3-none-any.whl → 1.3.0__py3-none-any.whl

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 (59) hide show
  1. lattifai/_init.py +20 -0
  2. lattifai/alignment/__init__.py +9 -1
  3. lattifai/alignment/lattice1_aligner.py +175 -54
  4. lattifai/alignment/lattice1_worker.py +47 -4
  5. lattifai/alignment/punctuation.py +38 -0
  6. lattifai/alignment/segmenter.py +3 -2
  7. lattifai/alignment/text_align.py +441 -0
  8. lattifai/alignment/tokenizer.py +134 -65
  9. lattifai/audio2.py +162 -183
  10. lattifai/cli/__init__.py +2 -1
  11. lattifai/cli/alignment.py +5 -0
  12. lattifai/cli/caption.py +111 -4
  13. lattifai/cli/transcribe.py +2 -6
  14. lattifai/cli/youtube.py +7 -1
  15. lattifai/client.py +72 -123
  16. lattifai/config/__init__.py +28 -0
  17. lattifai/config/alignment.py +14 -0
  18. lattifai/config/caption.py +45 -31
  19. lattifai/config/client.py +16 -0
  20. lattifai/config/event.py +102 -0
  21. lattifai/config/media.py +20 -0
  22. lattifai/config/transcription.py +25 -1
  23. lattifai/data/__init__.py +8 -0
  24. lattifai/data/caption.py +228 -0
  25. lattifai/diarization/__init__.py +41 -1
  26. lattifai/errors.py +78 -53
  27. lattifai/event/__init__.py +65 -0
  28. lattifai/event/lattifai.py +166 -0
  29. lattifai/mixin.py +49 -32
  30. lattifai/transcription/base.py +8 -2
  31. lattifai/transcription/gemini.py +147 -16
  32. lattifai/transcription/lattifai.py +25 -63
  33. lattifai/types.py +1 -1
  34. lattifai/utils.py +7 -13
  35. lattifai/workflow/__init__.py +28 -4
  36. lattifai/workflow/file_manager.py +2 -5
  37. lattifai/youtube/__init__.py +43 -0
  38. lattifai/youtube/client.py +1265 -0
  39. lattifai/youtube/types.py +23 -0
  40. lattifai-1.3.0.dist-info/METADATA +678 -0
  41. lattifai-1.3.0.dist-info/RECORD +57 -0
  42. {lattifai-1.2.1.dist-info → lattifai-1.3.0.dist-info}/entry_points.txt +1 -2
  43. lattifai/__init__.py +0 -88
  44. lattifai/alignment/sentence_splitter.py +0 -219
  45. lattifai/caption/__init__.py +0 -20
  46. lattifai/caption/caption.py +0 -1467
  47. lattifai/caption/gemini_reader.py +0 -462
  48. lattifai/caption/gemini_writer.py +0 -173
  49. lattifai/caption/supervision.py +0 -34
  50. lattifai/caption/text_parser.py +0 -145
  51. lattifai/cli/app_installer.py +0 -142
  52. lattifai/cli/server.py +0 -44
  53. lattifai/server/app.py +0 -427
  54. lattifai/workflow/youtube.py +0 -577
  55. lattifai-1.2.1.dist-info/METADATA +0 -1134
  56. lattifai-1.2.1.dist-info/RECORD +0 -58
  57. {lattifai-1.2.1.dist-info → lattifai-1.3.0.dist-info}/WHEEL +0 -0
  58. {lattifai-1.2.1.dist-info → lattifai-1.3.0.dist-info}/licenses/LICENSE +0 -0
  59. {lattifai-1.2.1.dist-info → lattifai-1.3.0.dist-info}/top_level.txt +0 -0
lattifai/server/app.py DELETED
@@ -1,427 +0,0 @@
1
- import asyncio
2
- import os
3
- import subprocess
4
- import sys
5
- import tempfile
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- # Load environment variables from .env file
10
- from dotenv import find_dotenv, load_dotenv
11
- from fastapi import BackgroundTasks, FastAPI, File, Form, Request, UploadFile
12
- from fastapi.middleware.cors import CORSMiddleware
13
- from fastapi.responses import JSONResponse
14
-
15
- # Try to find and load .env file from current directory or parent directories
16
- load_dotenv(find_dotenv(usecwd=True))
17
-
18
-
19
- app = FastAPI(title="LattifAI Web Interface")
20
-
21
- print(f"LOADING APP FROM: {__file__}")
22
-
23
- # Lazy-initialized client - will be created on first use
24
- _client = None
25
-
26
-
27
- def get_client():
28
- """Get or create the LattifAI client (lazy initialization)."""
29
- global _client
30
- if _client is None:
31
- from lattifai.client import LattifAI
32
-
33
- _client = LattifAI()
34
- return _client
35
-
36
-
37
- @app.on_event("startup")
38
- async def startup_event():
39
- print("Listing all registered routes:")
40
- for route in app.routes:
41
- print(f"Route: {route.path} - {route.name}")
42
-
43
-
44
- @app.middleware("http")
45
- async def log_requests(request: Request, call_next):
46
- print(f"INCOMING REQUEST: {request.method} {request.url}")
47
- response = await call_next(request)
48
- print(f"OUTGOING RESPONSE: {response.status_code}")
49
- return response
50
-
51
-
52
- app.add_middleware(
53
- CORSMiddleware,
54
- allow_origins=["*"], # Allow all origins for dev
55
- allow_credentials=True,
56
- allow_methods=["*"],
57
- allow_headers=["*"],
58
- )
59
-
60
-
61
- @app.get("/health")
62
- async def health_check():
63
- """Health check endpoint for server status monitoring."""
64
- return {"status": "ok", "message": "LattifAI backend server is running"}
65
-
66
-
67
- def mask_api_key(key: str) -> str:
68
- """Mask API key for display, showing only first 6 and last 4 characters."""
69
- if len(key) <= 10:
70
- return "*" * len(key)
71
- return key[:6] + "*" * (len(key) - 10) + key[-4:]
72
-
73
-
74
- @app.get("/api/keys")
75
- async def get_api_keys():
76
- """Get status of API keys from environment variables."""
77
- lattifai_key = os.environ.get("LATTIFAI_API_KEY", "")
78
- gemini_key = os.environ.get("GEMINI_API_KEY", "")
79
-
80
- return {
81
- "lattifai": {
82
- "exists": bool(lattifai_key),
83
- "masked_value": mask_api_key(lattifai_key) if lattifai_key else None,
84
- "create_url": "https://lattifai.com/dashboard/api-keys",
85
- },
86
- "gemini": {
87
- "exists": bool(gemini_key),
88
- "masked_value": mask_api_key(gemini_key) if gemini_key else None,
89
- "create_url": "https://aistudio.google.com/apikey",
90
- },
91
- }
92
-
93
-
94
- @app.post("/api/keys")
95
- async def save_api_keys(request: Request):
96
- """Save API keys to environment variables and optionally to .env file."""
97
- try:
98
- data = await request.json()
99
- lattifai_key = data.get("lattifai_key", "").strip()
100
- gemini_key = data.get("gemini_key", "").strip()
101
- save_to_file = data.get("save_to_file", False) # Optional: save to .env file
102
-
103
- # Always update environment variables in current process
104
- if lattifai_key:
105
- os.environ["LATTIFAI_API_KEY"] = lattifai_key
106
- if gemini_key:
107
- os.environ["GEMINI_API_KEY"] = gemini_key
108
-
109
- # Reset client to force re-initialization with new keys
110
- global _client
111
- _client = None
112
-
113
- result = {
114
- "status": "success",
115
- "message": "API keys updated in environment variables",
116
- }
117
-
118
- # Optionally save to .env file for persistence
119
- if save_to_file:
120
- # Find the .env file path
121
- env_path = find_dotenv(usecwd=True)
122
- if not env_path:
123
- # Create .env in current working directory
124
- env_path = Path.cwd() / ".env"
125
-
126
- # Read existing .env content
127
- env_lines = []
128
- if Path(env_path).exists():
129
- with open(env_path, "r") as f:
130
- env_lines = f.readlines()
131
-
132
- # Update or add API keys
133
- updated_lines = []
134
- lattifai_updated = False
135
- gemini_updated = False
136
-
137
- for line in env_lines:
138
- if line.strip().startswith("LATTIFAI_API_KEY=") or line.strip().startswith("#LATTIFAI_API_KEY="):
139
- if lattifai_key:
140
- updated_lines.append(f"LATTIFAI_API_KEY={lattifai_key}\n")
141
- lattifai_updated = True
142
- else:
143
- updated_lines.append(line) # Keep existing or commented out
144
- elif line.strip().startswith("GEMINI_API_KEY=") or line.strip().startswith("#GEMINI_API_KEY="):
145
- if gemini_key:
146
- updated_lines.append(f"GEMINI_API_KEY={gemini_key}\n")
147
- gemini_updated = True
148
- else:
149
- updated_lines.append(line) # Keep existing or commented out
150
- else:
151
- updated_lines.append(line)
152
-
153
- # Add new keys if they weren't in the file
154
- if lattifai_key and not lattifai_updated:
155
- updated_lines.append(f"LATTIFAI_API_KEY={lattifai_key}\n")
156
- if gemini_key and not gemini_updated:
157
- updated_lines.append(f"GEMINI_API_KEY={gemini_key}\n")
158
-
159
- # Write back to .env file
160
- with open(env_path, "w") as f:
161
- f.writelines(updated_lines)
162
-
163
- result["message"] = "API keys saved to environment variables and .env file"
164
- result["env_path"] = str(env_path)
165
-
166
- return result
167
-
168
- except Exception as e:
169
- import traceback
170
-
171
- traceback.print_exc()
172
- return JSONResponse(status_code=500, content={"error": str(e), "traceback": traceback.format_exc()})
173
-
174
-
175
- @app.post("/api/utils/select-directory")
176
- async def select_directory():
177
- """
178
- Open a native directory selection dialog on the server (local machine).
179
- Returns the selected path.
180
- """
181
- try:
182
- path = ""
183
- if sys.platform == "darwin":
184
- # Use AppleScript for macOS - it's cleaner than Tkinter on Mac
185
- script = """
186
- try
187
- set theFolder to choose folder with prompt "Select Output Directory"
188
- POSIX path of theFolder
189
- on error
190
- return ""
191
- end try
192
- """
193
- result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
194
- if result.returncode == 0:
195
- path = result.stdout.strip()
196
-
197
- # Fallback to Tkinter if path is still empty (e.g. not mac or mac script failed)
198
- # Note: Tkinter might not be installed or might fail in some environments
199
- if not path and sys.platform != "darwin":
200
- try:
201
- import tkinter
202
- from tkinter import filedialog
203
-
204
- root = tkinter.Tk()
205
- root.withdraw() # Hide main window
206
- root.wm_attributes("-topmost", 1) # Bring to front
207
- path = filedialog.askdirectory(title="Select Output Directory")
208
- root.destroy()
209
- except ImportError:
210
- pass
211
- except Exception as e:
212
- print(f"Tkinter dialog failed: {e}")
213
-
214
- return {"path": path}
215
- except Exception as e:
216
- # Don't fail the request, just return empty path or error logged
217
- print(f"Directory selection failed: {e}")
218
- return {"path": "", "error": str(e)}
219
-
220
-
221
- @app.post("/align")
222
- async def align_files(
223
- background_tasks: BackgroundTasks,
224
- media_file: Optional[UploadFile] = File(None),
225
- caption_file: Optional[UploadFile] = File(None),
226
- local_media_path: Optional[str] = Form(None),
227
- local_caption_path: Optional[str] = Form(None),
228
- local_output_dir: Optional[str] = Form(None),
229
- youtube_url: Optional[str] = Form(None),
230
- youtube_output_dir: Optional[str] = Form(None),
231
- split_sentence: bool = Form(True),
232
- normalize_text: bool = Form(False),
233
- output_format: str = Form("srt"),
234
- transcription_model: str = Form("nvidia/parakeet-tdt-0.6b-v3"),
235
- alignment_model: str = Form("LattifAI/Lattice-1"),
236
- ):
237
- # Check if LATTIFAI_API_KEY is set
238
- if not os.environ.get("LATTIFAI_API_KEY"):
239
- return JSONResponse(
240
- status_code=400,
241
- content={
242
- "error": "LATTIFAI_API_KEY is not set. Please set the environment variable or add it to your .env file.",
243
- "help_url": "https://lattifai.com/dashboard/api-keys",
244
- },
245
- )
246
-
247
- if not media_file and not youtube_url and not local_media_path:
248
- return JSONResponse(
249
- status_code=400, content={"error": "Either media file, local media path, or YouTube URL must be provided."}
250
- )
251
-
252
- # Get lazily initialized client
253
- client = get_client()
254
- if not client:
255
- # This should rarely happen due to lazy init, but just in case
256
- return JSONResponse(
257
- status_code=500,
258
- content={
259
- "error": "LattifAI client not initialized. Please check API key configuration.",
260
- },
261
- )
262
-
263
- media_path = None
264
- caption_path = None
265
- temp_files_to_delete = []
266
-
267
- try:
268
- if media_file:
269
- # Save uploaded media file to a temporary location
270
- with tempfile.NamedTemporaryFile(delete=False, suffix=Path(media_file.filename).suffix) as tmp_media:
271
- content = await media_file.read()
272
- tmp_media.write(content)
273
- media_path = tmp_media.name
274
- temp_files_to_delete.append(media_path)
275
-
276
- if caption_file:
277
- # Save uploaded caption file to a temporary location
278
- with tempfile.NamedTemporaryFile(
279
- delete=False, suffix=Path(caption_file.filename).suffix
280
- ) as tmp_caption:
281
- content = await caption_file.read()
282
- tmp_caption.write(content)
283
- caption_path = tmp_caption.name
284
- temp_files_to_delete.append(caption_path)
285
-
286
- elif local_media_path:
287
- media_path = local_media_path
288
- if not Path(media_path).exists():
289
- return JSONResponse(status_code=400, content={"error": f"Local media file not found: {media_path}"})
290
-
291
- if local_caption_path:
292
- caption_path = local_caption_path
293
- if not Path(caption_path).exists():
294
- return JSONResponse(
295
- status_code=400, content={"error": f"Local caption file not found: {caption_path}"}
296
- )
297
-
298
- # Process in thread pool to not block event loop
299
- loop = asyncio.get_event_loop()
300
- result_caption = await loop.run_in_executor(
301
- None,
302
- process_alignment,
303
- media_path,
304
- youtube_url,
305
- youtube_output_dir,
306
- caption_path,
307
- local_output_dir,
308
- split_sentence,
309
- normalize_text,
310
- transcription_model,
311
- alignment_model,
312
- output_format,
313
- )
314
-
315
- # Convert result to dict with specified output format
316
- caption_content = result_caption.to_string(format=output_format)
317
-
318
- return {
319
- "status": "success",
320
- "segments": [
321
- {
322
- "start": seg.start,
323
- "end": seg.end,
324
- "text": seg.text,
325
- "speaker": seg.speaker if hasattr(seg, "speaker") else None,
326
- }
327
- for seg in result_caption.alignments
328
- ],
329
- "caption_content": caption_content,
330
- "output_format": output_format,
331
- }
332
-
333
- except Exception as e:
334
- import traceback
335
-
336
- traceback.print_exc()
337
- return JSONResponse(status_code=500, content={"error": str(e), "traceback": traceback.format_exc()})
338
-
339
-
340
- def process_alignment(
341
- media_path,
342
- youtube_url,
343
- youtube_output_dir,
344
- caption_path,
345
- local_output_dir,
346
- split_sentence,
347
- normalize_text,
348
- transcription_model,
349
- alignment_model,
350
- output_format,
351
- ):
352
- """
353
- Wrapper to call LattifAI client.
354
- Note: Transcription will be automatically triggered when no caption is provided.
355
- """
356
- # Get lazily initialized client
357
- client = get_client()
358
- if not client:
359
- raise RuntimeError("LattifAI client not initialized")
360
-
361
- # Update caption config
362
- client.caption_config.normalize_text = normalize_text
363
-
364
- # Check if alignment model changed - if so, reinitialize aligner
365
- if client.aligner.config.model_name != alignment_model:
366
- print(
367
- f"Alignment model changed from {client.aligner.config.model_name} to {alignment_model}, reinitializing aligner..."
368
- ) # noqa: E501
369
- from lattifai.alignment import Lattice1Aligner
370
-
371
- client.aligner.config.model_name = alignment_model
372
- client.aligner = Lattice1Aligner(config=client.aligner.config)
373
-
374
- # Check if transcription model changed - if so, reinitialize transcriber
375
- if transcription_model != client.transcription_config.model_name:
376
- print(
377
- f"Transcription model changed from {client.transcription_config.model_name} to {transcription_model}, reinitializing transcriber..."
378
- ) # noqa: E501
379
- from lattifai.config import TranscriptionConfig
380
-
381
- client.transcription_config = TranscriptionConfig(model_name=transcription_model)
382
- client._transcriber = None
383
-
384
- if youtube_url:
385
- # If youtube, we use client.youtube
386
- # Note: client.youtube handles download + alignment
387
- # Will try to download YT captions first, if not available, will transcribe
388
-
389
- # Determine output directory
390
- # Default: ~/Downloads/YYYY-MM-DD
391
- if not youtube_output_dir or not youtube_output_dir.strip():
392
- from datetime import datetime
393
-
394
- today = datetime.now().strftime("%Y-%m-%d")
395
- youtube_output_dir = f"~/Downloads/{today}"
396
-
397
- temp_path = Path(youtube_output_dir).expanduser()
398
- temp_path.mkdir(parents=True, exist_ok=True)
399
-
400
- result = client.youtube(
401
- url=youtube_url,
402
- output_dir=temp_path,
403
- use_transcription=False, # Try to download captions first
404
- force_overwrite=True, # No user prompt in server mode
405
- split_sentence=split_sentence,
406
- )
407
- return result
408
- else:
409
- # Local file alignment
410
- output_caption_path = None
411
- if local_output_dir:
412
- output_dir = Path(local_output_dir).expanduser()
413
- output_dir.mkdir(parents=True, exist_ok=True)
414
- stem = Path(media_path).stem
415
- # Prevent overwriting input if names clash, use _LattifAI suffix
416
- output_filename = f"{stem}_LattifAI.{output_format}"
417
- output_caption_path = output_dir / output_filename
418
- print(f"Saving alignment result to: {output_caption_path}")
419
-
420
- # If no caption_path provided, client.alignment will automatically call _transcribe
421
- return client.alignment(
422
- input_media=str(media_path),
423
- input_caption=str(caption_path) if caption_path else None,
424
- output_caption_path=str(output_caption_path) if output_caption_path else None,
425
- split_sentence=split_sentence,
426
- streaming_chunk_secs=None, # Server API default: no streaming
427
- )