-
Notifications
You must be signed in to change notification settings - Fork 1
Implement MVP podcast generation #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 3 commits
72a94ee
b3b3373
d9a78a1
922f6fa
a46b4f2
c71e460
1b5498c
b7a6b65
62e60e6
0bbe907
6d03127
c7657d6
6809309
c204ea5
bd74fda
71a8c0c
5699040
cbc3797
d6061fe
7582c05
74102ba
8a79243
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| """add podcast table | ||
|
|
||
| Revision ID: 2042a1f0c0a1 | ||
| Revises: 10368f38610b | ||
| Create Date: 2025-10-05 06:00:00.000000 | ||
|
|
||
| """ | ||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| import sqlmodel.sql.sqltypes | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = '2042a1f0c0a1' | ||
| down_revision = '2cde6f094a4e' | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade(): | ||
| op.create_table( | ||
| 'podcast', | ||
| sa.Column('id', sa.Uuid(), nullable=False), | ||
| sa.Column('course_id', sa.Uuid(), nullable=False), | ||
| sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), | ||
| sa.Column('transcript', sa.Text(), nullable=False), | ||
| sa.Column('audio_path', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False), | ||
| sa.Column('storage_backend', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), | ||
| sa.Column('duration_seconds', sa.Float(), nullable=True), | ||
| sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), | ||
| sa.Column('updated_at', sa.DateTime(), nullable=False), | ||
| sa.ForeignKeyConstraint(['course_id'], ['course.id'], ondelete='CASCADE'), | ||
| sa.PrimaryKeyConstraint('id') | ||
| ) | ||
|
|
||
|
|
||
| def downgrade(): | ||
| op.drop_table('podcast') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import tempfile | ||
| import uuid | ||
| from asyncio.log import logger | ||
| import logging | ||
| from datetime import datetime, timezone | ||
| from typing import Any | ||
|
|
||
|
|
@@ -36,7 +37,8 @@ | |
| MAX_FILE_SIZE_MB = 25 | ||
| MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 | ||
|
|
||
| pc = Pinecone(api_key=PINECONE_API_KEY, environment=PINECONE_ENV_NAME) | ||
| pc = Pinecone(api_key=PINECONE_API_KEY) | ||
| log = logging.getLogger(__name__) | ||
|
|
||
| task_status: dict[str, str] = {} | ||
|
|
||
|
|
@@ -46,8 +48,15 @@ | |
| def ensure_index_exists(): | ||
| """Ensure Pinecone index exists with the correct dimension, recreate if wrong.""" | ||
| if pc.has_index(index_name): | ||
| log.info("[DOCS] Pinecone index exists | name=%s", index_name) | ||
| existing = pc.describe_index(index_name) | ||
| if existing.dimension != EXPECTED_DIMENSION: | ||
| log.warning( | ||
| "[DOCS] Index dimension mismatch | name=%s | have=%s want=%s — recreating", | ||
| index_name, | ||
| existing.dimension, | ||
| EXPECTED_DIMENSION, | ||
| ) | ||
| pc.delete_index(index_name) | ||
| pc.create_index( | ||
| name=index_name, | ||
|
|
@@ -56,6 +65,7 @@ def ensure_index_exists(): | |
| spec=ServerlessSpec(cloud="aws", region="us-east-1"), | ||
| ) | ||
| else: | ||
| log.info("[DOCS] Creating Pinecone index | name=%s dim=%s", index_name, EXPECTED_DIMENSION) | ||
| pc.create_index( | ||
| name=index_name, | ||
| dimension=EXPECTED_DIMENSION, | ||
|
|
@@ -165,6 +175,7 @@ async def process_pdf_task(file_path: str, document_id: uuid.UUID, session: Sess | |
| "id": embedding_uuid, | ||
| "values": embedding, | ||
| "metadata": { | ||
| "course_id": str(document.course_id), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for fixing this. I was curious why it wasn't working. |
||
| "document_id": str(document_id), | ||
| "chunk_id": str(record.id), | ||
| "text": record.text_content, | ||
|
|
@@ -176,6 +187,16 @@ async def process_pdf_task(file_path: str, document_id: uuid.UUID, session: Sess | |
| session.commit() | ||
|
|
||
| index = pc.Index(index_name) | ||
| log.info( | ||
| "[DOCS] Upserting vectors | index=%s | count=%d | course_id=%s | document_id=%s", | ||
| index_name, | ||
| len(vectors_to_upsert), | ||
| str(document.course_id), | ||
| str(document_id), | ||
| ) | ||
| if vectors_to_upsert: | ||
| sample_meta = vectors_to_upsert[0].get("metadata", {}) | ||
| log.info("[DOCS] Sample vector metadata keys=%s", list(sample_meta.keys())) | ||
| index.upsert(vectors=vectors_to_upsert) | ||
|
|
||
| document.updated_at = datetime.now(timezone.utc) | ||
|
|
@@ -290,6 +311,23 @@ async def process_multiple_documents( | |
| return {"message": "Processing started for multiple files", "documents": results} | ||
|
|
||
|
|
||
| @router.get("/by-course/{course_id}") | ||
|
Alameen688 marked this conversation as resolved.
Outdated
Alameen688 marked this conversation as resolved.
Outdated
|
||
| def list_documents_by_course(session: SessionDep, current_user: CurrentUser, course_id: uuid.UUID) -> Any: | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| """List documents for a course with basic fields.""" | ||
| docs = session.exec(select(Document).where(Document.course_id == course_id)).all() | ||
| return [ | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| { | ||
| "id": d.id, | ||
| "title": d.title, | ||
| "filename": d.filename, | ||
| "status": d.status.value if hasattr(d.status, 'value') else str(d.status), | ||
| "created_at": d.created_at, | ||
| "updated_at": d.updated_at, | ||
| } | ||
| for d in docs | ||
| ] | ||
|
|
||
|
|
||
| @router.get("/{id}", response_model=Document) | ||
| def read_document(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: | ||
| """Get a document by its ID, ensuring the user has permissions.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import os | ||
| import uuid | ||
| from typing import Any, Optional, List | ||
|
|
||
| from fastapi import APIRouter, HTTPException | ||
| from fastapi.responses import StreamingResponse, JSONResponse | ||
| from pydantic import BaseModel | ||
|
|
||
| from app.api.deps import CurrentUser, SessionDep | ||
| from sqlmodel import select | ||
| from sqlalchemy.orm import selectinload | ||
| from app.core.config import settings | ||
| from app.models.podcast import Podcast | ||
| from app.schemas.public import PodcastPublic, PodcastsPublic | ||
| from app.services.podcast_service import generate_podcast_for_course | ||
|
|
||
| router = APIRouter(prefix="/podcasts", tags=["podcasts"]) | ||
|
|
||
|
|
||
| @router.get("/{course_id}", response_model=PodcastsPublic) | ||
| def list_podcasts(course_id: uuid.UUID, session: SessionDep, current_user: CurrentUser) -> Any: | ||
| pods = session.exec(select(Podcast).where(Podcast.course_id == course_id)).all() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some pagination might be useful here
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. We can add this as a feature in another PR on both the backend and frontend. |
||
| return PodcastsPublic(data=[PodcastPublic.model_validate(p) for p in pods]) | ||
|
|
||
|
|
||
| class GeneratePodcastRequest(BaseModel): | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| title: Optional[str] = None | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| mode: Optional[str] = None # 'dialogue' | 'presentation' | ||
|
Alameen688 marked this conversation as resolved.
Outdated
Alameen688 marked this conversation as resolved.
Outdated
|
||
| topics: Optional[str] = None | ||
| teacher_voice: Optional[str] = None | ||
| student_voice: Optional[str] = None | ||
| narrator_voice: Optional[str] = None | ||
| document_ids: Optional[List[uuid.UUID]] = None | ||
|
|
||
|
|
||
| @router.post("/{course_id}/generate", response_model=PodcastPublic) | ||
| async def generate_podcast( | ||
| course_id: uuid.UUID, | ||
| session: SessionDep, | ||
| current_user: CurrentUser, | ||
| body: GeneratePodcastRequest | None = None, | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| ) -> Any: | ||
| if not body or not body.title or not body.title.strip(): | ||
| raise HTTPException(status_code=422, detail="Title is required") | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| title = body.title.strip() | ||
| mode = (body.mode or "dialogue") if body else "dialogue" | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| topics = body.topics if body else None | ||
| teacher_voice = body.teacher_voice if body and body.teacher_voice else settings.PODCAST_TEACHER_VOICE | ||
| student_voice = body.student_voice if body and body.student_voice else settings.PODCAST_STUDENT_VOICE | ||
| narrator_voice = body.narrator_voice if body and body.narrator_voice else settings.PODCAST_TEACHER_VOICE | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All these aren't really necessary if we use enum type and specify a default value |
||
| doc_ids = body.document_ids if body and body.document_ids else None | ||
| podcast = await generate_podcast_for_course( | ||
| session, | ||
| course_id, | ||
| title, | ||
| teacher_voice, | ||
| student_voice, | ||
| narrator_voice, | ||
| mode, | ||
| topics, | ||
| doc_ids, | ||
| ) | ||
| return PodcastPublic.model_validate(podcast) | ||
|
|
||
|
|
||
| @router.get("/by-id/{podcast_id}", response_model=PodcastPublic) | ||
|
Alameen688 marked this conversation as resolved.
Outdated
Alameen688 marked this conversation as resolved.
Outdated
|
||
| def get_podcast(podcast_id: uuid.UUID, session: SessionDep, current_user: CurrentUser) -> Any: | ||
| pod = session.get(Podcast, podcast_id) | ||
| if not pod: | ||
| raise HTTPException(status_code=404, detail="Podcast not found") | ||
| return PodcastPublic.model_validate(pod) | ||
|
|
||
|
|
||
| @router.get("/by-id/{podcast_id}/audio") | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| def stream_audio(podcast_id: uuid.UUID, session: SessionDep, current_user: CurrentUser): | ||
| pod = session.get(Podcast, podcast_id) | ||
| if not pod: | ||
| raise HTTPException(status_code=404, detail="Podcast not found") | ||
| if pod.storage_backend == "local": | ||
| file_path = pod.audio_path | ||
| if not os.path.exists(file_path): | ||
|
michaelgichia marked this conversation as resolved.
Outdated
|
||
| raise HTTPException(status_code=404, detail="Audio file missing") | ||
| def iterfile(): | ||
| with open(file_path, "rb") as f: | ||
| while chunk := f.read(8192): | ||
| yield chunk | ||
| return StreamingResponse(iterfile(), media_type="audio/mpeg") | ||
| else: | ||
| # For S3, return a presigned URL to let client fetch directly | ||
| try: | ||
| import boto3 | ||
| s3 = boto3.client( | ||
| "s3", | ||
| aws_access_key_id=settings.AWS_ACCESS_KEY_ID, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Share the S3 credentials with @deluakin so that he can add them to the Render Backend API service. |
||
| aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, | ||
| region_name=settings.AWS_REGION, | ||
| ) | ||
| bucket = settings.S3_BUCKET_NAME | ||
| if not bucket: | ||
| raise ValueError("S3 bucket not configured") | ||
| key = pod.audio_path.replace(f"s3://{bucket}/", "") if pod.audio_path.startswith("s3://") else pod.audio_path | ||
| url = s3.generate_presigned_url( | ||
| ClientMethod='get_object', | ||
| Params={'Bucket': bucket, 'Key': key}, | ||
| ExpiresIn=3600, | ||
| ) | ||
| return JSONResponse({"url": url}) | ||
| except Exception as e: | ||
| raise HTTPException(status_code=500, detail=f"Failed to generate S3 URL: {e}") | ||
|
|
||
|
|
||
| @router.delete("/by-id/{podcast_id}") | ||
|
Alameen688 marked this conversation as resolved.
Outdated
|
||
| def delete_podcast(podcast_id: uuid.UUID, session: SessionDep, current_user: CurrentUser) -> Any: | ||
| pod = session.exec( | ||
| select(Podcast).where(Podcast.id == podcast_id).options(selectinload(Podcast.course)) # type: ignore | ||
| ).first() | ||
|
|
||
| if not pod: | ||
| raise HTTPException(status_code=404, detail="Podcast not found") | ||
|
|
||
| # Permission: owner or superuser | ||
| if not current_user.is_superuser and getattr(pod, "course", None) and pod.course.owner_id != current_user.id: # type: ignore | ||
| raise HTTPException(status_code=403, detail="Not enough permissions to delete this podcast") | ||
|
|
||
| # Best-effort delete of underlying media | ||
| try: | ||
| if pod.storage_backend == "local" and pod.audio_path and os.path.exists(pod.audio_path): | ||
| try: | ||
| os.remove(pod.audio_path) | ||
| except Exception: | ||
| pass | ||
| elif pod.storage_backend == "s3" and pod.audio_path: | ||
| try: | ||
| import boto3 | ||
| bucket = settings.S3_BUCKET_NAME | ||
| if bucket: | ||
| key = pod.audio_path.replace(f"s3://{bucket}/", "") if pod.audio_path.startswith("s3://") else pod.audio_path | ||
| s3 = boto3.client( | ||
| "s3", | ||
| aws_access_key_id=settings.AWS_ACCESS_KEY_ID, | ||
| aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, | ||
| region_name=settings.AWS_REGION, | ||
| ) | ||
| s3.delete_object(Bucket=bucket, Key=key) | ||
| except Exception: | ||
| # ignore media delete failures | ||
| pass | ||
| finally: | ||
| session.delete(pod) | ||
| session.commit() | ||
| return {"message": "Podcast deleted successfully"} | ||
Uh oh!
There was an error while loading. Please reload this page.