Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .env.example
Comment thread
michaelgichia marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,19 @@ PINECONE_API_KEY=changethis

OPENAI_API_KEY=changethis

NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8000
NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8000

NEXT_INTERNAL_BACKEND_BASE_URL=http://backend:8000

# Podcast storage configuration
# "local" will store files under backend container at /app/podcasts
# "s3" will upload to an S3 bucket using the credentials below
PODCAST_STORAGE=local
PODCAST_LOCAL_DIR=/app/podcasts
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET_NAME=
S3_PREFIX=podcasts/
PODCAST_TEACHER_VOICE=coral
PODCAST_STUDENT_VOICE=alloy
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,16 @@


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chat',
sa.Column('message', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=True),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('course_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['course_id'], ['course.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# This migration originally (incorrectly) re-created the 'chat' table.
# The chat table is created in migration '6e308b39ff60_add_chat_table'.
# Keep only the intended FK change for 'quizattempt'.
op.drop_constraint(op.f('quizattempt_quiz_id_fkey'), 'quizattempt', type_='foreignkey')
op.create_foreign_key(None, 'quizattempt', 'quiz', ['quiz_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Revert the FK change only; do not drop 'chat' which belongs to a prior migration.
op.drop_constraint(None, 'quizattempt', type_='foreignkey')
op.create_foreign_key(op.f('quizattempt_quiz_id_fkey'), 'quizattempt', 'quiz', ['quiz_id'], ['id'])
op.drop_table('chat')
# ### end Alembic commands ###
38 changes: 38 additions & 0 deletions backend/app/alembic/versions/2042a1f0c0a1_add_podcast_table.py
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')
2 changes: 2 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
courses,
documents,
items,
podcasts,
login,
private,
quiz_sessions,
Expand All @@ -21,6 +22,7 @@
api_router.include_router(courses.router)
api_router.include_router(chat.router)
api_router.include_router(documents.router)
api_router.include_router(podcasts.router)
api_router.include_router(quiz_sessions.router)

if settings.ENVIRONMENT == "local":
Expand Down
11 changes: 10 additions & 1 deletion backend/app/api/routes/chat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from collections.abc import AsyncGenerator

import logging
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
Expand All @@ -11,6 +12,7 @@
from app.services.chat_db import verify_course_access, get_all_messages, create_greeting_if_needed

router = APIRouter(prefix="/chat", tags=["chat"])
logger = logging.getLogger(__name__)


class ChatMessage(BaseModel):
Expand Down Expand Up @@ -79,6 +81,13 @@ async def stream_chat(
Returns:
Streaming response of AI-generated content
"""
logger.info(
"[API] /chat/%s/stream | continue=%s | user_id=%s | preview=%s",
str(course_id),
chat.continue_response,
str(current_user.id),
chat.message[:120],
)
return StreamingResponse(
generate_chat_response(
chat.message,
Expand Down Expand Up @@ -139,4 +148,4 @@ async def get_chat_history(
return []

# Convert to ChatPublic
return [ChatPublic(**msg.model_dump()) for msg in messages]
return [ChatPublic(**msg.model_dump()) for msg in messages]
40 changes: 39 additions & 1 deletion backend/app/api/routes/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tempfile
import uuid
from asyncio.log import logger
import logging
from datetime import datetime, timezone
from typing import Any

Expand Down Expand Up @@ -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] = {}

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
Expand All @@ -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)
Expand Down Expand Up @@ -290,6 +311,23 @@ async def process_multiple_documents(
return {"message": "Processing started for multiple files", "documents": results}


@router.get("/by-course/{course_id}")
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
def list_documents_by_course(session: SessionDep, current_user: CurrentUser, course_id: uuid.UUID) -> Any:
Comment thread
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 [
Comment thread
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."""
Expand Down
151 changes: 151 additions & 0 deletions backend/app/api/routes/podcasts.py
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some pagination might be useful here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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):
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
title: Optional[str] = None
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
mode: Optional[str] = None # 'dialogue' | 'presentation'
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
Comment thread
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,
Comment thread
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")
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
title = body.title.strip()
mode = (body.mode or "dialogue") if body else "dialogue"
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)
Comment thread
Alameen688 marked this conversation as resolved.
Outdated
Comment thread
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")
Comment thread
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):
Comment thread
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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}")
Comment thread
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"}
Loading