Video-Note-Taker / backend /api /endpoints.py
rajiv-ramteke's picture
Fix TimestampMapper instantiation argument error
9174856
Raw
History Blame Contribute Delete
15.6 kB
"""
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
# =============================================================================
@process_router.post("/upload")
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
# =============================================================================
@status_router.get("/{job_id}")
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", ""),
})
)
@status_router.get("/")
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
# =============================================================================
@notes_router.get("/{job_id}")
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", ""),
})
)
@notes_router.get("/{job_id}/download")
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",
)
@notes_router.get("/{job_id}/download/json")
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
@notes_router.post("/translate")
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
@query_router.post("/")
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,
})
)