Spaces:
Running
Running
| """ | |
| backend/api/endpoints.py | |
| ========================= | |
| All FastAPI endpoint handlers. | |
| Handles video upload, pipeline execution, RAG query, job status, and note retrieval. | |
| """ | |
| import os | |
| import json | |
| import asyncio | |
| from typing import Dict, Optional | |
| from pathlib import Path | |
| from fastapi import APIRouter, File, Form, HTTPException, UploadFile, BackgroundTasks | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from pydantic import BaseModel | |
| from backend.utils.config import settings | |
| from backend.utils.helper import ( | |
| generate_job_id, ensure_dir, safe_filename, | |
| get_file_size_mb, build_success_response, build_error_response, | |
| load_json, | |
| ) | |
| from backend.utils.logger import get_logger | |
| logger = get_logger(__name__) | |
| # ── In-memory job store (replace with DB in production) ────────────────────── | |
| _jobs: Dict[str, Dict] = {} | |
| # ── Sub-routers ─────────────────────────────────────────────────────────────── | |
| process_router = APIRouter() | |
| query_router = APIRouter() | |
| status_router = APIRouter() | |
| notes_router = APIRouter() | |
| # ============================================================================= | |
| # PROCESS — Upload + run full pipeline | |
| # ============================================================================= | |
| async def upload_and_process( | |
| background_tasks: BackgroundTasks, | |
| file: UploadFile = File(...), | |
| openai_key: Optional[str] = Form(None), | |
| notes_language: Optional[str] = Form("English"), | |
| ): | |
| """ | |
| Upload a video file and start the AI processing pipeline. | |
| Returns a job_id to poll for status. | |
| """ | |
| # Validate file type | |
| allowed = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".mp3", ".wav", ".m4a"} | |
| ext = Path(file.filename).suffix.lower() | |
| if ext not in allowed: | |
| raise HTTPException(400, f"Unsupported file type: {ext}") | |
| # Save uploaded file chunk by chunk to avoid out-of-memory errors | |
| job_id = generate_job_id() | |
| filename = safe_filename(Path(file.filename).stem) + ext | |
| video_path = os.path.join(settings.UPLOAD_DIR, f"{job_id}{ext}") | |
| ensure_dir(settings.UPLOAD_DIR) | |
| size_bytes = 0 | |
| with open(video_path, "wb") as f: | |
| while chunk := await file.read(1024 * 1024): # Read in 1MB chunks | |
| size_bytes += len(chunk) | |
| if size_bytes > settings.max_file_size_bytes: | |
| os.remove(video_path) | |
| raise HTTPException(413, f"File too large: max {settings.MAX_FILE_SIZE_MB} MB") | |
| f.write(chunk) | |
| size_mb = size_bytes / (1024 * 1024) | |
| logger.info(f"Job {job_id}: uploaded '{filename}' ({size_mb:.1f} MB)") | |
| # Store API key in job info instead of global os.environ | |
| # to prevent race conditions between concurrent requests. | |
| # Register job | |
| _jobs[job_id] = { | |
| "job_id": job_id, | |
| "filename": filename, | |
| "status": "queued", | |
| "progress": 0, | |
| "message": "Queued for processing", | |
| "video_path": video_path, | |
| "notes_language": notes_language or "English", | |
| "openai_key": openai_key, | |
| "result": None, | |
| "error": None, | |
| } | |
| # Run pipeline in background | |
| background_tasks.add_task(_run_pipeline, job_id, video_path, filename) | |
| return JSONResponse( | |
| build_success_response( | |
| {"job_id": job_id, "filename": filename, "size_mb": round(size_mb, 2)}, | |
| message="Upload successful. Processing started.", | |
| ) | |
| ) | |
| def _run_pipeline(job_id: str, video_path: str, filename: str): | |
| """Background task: runs the full AI pipeline for a given job.""" | |
| from backend.services.audio_extractor import AudioExtractor | |
| from backend.services.whisper_transcriber import WhisperTranscriber | |
| from backend.services.text_chunker import TextChunker | |
| from backend.services.summarizer import Summarizer | |
| from backend.services.rag_pipeline import RAGPipeline | |
| from backend.services.quiz_generator import QuizGenerator | |
| from backend.services.topic_extractor import TopicExtractor | |
| from backend.services.note_generator import NoteGenerator | |
| def update(progress: int, message: str): | |
| _jobs[job_id]["progress"] = progress | |
| _jobs[job_id]["message"] = message | |
| _jobs[job_id]["status"] = "processing" | |
| logger.info(f"[{job_id}] {progress}% — {message}") | |
| try: | |
| update(5, "Extracting audio from video...") | |
| extractor = AudioExtractor() | |
| audio_path = extractor.extract(video_path, job_id) | |
| duration = extractor.get_video_duration(video_path) | |
| api_key = _jobs[job_id].get("openai_key") | |
| update(20, "Transcribing audio with Whisper ASR...") | |
| transcriber = WhisperTranscriber(api_key=api_key) | |
| transcript = transcriber.transcribe(audio_path, job_id) | |
| update(40, "Chunking transcript...") | |
| chunker = TextChunker() | |
| chunks = chunker.chunk_transcript(transcript) | |
| update(50, "Building RAG vector index...") | |
| rag = RAGPipeline() | |
| rag.index_chunks(chunks) | |
| rag.save_index() | |
| update(60, "Summarizing chunks with LLM...") | |
| summarizer = Summarizer(api_key=api_key) | |
| # Language: prefer the user-selected language; fall back to auto-detected transcript language | |
| user_language = _jobs[job_id].get("notes_language", "English") | |
| lang_code = transcript.get("language", "en") | |
| lang_fallback_map = { | |
| "en": "English", "hi": "Hindi", "mr": "Marathi", | |
| "es": "Spanish", "fr": "French", "de": "German", | |
| "zh": "Chinese", "ja": "Japanese", | |
| } | |
| language = user_language if user_language else lang_fallback_map.get(lang_code, "English") | |
| logger.info(f"[{job_id}] Notes language: {language}") | |
| summarized_chunks = summarizer.summarize_chunks(chunks, language=language) | |
| update(75, "Generating final structured notes...") | |
| final_notes = summarizer.generate_final_notes(summarized_chunks, language=language) | |
| update(82, "Generating interactive quiz...") | |
| q_gen = QuizGenerator(api_key=api_key) | |
| quiz_data = q_gen.generate_quiz(chunks, language=language) | |
| update(90, "Extracting topic summaries...") | |
| topic_ext = TopicExtractor(api_key=api_key) | |
| topics = topic_ext.extract(chunks, language=language) | |
| update(91, "Extracting action items...") | |
| from backend.services.action_item_extractor import ActionItemExtractor | |
| action_ext = ActionItemExtractor(api_key=api_key) | |
| action_items = action_ext.extract(chunks, language=language) | |
| update(92, "Mapping timestamps and chapters...") | |
| from backend.services.timestamp_mapper import TimestampMapper | |
| ts_mapper = TimestampMapper() | |
| highlights = ts_mapper.map_timestamps(summarized_chunks) | |
| chapters = ts_mapper.generate_chapter_markers(highlights) | |
| update(93, "Generating Q&A pairs...") | |
| from backend.services.qa_generator import QAGenerator | |
| qa_gen = QAGenerator(api_key=api_key) | |
| qa_pairs = qa_gen.generate_qa(chunks, language=language) | |
| update(95, "Assembling final notes document...") | |
| generator = NoteGenerator() | |
| result = generator.generate( | |
| job_id=job_id, | |
| filename=filename, | |
| transcript=transcript, | |
| summarized_chunks=summarized_chunks, | |
| final_notes=final_notes, | |
| quiz=quiz_data, | |
| topics=topics, | |
| qa_pairs=qa_pairs, | |
| action_items=action_items, | |
| highlights=highlights, | |
| chapters=chapters, | |
| duration=duration, | |
| ) | |
| _jobs[job_id].update({ | |
| "status": "complete", | |
| "progress": 100, | |
| "message": "Processing complete!", | |
| "result": result["data"], | |
| "markdown": result["markdown"], | |
| }) | |
| logger.info(f"[{job_id}] ✅ Pipeline complete") | |
| except Exception as e: | |
| logger.error(f"[{job_id}] Pipeline error: {e}", exc_info=True) | |
| _jobs[job_id].update({ | |
| "status": "error", | |
| "message": f"Processing failed: {str(e)}", | |
| "error": str(e), | |
| }) | |
| # ============================================================================= | |
| # STATUS — Poll job progress | |
| # ============================================================================= | |
| async def get_job_status(job_id: str): | |
| """Get the current status and progress of a processing job.""" | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, f"Job not found: {job_id}") | |
| return JSONResponse( | |
| build_success_response({ | |
| "job_id": job_id, | |
| "status": job["status"], | |
| "progress": job["progress"], | |
| "message": job["message"], | |
| "filename": job.get("filename", ""), | |
| }) | |
| ) | |
| async def list_jobs(): | |
| """List all processing jobs.""" | |
| jobs_summary = [ | |
| { | |
| "job_id": jid, | |
| "filename": j.get("filename"), | |
| "status": j["status"], | |
| "progress": j["progress"], | |
| } | |
| for jid, j in _jobs.items() | |
| ] | |
| return JSONResponse(build_success_response({"jobs": jobs_summary})) | |
| # ============================================================================= | |
| # NOTES — Retrieve results | |
| # ============================================================================= | |
| async def get_notes(job_id: str): | |
| """Retrieve the structured notes for a completed job.""" | |
| job = _jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, f"Job not found: {job_id}") | |
| if job["status"] != "complete": | |
| raise HTTPException(409, f"Job not complete yet. Status: {job['status']}") | |
| return JSONResponse( | |
| build_success_response({ | |
| "job_id": job_id, | |
| "filename": job.get("filename"), | |
| "notes": job["result"], | |
| "markdown": job.get("markdown", ""), | |
| }) | |
| ) | |
| async def download_notes(job_id: str): | |
| """Download notes as a Markdown file.""" | |
| md_path = os.path.join(settings.OUTPUT_DIR, "final_notes", f"{job_id}_notes.md") | |
| if not os.path.exists(md_path): | |
| raise HTTPException(404, "Notes file not found. Process the video first.") | |
| return FileResponse( | |
| md_path, | |
| media_type="text/markdown", | |
| filename=f"notes_{job_id}.md", | |
| ) | |
| async def download_notes_json(job_id: str): | |
| """Download notes as a JSON file.""" | |
| json_path = os.path.join(settings.OUTPUT_DIR, "final_notes", f"{job_id}_notes.json") | |
| if not os.path.exists(json_path): | |
| raise HTTPException(404, "JSON notes file not found.") | |
| return FileResponse( | |
| json_path, | |
| media_type="application/json", | |
| filename=f"notes_{job_id}.json", | |
| ) | |
| class TranslateRequest(BaseModel): | |
| job_id: str | |
| language: str | |
| async def translate_notes_endpoint(req: TranslateRequest): | |
| """Translate the final notes to a specified language.""" | |
| from backend.services.summarizer import Summarizer | |
| job = _jobs.get(req.job_id) | |
| if not job: | |
| raise HTTPException(404, f"Job not found: {req.job_id}") | |
| if job["status"] != "complete": | |
| raise HTTPException(409, "Job not complete yet.") | |
| markdown_notes = job.get("markdown", "") | |
| if not markdown_notes: | |
| raise HTTPException(404, "No notes found to translate.") | |
| try: | |
| api_key = job.get("openai_key") | |
| summarizer = Summarizer(api_key=api_key) | |
| translated_markdown = summarizer.translate_notes(markdown_notes, req.language) | |
| # New: Translate all structured data so the whole UI flips | |
| from backend.services.translator import Translator | |
| translator = Translator(api_key=api_key) | |
| notes_data = job.get("result", {}) | |
| translated_quiz = translator.translate_json_array(notes_data.get("quiz", []), req.language) | |
| translated_topics = translator.translate_json_array(notes_data.get("topics", []), req.language) | |
| translated_qa = translator.translate_json_array(notes_data.get("qa_pairs", []), req.language) | |
| translated_transcript = translator.translate_transcript(notes_data.get("transcript_segments", []), req.language) | |
| # Update the job state | |
| job["markdown"] = translated_markdown | |
| job["result"]["quiz"] = translated_quiz | |
| job["result"]["topics"] = translated_topics | |
| job["result"]["qa_pairs"] = translated_qa | |
| job["result"]["transcript_segments"] = translated_transcript | |
| return JSONResponse( | |
| build_success_response({ | |
| "job_id": req.job_id, | |
| "language": req.language, | |
| "translated_markdown": translated_markdown, | |
| "translated_quiz": translated_quiz, | |
| "translated_topics": translated_topics, | |
| "translated_qa_pairs": translated_qa, | |
| "translated_transcript": translated_transcript | |
| }) | |
| ) | |
| except Exception as e: | |
| logger.error(f"Translation error for job {req.job_id}: {e}", exc_info=True) | |
| raise HTTPException(500, f"Translation failed: {str(e)}") | |
| # ============================================================================= | |
| # QUERY — RAG semantic search | |
| # ============================================================================= | |
| class QueryRequest(BaseModel): | |
| job_id: str | |
| query: str | |
| top_k: int = 5 | |
| async def query_transcript(req: QueryRequest): | |
| """ | |
| Perform a semantic search over an indexed video transcript. | |
| Returns the most relevant segments for the given query. | |
| """ | |
| from backend.services.rag_pipeline import RAGPipeline | |
| from backend.services.summarizer import Summarizer | |
| job = _jobs.get(req.job_id) | |
| if not job: | |
| raise HTTPException(404, f"Job not found: {req.job_id}") | |
| if job["status"] != "complete": | |
| raise HTTPException(409, "Job not complete yet.") | |
| rag = RAGPipeline() | |
| loaded = rag.load_index() | |
| if not loaded: | |
| raise HTTPException(503, "RAG index not available for this job.") | |
| results = rag.query(req.query, top_k=req.top_k) | |
| answer = "" | |
| if results: | |
| # Build context from chunks | |
| context_parts = [f"[{r.get('start_ts', '')} → {r.get('end_ts', '')}]: {r.get('text', '')}" for r in results] | |
| context = "\n\n".join(context_parts) | |
| try: | |
| api_key = job.get("openai_key") | |
| summarizer = Summarizer(api_key=api_key) | |
| answer = summarizer.answer_question(req.query, context) | |
| except Exception as e: | |
| logger.error(f"Error generating answer for query '{req.query}': {e}") | |
| answer = "Sorry, I encountered an error while trying to answer your question." | |
| else: | |
| answer = "No relevant sections found in the video to answer your question." | |
| return JSONResponse( | |
| build_success_response({ | |
| "query": req.query, | |
| "answer": answer, | |
| "results": results, | |
| }) | |
| ) | |