diff --git a/.env.example b/.env.example index e1e9179..e48f999 100644 --- a/.env.example +++ b/.env.example @@ -50,4 +50,19 @@ PINECONE_API_KEY=changethis OPENAI_API_KEY=changethis -NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8000 \ No newline at end of file +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=AKIA... +AWS_SECRET_ACCESS_KEY=ieb... +AWS_REGION=changethis +S3_BUCKET_NAME=changethis +S3_PREFIX=podcasts/ +PODCAST_TEACHER_VOICE=coral +PODCAST_STUDENT_VOICE=alloy diff --git a/backend/app/alembic/versions/2042a1f0c0a1_add_podcast_table.py b/backend/app/alembic/versions/2042a1f0c0a1_add_podcast_table.py new file mode 100644 index 0000000..fa7bdd5 --- /dev/null +++ b/backend/app/alembic/versions/2042a1f0c0a1_add_podcast_table.py @@ -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') diff --git a/backend/app/alembic/versions/a9b7c6d5e4f3_merge_heads_podcast_and_dev.py b/backend/app/alembic/versions/a9b7c6d5e4f3_merge_heads_podcast_and_dev.py new file mode 100644 index 0000000..4d31f45 --- /dev/null +++ b/backend/app/alembic/versions/a9b7c6d5e4f3_merge_heads_podcast_and_dev.py @@ -0,0 +1,27 @@ +"""merge heads: podcast + dev + +Revision ID: a9b7c6d5e4f3 +Revises: ('2042a1f0c0a1', '64343f21e9a8') +Create Date: 2025-10-06 00:00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9b7c6d5e4f3' +down_revision = ('2042a1f0c0a1', '64343f21e9a8') +branch_labels = None +depends_on = None + + +def upgrade(): + # Merge point: no-op + pass + + +def downgrade(): + # Merge point: no-op + pass + diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 558f556..9d6a397 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -5,6 +5,7 @@ courses, documents, items, + podcasts, login, private, quiz_sessions, @@ -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": diff --git a/backend/app/api/routes/courses.py b/backend/app/api/routes/courses.py index 28f9002..9640f15 100644 --- a/backend/app/api/routes/courses.py +++ b/backend/app/api/routes/courses.py @@ -171,10 +171,10 @@ def delete_course( return {"message": "Course deleted successfully"} -@router.get("/{id}/documents", response_model=list[dict[str, Any]]) +@router.get("/{id}/documents", response_model=list[DocumentPublic]) async def list_documents( id: str, session: SessionDep, skip: int = 0, limit: int = 100 -) -> list[dict[str, Any]]: +) -> list[DocumentPublic]: """ List documents for a specific course. """ @@ -182,16 +182,9 @@ async def list_documents( select(Document).where(Document.course_id == id).offset(skip).limit(limit) ) documents = session.exec(statement).all() - return [ - { - "id": str(doc.id), - "filename": doc.filename, - "chunk_count": doc.chunk_count, - "status": doc.status, - "updated_at": doc.updated_at.isoformat(), - } - for doc in documents - ] + + # Use the public schema's from_attributes (ORM mode) to convert DB models + return [DocumentPublic.model_validate(doc) for doc in documents] @router.get("/{id}/quizzes", response_model=QuizzesPublic) diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py index b9bc700..a7fa7f1 100644 --- a/backend/app/api/routes/documents.py +++ b/backend/app/api/routes/documents.py @@ -4,6 +4,7 @@ import tempfile import uuid from asyncio.log import logger +import logging from datetime import datetime, timezone from typing import Any @@ -21,7 +22,7 @@ from app.models.course import Course from app.models.document import Document from app.models.embeddings import Chunk -from app.schemas.public import DocumentStatus +from app.schemas.public import DocumentStatus, DocumentPublic from app.tasks import generate_quizzes_task router = APIRouter(prefix="/documents", tags=["documents"]) @@ -37,6 +38,7 @@ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 pc = Pinecone(api_key=PINECONE_API_KEY, environment=PINECONE_ENV_NAME) +log = logging.getLogger(__name__) task_status: dict[str, str] = {} @@ -48,6 +50,12 @@ def ensure_index_exists(): if pc.has_index(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, @@ -165,6 +173,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), "document_id": str(document_id), "chunk_id": str(record.id), "text": record.text_content, @@ -290,8 +299,8 @@ async def process_multiple_documents( return {"message": "Processing started for multiple files", "documents": results} -@router.get("/{id}", response_model=Document) -def read_document(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: +@router.get("/{id}", response_model=DocumentPublic) +def read_document(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> DocumentPublic: """Get a document by its ID, ensuring the user has permissions.""" statement = ( select(Document) @@ -308,7 +317,7 @@ def read_document(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) detail="Document not found or you do not have permission to access it.", ) - return document + return DocumentPublic.model_validate(document) def delete_embeddings_task(document_id: uuid.UUID): @@ -321,13 +330,13 @@ def delete_embeddings_task(document_id: uuid.UUID): logger.error(f"Failed to delete embeddings for document {document_id}: {e}") -@router.delete("/{id}") +@router.delete("/{id}", response_model=Message) def delete_document( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, background_tasks: BackgroundTasks, -) -> Any: +) -> Message: """Delete a document by its ID, ensuring the user has permissions.""" document = session.exec( diff --git a/backend/app/api/routes/podcasts.py b/backend/app/api/routes/podcasts.py new file mode 100644 index 0000000..1538e93 --- /dev/null +++ b/backend/app/api/routes/podcasts.py @@ -0,0 +1,140 @@ +import os +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from sqlalchemy.orm import selectinload +from sqlmodel import select + +from app.api.deps import CurrentUser, SessionDep +from app.core.config import settings +from app.models.podcast import Podcast +from app.schemas.internal import GeneratePodcastRequest +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/{course_id}", response_model=PodcastsPublic) +def list_podcasts(course_id: uuid.UUID, session: SessionDep, _current_user: CurrentUser, skip: int = 0, limit: int = 50) -> PodcastsPublic: + pods = session.exec(select(Podcast).where(Podcast.course_id == course_id).order_by(Podcast.created_at.desc()).offset(skip).limit(limit)).all() + return PodcastsPublic(data=[PodcastPublic.model_validate(p) for p in pods]) + + + +@router.post("/course/{course_id}/generate", response_model=PodcastPublic) +async def generate_podcast( + course_id: uuid.UUID, + session: SessionDep, + _current_user: CurrentUser, + body: GeneratePodcastRequest, +) -> PodcastPublic: + title = body.title.strip() + mode = body.mode + topics = body.topics + teacher_voice = body.teacher_voice or settings.PODCAST_TEACHER_VOICE + student_voice = body.student_voice or settings.PODCAST_STUDENT_VOICE + narrator_voice = body.narrator_voice or settings.PODCAST_TEACHER_VOICE + doc_ids = body.document_ids + 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("/{podcast_id}", response_model=PodcastPublic) +def get_podcast(podcast_id: uuid.UUID, session: SessionDep, _current_user: CurrentUser) -> PodcastPublic: + 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("/{podcast_id}/audio") +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): + 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, + 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("/{podcast_id}") +def delete_podcast(podcast_id: uuid.UUID, session: SessionDep, current_user: CurrentUser) -> dict[str, str]: + 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"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ee13bec..cee66cb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -93,6 +93,17 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + # Podcast/Audio storage settings + PODCAST_STORAGE: Literal["local", "s3"] = "local" + PODCAST_LOCAL_DIR: str = "/app/podcasts" + AWS_ACCESS_KEY_ID: str | None = None + AWS_SECRET_ACCESS_KEY: str | None = None + AWS_REGION: str | None = None + S3_BUCKET_NAME: str | None = None + S3_PREFIX: str = "podcasts/" + PODCAST_TEACHER_VOICE: str = "coral" + PODCAST_STUDENT_VOICE: str = "alloy" + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cbb7d88..a3ec609 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from .chat import Chat # noqa: F401 +from .podcast import Podcast # noqa: F401 from .common import * # noqa: F403, if you have base mixins here from .course import Course # noqa: F401 from .document import Document # noqa: F401 diff --git a/backend/app/models/course.py b/backend/app/models/course.py index e5d9087..98d0eb3 100644 --- a/backend/app/models/course.py +++ b/backend/app/models/course.py @@ -38,6 +38,13 @@ class Course(CourseBase, table=True): sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) chats: list["Chat"] = Relationship(back_populates="course") # noqa: F821 # type: ignore + podcasts: list["Podcast"] = Relationship( + back_populates="course", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "passive_deletes": True, + }, + ) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), diff --git a/backend/app/models/podcast.py b/backend/app/models/podcast.py new file mode 100644 index 0000000..785791f --- /dev/null +++ b/backend/app/models/podcast.py @@ -0,0 +1,30 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import func +from sqlmodel import Field, Relationship, SQLModel, text + + +class Podcast(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + course_id: uuid.UUID = Field(foreign_key="course.id") + title: str + transcript: str + audio_path: str # local path or S3 key/URL depending on storage backend + storage_backend: str = Field(default="local") + duration_seconds: float | None = None + + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"server_default": text("CURRENT_TIMESTAMP")}, + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={ + "server_default": text("CURRENT_TIMESTAMP"), + "onupdate": func.now(), + }, + ) + + course: "Course" = Relationship(back_populates="podcasts") # noqa: F821 + diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py index e69de29..a7e1182 100644 --- a/backend/app/prompts/__init__.py +++ b/backend/app/prompts/__init__.py @@ -0,0 +1,13 @@ +from .podcast import ( + PROMPT_TEMPLATE, + PROMPT_MONO_TEMPLATE, + dialogue_prompt, + presentation_prompt, +) + +__all__ = [ + "PROMPT_TEMPLATE", + "PROMPT_MONO_TEMPLATE", + "dialogue_prompt", + "presentation_prompt", +] diff --git a/backend/app/prompts/podcast.py b/backend/app/prompts/podcast.py new file mode 100644 index 0000000..6aeb5e3 --- /dev/null +++ b/backend/app/prompts/podcast.py @@ -0,0 +1,105 @@ +"""Prompt templates for podcast generation.""" + +PROMPT_TEMPLATE = ( + "You are producing a short conversational podcast between a Teacher and a Student.\n" + "Use the following course context to guide the conversation. The Teacher should explain core ideas clearly,\n" + "and the Student should ask natural, helpful questions someone might have.\n\n" + "Constraints:\n" + "- Keep it concise (2-4 minutes when spoken).\n" + "- Alternate turns: start with 'Teacher:' then 'Student:', etc.\n" + "- Do not reference that you are an AI.\n" + "- Stay grounded strictly in the provided context; avoid speculation.\n\n" + "Context:\n{context}\n\n" + "Output format:\n" + "Teacher: \n" + "Student: \n" + "Teacher: \n" + "... (2-6 exchanges total)\n" +) + +PROMPT_MONO_TEMPLATE = ( + "You are producing a concise educational presentation (single narrator).\n" + "Use the following course context to deliver a clear, coherent explanation,\n" + "highlighting key ideas, definitions, examples, and takeaways.\n\n" + "Constraints:\n" + "- Keep it to ~2-4 minutes when spoken.\n" + "- No speaker labels or dialogue.\n" + "- Maintain an engaging, instructive, academic tone.\n" + "- Stay strictly grounded in the provided context; no speculation.\n\n" + "Context:\n{context}\n\n" + "Output: A single continuous narrative (no role tags).\n" +) + + +def dialogue_prompt(context: str, title: str | None = None) -> str: + """Format the dialogue prompt with optional title metadata.""" + prefix = f"Title: {title}\n\n" if title else "" + return f"{prefix}{PROMPT_TEMPLATE.format(context=context)}" + + +def presentation_prompt(context: str, title: str | None = None) -> str: + """Format the monologue prompt with optional title metadata.""" + prefix = f"Title: {title}\n\n" if title else "" + return f"{prefix}{PROMPT_MONO_TEMPLATE.format(context=context)}" + + +# TTS Instructions for different roles and modes +TTS_TEACHER_INSTRUCTIONS = """Accent/Affect: Warm, knowledgeable, and patient, like an experienced teacher explaining concepts. + +Tone: Encouraging, clear, and supportive, making learning feel accessible and enjoyable. + +Pacing: Moderate with thoughtful pauses, allowing students to process information. + +Emotion: Patient and enthusiastic, conveying genuine care for student understanding. + +Pronunciation: Clearly articulate educational terms and concepts with gentle emphasis. + +Personality Affect: Friendly and authoritative, speaking with confidence while remaining approachable and supportive.""" + +TTS_STUDENT_INSTRUCTIONS = """Accent/Affect: Curious, engaged, and slightly uncertain, like an eager learner asking questions. + +Tone: Inquisitive, sometimes hesitant, but genuinely interested in understanding. + +Pacing: Slightly faster when excited, with natural pauses when thinking. + +Emotion: Enthusiastic about learning, occasionally confused but always willing to engage. + +Pronunciation: Natural speech patterns with occasional uncertainty or excitement. + +Personality Affect: Genuine curiosity and eagerness to learn, speaking authentically as someone discovering new concepts.""" + +TTS_PRESENTATION_INSTRUCTIONS = """Accent/Affect: Professional, clear, and engaging, like an experienced educator presenting complex topics. + +Tone: Confident, articulate, and well-paced, making content accessible and interesting. + +Pacing: Moderate and deliberate, with natural pauses for emphasis and comprehension. + +Emotion: Enthusiastic about the subject matter, conveying genuine interest and expertise. + +Pronunciation: Clearly articulate technical terms and concepts with appropriate emphasis. + +Personality Affect: Knowledgeable and approachable, speaking with authority while remaining engaging and easy to follow.""" + +TTS_DEFAULT_INSTRUCTIONS = """Accent/Affect: Clear, natural, and conversational. + +Tone: Friendly and engaging, maintaining listener interest. + +Pacing: Natural conversational rhythm with appropriate pauses. + +Emotion: Warm and approachable, conveying genuine interest in the topic. + +Pronunciation: Clear articulation with natural emphasis. + +Personality Affect: Conversational and relatable, speaking in an authentic and engaging manner.""" + + +def get_tts_instructions(role: str, mode: str) -> str: + """Get TTS instructions based on role and mode for better audio quality.""" + if mode == "presentation": + return TTS_PRESENTATION_INSTRUCTIONS + elif role == "teacher": + return TTS_TEACHER_INSTRUCTIONS + elif role == "student": + return TTS_STUDENT_INSTRUCTIONS + else: + return TTS_DEFAULT_INSTRUCTIONS diff --git a/backend/app/schemas/internal.py b/backend/app/schemas/internal.py index d9a09f6..08ec8be 100644 --- a/backend/app/schemas/internal.py +++ b/backend/app/schemas/internal.py @@ -3,6 +3,26 @@ from pydantic import BaseModel, Field from app.schemas.public import DifficultyLevel +import uuid +from enum import Enum + + + +class ModeEnum(str, Enum): + dialogue = "dialogue" + presentation = "presentation" + + +class GeneratePodcastRequest(BaseModel): + """Request body for generating a podcast for a course. + """ + title: str + mode: ModeEnum = ModeEnum.dialogue + topics: str | None = None + teacher_voice: str | None = None + student_voice: str | None = None + narrator_voice: str | None = None + document_ids: list[uuid.UUID] | None = None class PaginationParams(BaseModel): diff --git a/backend/app/schemas/public.py b/backend/app/schemas/public.py index 7c7aca7..68bd457 100644 --- a/backend/app/schemas/public.py +++ b/backend/app/schemas/public.py @@ -38,6 +38,8 @@ class DocumentStatus(str, Enum): class DocumentPublic(PydanticBase): id: uuid.UUID course_id: uuid.UUID + title: str + filename: str updated_at: datetime created_at: datetime status: DocumentStatus @@ -197,13 +199,16 @@ class QuizSessionPublicWithResults(QuizSessionPublicWithQuizzes): class ChatPublic(PydanticBase): + """Public schema for Chat message entries (no quiz fields).""" + id: uuid.UUID - message: str course_id: uuid.UUID + message: str | None = None is_system: bool created_at: datetime updated_at: datetime + class ChatMessage(BaseModel): message: str continue_response: bool = False # Flag to continue previous response @@ -214,4 +219,25 @@ class Config: "message": "What is the main topic of the course?", "continue_response": False, } - } \ No newline at end of file + } + + +# ---------------------------------------------------------------------- +# Podcast Schemas +# ---------------------------------------------------------------------- + + +class PodcastPublic(PydanticBase): + id: uuid.UUID + course_id: uuid.UUID + title: str + transcript: str + audio_path: str + storage_backend: str + duration_seconds: float | None = None + created_at: datetime + updated_at: datetime + + +class PodcastsPublic(BaseModel): + data: list[PodcastPublic] diff --git a/backend/app/services/chat_cache.py b/backend/app/services/chat_cache.py index 6fdb53f..d2a123a 100644 --- a/backend/app/services/chat_cache.py +++ b/backend/app/services/chat_cache.py @@ -1,6 +1,7 @@ """ Chat response caching service """ +import logging import uuid import numpy as np from typing import Optional, Tuple, List @@ -10,6 +11,8 @@ from app.models.chat import Chat from app.api.deps import SessionDep +logger = logging.getLogger(__name__) + # Caching constants SIMILARITY_THRESHOLD = 0.85 # Minimum similarity for cache hit MAX_CACHE_ENTRIES = 100 # Maximum cached responses per course @@ -74,7 +77,7 @@ async def check_cached_response( return cached_response, cached_question except Exception as e: - print(f"Error checking cache similarity: {e}") + logger.exception("[CACHE] Error checking cache similarity: %s", e) continue - return None \ No newline at end of file + return None diff --git a/backend/app/services/chat_service.py b/backend/app/services/chat_service.py index a3ab705..390024f 100644 --- a/backend/app/services/chat_service.py +++ b/backend/app/services/chat_service.py @@ -1,6 +1,7 @@ """ Main chat service that orchestrates all chat functionality """ +import logging import uuid from collections.abc import AsyncGenerator from typing import List @@ -23,6 +24,8 @@ from app.services.rag_service import get_question_embedding, retrieve_relevant_context from app.services.openai_service import stream_cached_response, generate_openai_response +logger = logging.getLogger(__name__) + async def handle_continuation( course_id: uuid.UUID, @@ -37,6 +40,7 @@ async def handle_continuation( last_system_msg = get_last_system_message(course_id, session) if not last_system_msg or not last_system_msg.message: + logger.warning("[CHAT] No previous system message found to continue | course_id=%s", str(course_id)) yield "Error: No previous response found to continue" return @@ -124,6 +128,7 @@ async def handle_regular_question( context_str = await retrieve_relevant_context(question_embedding, course_id) if not context_str: + logger.warning("[CHAT] No relevant context found | course_id=%s", str(course_id)) yield "Error: No relevant content found for this question" return @@ -165,4 +170,4 @@ async def handle_regular_question( yield chunk # Save system message - save_system_message(full_response, course_id, session) \ No newline at end of file + save_system_message(full_response, course_id, session) diff --git a/backend/app/services/podcast_service.py b/backend/app/services/podcast_service.py new file mode 100644 index 0000000..8fbfe2b --- /dev/null +++ b/backend/app/services/podcast_service.py @@ -0,0 +1,333 @@ +""" +Podcast generation service: builds a conversational transcript from course materials +and generates audio using OpenAI TTS. Stores audio locally or in S3 based on config. +""" +import json +import logging +import os +import tempfile +import uuid + +import boto3 +from fastapi import HTTPException + +from app.api.deps import SessionDep +from app.api.routes.documents import async_openai_client +from app.core.config import settings +from app.models.podcast import Podcast +from app.prompts.podcast import dialogue_prompt, presentation_prompt, get_tts_instructions +from app.services.rag_service import get_question_embedding, retrieve_relevant_context + +logger = logging.getLogger(__name__) + +ALLOWED_VOICES = { + "alloy", "echo", "fable", "onyx", "nova", "shimmer", + "coral", "verse", "ballad", "ash", "sage", "marin", "cedar", +} + +def _sanitize_voice(voice: str, fallback: str) -> str: + v = (voice or "").strip().lower() + if v in ALLOWED_VOICES: + return v + fb = (fallback or settings.PODCAST_TEACHER_VOICE).strip().lower() + logger.warning("[PODCAST] Unsupported voice '%s'. Using fallback '%s'", voice, fb) + return fb if fb in ALLOWED_VOICES else "alloy" + + +async def generate_transcript_from_context(context: str, title: str) -> str: + system = ( + "You create engaging, accurate educational dialog scripts suitable for audio narration." + ) + user = dialogue_prompt(context, title) + resp = await async_openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.6, + max_tokens=1200, + ) + content = resp.choices[0].message.content or "" + return content.strip() + + +async def generate_presentation_transcript(context: str, title: str) -> str: + system = ( + "You create concise, accurate educational presentations suitable for audio narration." + ) + user = presentation_prompt(context, title) + resp = await async_openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.6, + max_tokens=1200, + ) + content = resp.choices[0].message.content or "" + return content.strip() + + +async def generate_dialog_turns_from_context(context: str, title: str) -> list[dict]: + """Ask LLM to produce structured dialog JSON: {"turns": [{"role":"teacher"|"student","text":"..."}, ...]}""" + system = ( + "You create concise, accurate educational dialogues grounded in provided context." + " Return ONLY strict JSON with the following structure and nothing else:" + " {\n \"turns\": [ { \"role\": \"teacher\", \"text\": \"...\" }, { \"role\": \"student\", \"text\": \"...\" } ]\n }" + ) + user = ( + "Create a 2-4 minute dialogue alternating roles 'teacher' and 'student'.\n" + "Rules:\n- No extra commentary, return pure JSON.\n- Alternate roles.\n- 4 to 10 total turns.\n- Stay within the context.\n\n" + f"Title: {title}\nContext:\n{context}" + ) + resp = await async_openai_client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.6, + max_tokens=1200, + ) + content = resp.choices[0].message.content or "" + try: + data = json.loads(content) + turns = data.get("turns", []) + # Strict: accept only teacher/student roles + cleaned = [] + for t in turns: + role = (t.get("role") or "").lower() + text = (t.get("text") or "").strip() + if role in ("teacher", "student") and text: + cleaned.append({"role": role, "text": text}) + if cleaned: + return cleaned + # If JSON parsed but roles invalid/empty, fall back to text parsing + logger.warning("[PODCAST] Dialog JSON contained no teacher/student roles; falling back to text parsing") + txt = await generate_transcript_from_context(context, title) + segs = _parse_dialog_segments(txt) + return [{"role": r, "text": t} for r, t in segs] + except Exception: + logger.warning("[PODCAST] JSON dialog parse failed; falling back to text parsing") + # fallback to text-based transcript and parsing + txt = await generate_transcript_from_context(context, title) + segs = _parse_dialog_segments(txt) + return [{"role": r, "text": t} for r, t in segs] + + +async def tts_generate_audio(transcript: str) -> bytes: + """Generate TTS audio bytes from transcript using OpenAI TTS.""" + try: + # Prefer streaming API to avoid response shape issues across SDK versions + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp_path = tmp.name + try: + safe_voice = _sanitize_voice(settings.PODCAST_TEACHER_VOICE, settings.PODCAST_TEACHER_VOICE) + async with async_openai_client.audio.speech.with_streaming_response.create( + model="gpt-4o-mini-tts", + voice=safe_voice, + input=transcript, + ) as response: + await response.stream_to_file(tmp_path) + with open(tmp_path, "rb") as f: + return f.read() + finally: + try: + os.remove(tmp_path) + except Exception: + pass + except Exception as e: + logger.exception("[PODCAST] TTS generation failed: %s", e) + raise HTTPException(status_code=500, detail=f"TTS generation failed: {e}") + + +def ensure_local_dir(path: str) -> None: + os.makedirs(path, exist_ok=True) + + +def store_audio_local(audio_bytes: bytes, filename: str) -> str: + ensure_local_dir(settings.PODCAST_LOCAL_DIR) + dest_path = os.path.join(settings.PODCAST_LOCAL_DIR, filename) + with open(dest_path, "wb") as f: + f.write(audio_bytes) + return dest_path + + +def store_audio_s3(audio_bytes: bytes, key: str) -> str: + if not settings.S3_BUCKET_NAME or not settings.AWS_REGION: + raise HTTPException(status_code=500, detail="S3 configuration is missing") + 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, + ) + bucket = settings.S3_BUCKET_NAME + s3.put_object(Bucket=bucket, Key=key, Body=audio_bytes, ContentType="audio/mpeg") + # Return an s3:// URL; clients should hit our audio endpoint or a presigned URL + return f"s3://{bucket}/{key}" + + +async def generate_podcast_for_course( + session: SessionDep, + course_id: uuid.UUID, + title: str, + teacher_voice: str, + student_voice: str, + narrator_voice: str, + mode: str = "dialogue", + topics: str | None = None, + document_ids: list[uuid.UUID] | None = None, +) -> Podcast: + # Retrieve broad context for the course by embedding a generic request and pulling top content + focus = f" Provide an overview for course {course_id}." + (f" Focus on: {topics}." if topics else "") + question_embedding = await get_question_embedding(focus) + context = await retrieve_relevant_context(question_embedding, course_id, top_k=8, document_ids=document_ids) + if not context: + raise HTTPException(status_code=400, detail="No relevant content available for podcast") + + if mode == "presentation": + # Single narrator monologue (no role tags) + monologue = await generate_presentation_transcript(context, f"{title} (Presentation Mode)") + audio_bytes = await _tts_to_bytes(monologue, narrator_voice, role="narrator", mode="presentation") + transcript = monologue + else: + # Build alternating-speaker audio with different voices using structured turns + turns = await generate_dialog_turns_from_context(context, title) + # Add a blank line between turns for better readability + transcript = "\n\n".join([ + (f"Teacher: {t['text']}" if t['role'] == "teacher" else f"Student: {t['text']}") + for t in turns + ]) + if turns: + temp_files: list[str] = [] + try: + for t in turns: + voice = teacher_voice if t["role"] == "teacher" else student_voice + path = await _tts_to_temp_file(t["text"], voice, role=t["role"], mode="dialogue") + temp_files.append(path) + audio_bytes = _concat_files(temp_files) + finally: + for p in temp_files: + try: + os.remove(p) + except Exception: + pass + else: + audio_bytes = await tts_generate_audio(transcript) + pod_id = uuid.uuid4() + + storage = settings.PODCAST_STORAGE + if storage == "s3": + key = f"{settings.S3_PREFIX}{pod_id}.mp3" + audio_path = store_audio_s3(audio_bytes, key) + else: + filename = f"{pod_id}.mp3" + audio_path = store_audio_local(audio_bytes, filename) + storage = "local" + + podcast = Podcast( + id=pod_id, + course_id=course_id, + title=title, + transcript=transcript, + audio_path=audio_path, + storage_backend=storage, + ) + session.add(podcast) + session.commit() + session.refresh(podcast) + return podcast + + +def _parse_dialog_segments(transcript: str) -> list[tuple[str, str]]: + segments: list[tuple[str, str]] = [] + current_speaker: str | None = None + current_text: list[str] = [] + for raw in transcript.splitlines(): + line = raw.strip() + if not line: + continue + lower = line.lower() + spk = None + if lower.startswith("teacher:"): + spk = "teacher" + content = line.split(":", 1)[1].strip() + elif lower.startswith("student:"): + spk = "student" + content = line.split(":", 1)[1].strip() + else: + content = line + if spk is not None: + if current_speaker is not None and current_text: + segments.append((current_speaker, " ".join(current_text).strip())) + current_speaker = spk + current_text = [content] + else: + if current_speaker is None: + current_speaker = "teacher" + current_text.append(content) + if current_speaker is not None and current_text: + segments.append((current_speaker, " ".join(current_text).strip())) + return [(s, t) for s, t in segments if t] + + +async def _tts_to_temp_file(text: str, voice: str, role: str = "teacher", mode: str = "dialogue") -> str: + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp_path = tmp.name + try: + safe_voice = _sanitize_voice(voice, settings.PODCAST_TEACHER_VOICE) + instructions = get_tts_instructions(role, mode) + async with async_openai_client.audio.speech.with_streaming_response.create( + model="gpt-4o-mini-tts", + voice=safe_voice, + input=text, + instructions=instructions, + ) as response: + await response.stream_to_file(tmp_path) + return tmp_path + except Exception as e: + logger.exception("[PODCAST] TTS segment failed (voice=%s): %s", voice, e) + try: + os.remove(tmp_path) + except Exception: + pass + raise HTTPException(status_code=500, detail=f"TTS segment failed: {e}") + + +async def _tts_to_bytes(text: str, voice: str, role: str = "teacher", mode: str = "dialogue") -> bytes: + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp: + tmp_path = tmp.name + try: + safe_voice = _sanitize_voice(voice, settings.PODCAST_TEACHER_VOICE) + instructions = get_tts_instructions(role, mode) + async with async_openai_client.audio.speech.with_streaming_response.create( + model="gpt-4o-mini-tts", + voice=safe_voice, + input=text, + instructions=instructions, + ) as response: + await response.stream_to_file(tmp_path) + with open(tmp_path, 'rb') as f: + return f.read() + finally: + try: + os.remove(tmp_path) + except Exception: + pass + + +def _concat_files(paths: list[str]) -> bytes: + if not paths: + return b"" + # Naive byte concatenation works for many MP3 players and is acceptable for MVP + chunks: list[bytes] = [] + for p in paths: + try: + with open(p, 'rb') as f: + chunks.append(f.read()) + except Exception as e: + logger.exception("[PODCAST] Failed reading segment %s: %s", p, e) + return b"".join(chunks) diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index 29f063f..bc7856d 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -1,6 +1,7 @@ """ RAG (Retrieval-Augmented Generation) service for document context retrieval """ +import logging import uuid from typing import List, Optional @@ -11,6 +12,7 @@ pc, ) +logger = logging.getLogger(__name__) async def get_question_embedding(question: str) -> List[float]: """Generate embedding for a question""" @@ -18,13 +20,15 @@ async def get_question_embedding(question: str) -> List[float]: input=[question], model=EMBEDDING_MODEL, ) - return embed_resp.data[0].embedding + embedding = embed_resp.data[0].embedding + return embedding async def retrieve_relevant_context( - question_embedding: List[float], + question_embedding: List[float], course_id: uuid.UUID, - top_k: int = 5 + top_k: int = 5, + document_ids: Optional[List[uuid.UUID]] = None, ) -> Optional[str]: """ Retrieve relevant context from course documents using vector similarity @@ -38,26 +42,79 @@ async def retrieve_relevant_context( Concatenated context string or None if no relevant content found """ try: + # Ensure index exists and log setup + has_idx = pc.has_index(index_name) + if not has_idx: + logger.warning("[RAG] Index %s does not exist before query", index_name) + # Query Pinecone for relevant chunks index = pc.Index(index_name) + pine_filter: dict = {"course_id": {"$eq": str(course_id)}} + if document_ids: + pine_filter["document_id"] = {"$in": [str(d) for d in document_ids]} query_result = index.query( vector=question_embedding, - filter={"course_id": str(course_id)}, + filter=pine_filter, top_k=top_k, include_metadata=True, ) - - contexts = [ - match["metadata"]["text"] - for match in query_result["matches"] - if "metadata" in match and "text" in match["metadata"] - ] + + # Pinecone may return either an object or dict-like structure + matches = query_result.get("matches", []) if hasattr(query_result, "get") else getattr(query_result, "matches", []) + + contexts: List[str] = [] + if matches: + for i, m in enumerate(matches[:min(5, len(matches))]): + # tolerate different shapes (dict or object) + score = m.get("score") if isinstance(m, dict) else getattr(m, "score", None) + metadata = m.get("metadata") if isinstance(m, dict) else getattr(m, "metadata", {}) + text = metadata.get("text") if isinstance(metadata, dict) else None + cid = metadata.get("course_id") if isinstance(metadata, dict) else None + did = metadata.get("document_id") if isinstance(metadata, dict) else None + contexts.append(text) if text else None if not contexts: + # Additional debug: try an unfiltered query to inspect stored metadata + try: + probe = index.query( + vector=question_embedding, + top_k=1, + include_metadata=True, + ) + probe_matches = probe.get("matches", []) if hasattr(probe, "get") else getattr(probe, "matches", []) + if probe_matches: + pm = probe_matches[0] + pmeta = pm.get("metadata") if isinstance(pm, dict) else getattr(pm, "metadata", {}) + # Fallback: if vectors exist but filter produced none, try manual filtering in code + # to guard against filter-shape mismatches across SDK versions + fallback = index.query( + vector=question_embedding, + top_k=max(10, top_k), + include_metadata=True, + ) + fb_matches = fallback.get("matches", []) if hasattr(fallback, "get") else getattr(fallback, "matches", []) + fb_contexts: List[str] = [] + for m in fb_matches: + md = m.get("metadata") if isinstance(m, dict) else getattr(m, "metadata", {}) + text = md.get("text") if isinstance(md, dict) else None + cid = md.get("course_id") if isinstance(md, dict) else None + if text and str(cid) == str(course_id): + fb_contexts.append(text) + if fb_contexts: + merged_fb = "\n\n".join(fb_contexts[:top_k]) + return merged_fb + except Exception as pe: + logger.exception("[RAG] Probe query failed: %s", pe) + logger.warning( + "[RAG] No contexts found from Pinecone | index=%s | course_id=%s", + index_name, + str(course_id), + ) return None - return "\n\n".join(contexts) - + merged = "\n\n".join(contexts) + return merged + except Exception as e: - print(f"Error retrieving context: {e}") - return None \ No newline at end of file + logger.exception("[RAG] Error retrieving context from Pinecone: %s", e) + return None diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5507cbb..4ac0cc3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "aiofiles>=24.1.0", "tiktoken>=0.5.0", "numpy>=1.24.0", + "boto3>=1.34.0", ] [tool.uv] diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh index 81094f9..480789c 100644 --- a/backend/scripts/prestart.sh +++ b/backend/scripts/prestart.sh @@ -6,8 +6,7 @@ set -x # Let the DB start python app/backend_pre_start.py -# Run migrations alembic upgrade head # Create initial data in DB -python app/initial_data.py \ No newline at end of file +python app/initial_data.py diff --git a/backend/uv.lock b/backend/uv.lock index fbe7f4c..36fb2e9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -122,6 +122,7 @@ dependencies = [ { name = "aiofiles" }, { name = "alembic" }, { name = "bcrypt" }, + { name = "boto3" }, { name = "email-validator" }, { name = "emails" }, { name = "fastapi", extra = ["standard"] }, @@ -130,6 +131,7 @@ dependencies = [ { name = "langchain" }, { name = "langchain-openai" }, { name = "langchain-pinecone" }, + { name = "numpy" }, { name = "openai" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pinecone" }, @@ -142,6 +144,7 @@ dependencies = [ { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, { name = "tenacity" }, + { name = "tiktoken" }, ] [package.dev-dependencies] @@ -159,6 +162,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "alembic", specifier = ">=1.12.1,<2.0.0" }, { name = "bcrypt", specifier = "==4.3.0" }, + { name = "boto3", specifier = ">=1.34.0" }, { name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" }, { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1,<1.0.0" }, @@ -167,6 +171,7 @@ requires-dist = [ { name = "langchain", specifier = ">=0.3.27" }, { name = "langchain-openai", specifier = ">=0.3.33" }, { name = "langchain-pinecone", specifier = ">=0.2.12" }, + { name = "numpy", specifier = ">=1.24.0" }, { name = "openai", specifier = ">=1.107.3" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4,<2.0.0" }, { name = "pinecone", specifier = ">=7.3.0" }, @@ -179,6 +184,7 @@ requires-dist = [ { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.20.0,<3.0.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, { name = "tenacity", specifier = ">=8.2.3,<9.0.0" }, + { name = "tiktoken", specifier = ">=0.5.0" }, ] [package.metadata.requires-dev] @@ -250,6 +256,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "boto3" +version = "1.40.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/22/97605e64b8661a13f1dd9412c7989b3d78673bc79d91ca61d8237e90b503/boto3-1.40.45.tar.gz", hash = "sha256:e8d794dc1f01729d93dc188c90cf63cd0d32df8818a82ac46e641f6ffcea615e", size = 111561, upload-time = "2025-10-03T19:32:12.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/db/7d3c27f530c2b354d546ad7fb94505be8b78a5ecabe34c6a1f9a9d6be03e/boto3-1.40.45-py3-none-any.whl", hash = "sha256:5b145752d20f29908e3cb8c823bee31c77e6bcf18787e570f36bbc545cc779ed", size = 139345, upload-time = "2025-10-03T19:32:11.145Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/19/6c85d5523dd05e060d182cd0e7ce82df60ab738d18b1c8ee2202e4ca02b9/botocore-1.40.45.tar.gz", hash = "sha256:cf8b743527a2a7e108702d24d2f617e93c6dc7ae5eb09aadbe866f15481059df", size = 14395172, upload-time = "2025-10-03T19:32:03.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/06/df47e2ecb74bd184c9d056666afd3db011a649eaca663337835a6dd5aee6/botocore-1.40.45-py3-none-any.whl", hash = "sha256:9abf473d8372ade8442c0d4634a9decb89c854d7862ffd5500574eb63ab8f240", size = 14063670, upload-time = "2025-10-03T19:31:58.999Z" }, +] + [[package]] name = "cachetools" version = "6.2.0" @@ -734,6 +768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/22/7ab7b4ec3a1c1f03aef376af11d23b05abcca3fb31fbca1e7557053b1ba2/jiter-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e2bbf24f16ba5ad4441a9845e40e4ea0cb9eed00e76ba94050664ef53ef4406", size = 347102, upload-time = "2025-09-15T09:20:20.16Z" }, ] +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1669,6 +1712,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, ] +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + [[package]] name = "sentry-sdk" version = "2.37.1" diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 871d206..036ad1a 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -97,6 +97,7 @@ services: environment: NODE_ENV: development NEXT_PUBLIC_BACKEND_BASE_URL: http://localhost:8000 + NEXT_INTERNAL_BACKEND_BASE_URL: http://backend:8000 depends_on: - backend volumes: diff --git a/frontend/openapi.json b/frontend/openapi.json index c477d3e..a77551d 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "Athena", "version": "0.1.0"}, "paths": {"/api/v1/login/access-token": {"post": {"tags": ["login"], "summary": "Login Access Token", "description": "OAuth2 compatible token login, get an access token for future requests", "operationId": "login-login_access_token", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_login-login_access_token"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Token"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/login/test-token": {"post": {"tags": ["login"], "summary": "Test Token", "description": "Test access token", "operationId": "login-test_token", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/password-recovery/{email}": {"post": {"tags": ["login"], "summary": "Recover Password", "description": "Password Recovery", "operationId": "login-recover_password", "parameters": [{"name": "email", "in": "path", "required": true, "schema": {"type": "string", "title": "Email"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/reset-password/": {"post": {"tags": ["login"], "summary": "Reset Password", "description": "Reset password", "operationId": "login-reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPassword"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/password-recovery-html-content/{email}": {"post": {"tags": ["login"], "summary": "Recover Password Html Content", "description": "HTML Content for Password Recovery", "operationId": "login-recover_password_html_content", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "email", "in": "path", "required": true, "schema": {"type": "string", "title": "Email"}}], "responses": {"200": {"description": "Successful Response", "content": {"text/html": {"schema": {"type": "string"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/": {"get": {"tags": ["users"], "summary": "Read Users", "description": "Retrieve users.", "operationId": "users-read_users", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UsersPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["users"], "summary": "Create User", "description": "Create new user.", "operationId": "users-create_user", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/me": {"get": {"tags": ["users"], "summary": "Read User Me", "description": "Get current user.", "operationId": "users-read_user_me", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "delete": {"tags": ["users"], "summary": "Delete User Me", "description": "Delete own user.", "operationId": "users-delete_user_me", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "patch": {"tags": ["users"], "summary": "Update User Me", "description": "Update own user.", "operationId": "users-update_user_me", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdateMe"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/users/me/password": {"patch": {"tags": ["users"], "summary": "Update Password Me", "description": "Update own password.", "operationId": "users-update_password_me", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdatePassword"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/users/signup": {"post": {"tags": ["users"], "summary": "Register User", "description": "Create new user without the need to be logged in.", "operationId": "users-register_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRegister"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}": {"get": {"tags": ["users"], "summary": "Read User By Id", "description": "Get a specific user by id.", "operationId": "users-read_user_by_id", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Update User", "description": "Update a user.", "operationId": "users-update_user", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete User", "description": "Delete a user.", "operationId": "users-delete_user", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/utils/test-email/": {"post": {"tags": ["utils"], "summary": "Test Email", "description": "Test emails.", "operationId": "utils-test_email", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "email_to", "in": "query", "required": true, "schema": {"type": "string", "format": "email", "title": "Email To"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/utils/health-check/": {"get": {"tags": ["utils"], "summary": "Health Check", "operationId": "utils-health_check", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "boolean", "title": "Response Utils-Health Check"}}}}}}}, "/api/v1/items/": {"get": {"tags": ["items"], "summary": "Read Items", "description": "Retrieve items.", "operationId": "items-read_items", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemsPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["items"], "summary": "Create Item", "description": "Create new item.", "operationId": "items-create_item", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/items/{id}": {"get": {"tags": ["items"], "summary": "Read Item", "description": "Get item by ID.", "operationId": "items-read_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["items"], "summary": "Update Item", "description": "Update an item.", "operationId": "items-update_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["items"], "summary": "Delete Item", "description": "Delete an item.", "operationId": "items-delete_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/": {"get": {"tags": ["courses"], "summary": "Read Courses", "description": "Retrieve courses with pagination and user-based security filtering.", "operationId": "courses-read_courses", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CoursesPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["courses"], "summary": "Create Course", "description": "Create new course.", "operationId": "courses-create_course", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Course"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}": {"get": {"tags": ["courses"], "summary": "Read Course", "description": "Get course by ID, including its documents.", "operationId": "courses-read_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseWithDocuments"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["courses"], "summary": "Update Course", "description": "Update an course.", "operationId": "courses-update_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CoursePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["courses"], "summary": "Delete Course", "description": "Delete an course.", "operationId": "courses-delete_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/documents": {"get": {"tags": ["courses"], "summary": "List Documents", "description": "List documents for a specific course.", "operationId": "courses-list_documents", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}, {"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"type": "object", "additionalProperties": true}, "title": "Response Courses-List Documents"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/quizzes": {"get": {"tags": ["courses"], "summary": "List Quizzes", "description": "Fetches the first 10 Quiz objects related to a specific course,\nensuring the course is owned by the current user.", "operationId": "courses-list_quizzes", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "query", "required": true, "schema": {"type": "string", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 50, "exclusiveMinimum": 0, "default": 5, "title": "Limit"}}, {"name": "offset", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 0, "default": 0, "title": "Offset"}}, {"name": "order_by", "in": "query", "required": false, "schema": {"enum": ["created_at", "difficulty_level", "quiz_text"], "type": "string", "default": "created_at", "title": "Order By"}}, {"name": "difficulty", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/DifficultyLevel", "default": "easy"}}, {"name": "order_direction", "in": "query", "required": false, "schema": {"enum": ["asc", "desc"], "type": "string", "default": "desc", "title": "Order Direction"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizzesPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/attempts": {"get": {"tags": ["courses"], "summary": "Get Attempts Sessions", "description": "Fetch all incomplete quiz sessions for a given course and user.", "operationId": "courses-get_attempts_sessions", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSessionsList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{course_id}/quiz/start": {"post": {"tags": ["courses"], "summary": "Start New Quiz Session", "description": "Creates a new, immutable QuizSession, selects the initial set of questions,\nand returns the session details and the first batch of questions.", "operationId": "courses-start_new_quiz_session", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 50, "exclusiveMinimum": 0, "default": 5, "title": "Limit"}}, {"name": "offset", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 0, "default": 0, "title": "Offset"}}, {"name": "order_by", "in": "query", "required": false, "schema": {"enum": ["created_at", "difficulty_level", "quiz_text"], "type": "string", "default": "created_at", "title": "Order By"}}, {"name": "difficulty", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/DifficultyLevel", "default": "easy"}}, {"name": "order_direction", "in": "query", "required": false, "schema": {"enum": ["asc", "desc"], "type": "string", "default": "desc", "title": "Order Direction"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "prefixItems": [{"$ref": "#/components/schemas/QuizSessionPublic"}, {"$ref": "#/components/schemas/QuizzesPublic"}], "minItems": 2, "maxItems": 2, "title": "Response Courses-Start New Quiz Session"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{course_id}/stats": {"get": {"tags": ["courses"], "summary": "Get Quiz Stats", "description": "Fetches course statistics: overall average, total attempts, and the full\ndetails of the single best-scoring quiz session.", "operationId": "courses-get_quiz_stats", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizStats"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/flashcards": {"get": {"tags": ["courses"], "summary": "Generate Flashcards By Course Id", "description": "Generate flashcards for a specific course by retrieving relevant chunks and\nusing an LLM to structure the content into Q&A items.", "operationId": "courses-generate_flashcards_by_course_id", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/QAItem"}, "title": "Response Courses-Generate Flashcards By Course Id"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/chat/{course_id}/stream": {"post": {"tags": ["chat"], "summary": "Stream chat responses", "description": "Stream AI-generated responses based on course materials", "operationId": "chat-stream_chat", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatMessage"}}}}, "responses": {"200": {"description": "Successful streaming response"}, "404": {"description": "Course not found"}, "401": {"description": "Not authenticated"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/chat/{course_id}/history": {"get": {"tags": ["chat"], "summary": "Get chat history", "description": "Retrieve chat history for a course", "operationId": "chat-get_chat_history", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 50, "title": "Limit"}}], "responses": {"200": {"description": "List of chat messages", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ChatPublic"}, "title": "Response 200 Chat-Get Chat History"}}}}, "404": {"description": "Course not found"}, "401": {"description": "Not authenticated"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/documents/process": {"post": {"tags": ["documents"], "summary": "Process Multiple Documents", "description": "Accept multiple PDF uploads, save to temp files, and queue a background task for each.", "operationId": "documents-process_multiple_documents", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_documents-process_multiple_documents"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/documents/{id}": {"get": {"tags": ["documents"], "summary": "Read Document", "description": "Get a document by its ID, ensuring the user has permissions.", "operationId": "documents-read_document", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Document"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["documents"], "summary": "Delete Document", "description": "Delete a document by its ID, ensuring the user has permissions.", "operationId": "documents-delete_document", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Documents-Delete Document"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/quiz-sessions/{id}": {"get": {"tags": ["quiz-sessions"], "summary": "Get Quiz Session Optimized", "description": "Retrieves a QuizSession, eagerly loading attempts ONLY if completed,\nor just the session and quizzes if in progress.", "operationId": "quiz-sessions-get_quiz_session_optimized", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSessionPublicWithResults"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/quiz-sessions/{id}/score": {"post": {"tags": ["quiz-sessions"], "summary": "Submit And Score Quiz Batch", "description": "API endpoint to receive a batch of user answers and score a specific\nQuizSession identified by the session_id.", "operationId": "quiz-sessions-submit_and_score_quiz_batch", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSubmissionBatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizScoreSummary"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/private/users/": {"post": {"tags": ["private"], "summary": "Create User", "description": "Create a new user.", "operationId": "private-create_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PrivateUserCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"Body_documents-process_multiple_documents": {"properties": {"files": {"items": {"type": "string", "format": "binary"}, "type": "array", "title": "Files"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}}, "type": "object", "required": ["files", "course_id"], "title": "Body_documents-process_multiple_documents"}, "Body_login-login_access_token": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "^password$"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "format": "password", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "format": "password", "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_login-login_access_token"}, "ChatMessage": {"properties": {"message": {"type": "string", "title": "Message"}, "continue_response": {"type": "boolean", "title": "Continue Response", "default": false}}, "type": "object", "required": ["message"], "title": "ChatMessage", "example": {"continue_response": false, "message": "What is the main topic of the course?"}}, "ChatPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Score Percentage"}, "is_completed": {"type": "boolean", "title": "Is Completed"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}, "message": {"type": "string", "title": "Message"}, "is_system": {"type": "boolean", "title": "Is System"}}, "type": "object", "required": ["id", "course_id", "total_submitted", "total_correct", "is_completed", "created_at", "updated_at", "message", "is_system"], "title": "ChatPublic"}, "Course": {"properties": {"name": {"type": "string", "maxLength": 255, "minLength": 3, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["name", "owner_id"], "title": "Course"}, "CourseCreate": {"properties": {"name": {"type": "string", "maxLength": 255, "minLength": 3, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}}, "type": "object", "required": ["name"], "title": "CourseCreate"}, "CoursePublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "documents": {"items": {"$ref": "#/components/schemas/DocumentPublic"}, "type": "array", "title": "Documents"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "owner_id", "name", "documents", "created_at", "updated_at"], "title": "CoursePublic"}, "CourseUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 255, "minLength": 3}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}}, "type": "object", "title": "CourseUpdate"}, "CourseWithDocuments": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "documents": {"items": {"$ref": "#/components/schemas/DocumentPublic"}, "type": "array", "title": "Documents", "default": []}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "owner_id", "name", "created_at", "updated_at"], "title": "CourseWithDocuments"}, "CoursesPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/CoursePublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "CoursesPublic"}, "DifficultyLevel": {"type": "string", "enum": ["easy", "medium", "hard", "expert", "all"], "title": "DifficultyLevel"}, "Document": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "chunk_count": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Chunk Count"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "embedding_namespace": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Embedding Namespace"}, "filename": {"type": "string", "title": "Filename"}, "status": {"$ref": "#/components/schemas/DocumentStatus", "default": "pending"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["title", "course_id", "filename"], "title": "Document"}, "DocumentPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "status": {"$ref": "#/components/schemas/DocumentStatus"}}, "type": "object", "required": ["id", "course_id", "updated_at", "created_at", "status"], "title": "DocumentPublic"}, "DocumentStatus": {"type": "string", "enum": ["pending", "processing", "completed", "failed"], "title": "DocumentStatus"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "Item": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}}, "type": "object", "required": ["title", "owner_id"], "title": "Item"}, "ItemCreate": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}}, "type": "object", "required": ["title"], "title": "ItemCreate"}, "ItemPublic": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}}, "type": "object", "required": ["title", "id", "owner_id"], "title": "ItemPublic"}, "ItemUpdate": {"properties": {"title": {"anyOf": [{"type": "string", "maxLength": 255, "minLength": 1}, {"type": "null"}], "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}}, "type": "object", "title": "ItemUpdate"}, "ItemsPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/Item"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "ItemsPublic"}, "Message": {"properties": {"message": {"type": "string", "title": "Message"}}, "type": "object", "required": ["message"], "title": "Message"}, "NewPassword": {"properties": {"token": {"type": "string", "title": "Token"}, "new_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["token", "new_password"], "title": "NewPassword"}, "PrivateUserCreate": {"properties": {"email": {"type": "string", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "full_name": {"type": "string", "title": "Full Name"}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}}, "type": "object", "required": ["email", "password", "full_name"], "title": "PrivateUserCreate"}, "QAItem": {"properties": {"question": {"type": "string", "title": "Question"}, "answer": {"type": "string", "title": "Answer"}}, "type": "object", "required": ["question", "answer"], "title": "QAItem"}, "QuizAttemptPublic": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "selected_answer_text": {"type": "string", "title": "Selected Answer Text"}, "is_correct": {"type": "boolean", "title": "Is Correct"}, "correct_answer_text": {"type": "string", "title": "Correct Answer Text"}, "time_spent_seconds": {"type": "number", "title": "Time Spent Seconds"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}}, "type": "object", "required": ["quiz_id", "selected_answer_text", "is_correct", "correct_answer_text", "time_spent_seconds", "created_at"], "title": "QuizAttemptPublic", "description": "Public schema for a single QuizAttempt record.\nUsed to return the full history/results when a session is complete."}, "QuizChoice": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "text": {"type": "string", "title": "Text"}}, "type": "object", "required": ["id", "text"], "title": "QuizChoice"}, "QuizPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "quiz_text": {"type": "string", "title": "Quiz Text"}, "choices": {"items": {"$ref": "#/components/schemas/QuizChoice"}, "type": "array", "title": "Choices"}}, "type": "object", "required": ["id", "quiz_text", "choices"], "title": "QuizPublic"}, "QuizScoreSummary": {"properties": {"total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"type": "number", "title": "Score Percentage"}, "results": {"items": {"$ref": "#/components/schemas/SingleQuizScore"}, "type": "array", "title": "Results"}}, "type": "object", "required": ["total_submitted", "total_correct", "score_percentage", "results"], "title": "QuizScoreSummary", "description": "The overall score for the batch of submissions."}, "QuizSessionPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Score Percentage"}, "is_completed": {"type": "boolean", "title": "Is Completed"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "course_id", "total_submitted", "total_correct", "is_completed", "created_at", "updated_at"], "title": "QuizSessionPublic", "description": "Public schema for a QuizSession."}, "QuizSessionPublicWithResults": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Score Percentage"}, "is_completed": {"type": "boolean", "title": "Is Completed"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}, "quizzes": {"items": {"$ref": "#/components/schemas/QuizPublic"}, "type": "array", "title": "Quizzes"}, "results": {"items": {"$ref": "#/components/schemas/QuizAttemptPublic"}, "type": "array", "title": "Results"}}, "type": "object", "required": ["id", "course_id", "total_submitted", "total_correct", "is_completed", "created_at", "updated_at"], "title": "QuizSessionPublicWithResults", "description": "Expanded schema that includes quiz attempts (results)\nwhen the session is marked as completed."}, "QuizSessionsList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QuizSessionPublic"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "QuizSessionsList"}, "QuizStats": {"properties": {"best_total_submitted": {"type": "integer", "title": "Best Total Submitted"}, "best_total_correct": {"type": "integer", "title": "Best Total Correct"}, "best_score_percentage": {"type": "number", "title": "Best Score Percentage"}, "average_score": {"type": "number", "title": "Average Score"}, "attempts": {"type": "integer", "title": "Attempts"}}, "type": "object", "required": ["best_total_submitted", "best_total_correct", "best_score_percentage", "average_score", "attempts"], "title": "QuizStats"}, "QuizSubmissionBatch": {"properties": {"submissions": {"items": {"$ref": "#/components/schemas/SingleQuizSubmission"}, "type": "array", "title": "Submissions"}, "total_time_seconds": {"type": "number", "title": "Total Time Seconds", "default": 0.0}}, "type": "object", "required": ["submissions"], "title": "QuizSubmissionBatch", "description": "Container for multiple quiz submissions."}, "QuizzesPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QuizPublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "QuizzesPublic"}, "SingleQuizScore": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "is_correct": {"type": "boolean", "title": "Is Correct"}, "correct_answer_text": {"type": "string", "title": "Correct Answer Text"}, "feedback": {"type": "string", "title": "Feedback"}}, "type": "object", "required": ["quiz_id", "is_correct", "correct_answer_text", "feedback"], "title": "SingleQuizScore", "description": "The result for a single question."}, "SingleQuizSubmission": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "selected_answer_text": {"type": "string", "title": "Selected Answer Text"}}, "type": "object", "required": ["quiz_id", "selected_answer_text"], "title": "SingleQuizSubmission", "description": "The user's answer for one question."}, "Token": {"properties": {"access_token": {"type": "string", "title": "Access Token"}, "token_type": {"type": "string", "title": "Token Type", "default": "bearer"}}, "type": "object", "required": ["access_token"], "title": "Token"}, "UpdatePassword": {"properties": {"current_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Current Password"}, "new_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["current_password", "new_password"], "title": "UpdatePassword"}, "UserCreate": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserPublic": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "id": {"type": "string", "format": "uuid", "title": "Id"}}, "type": "object", "required": ["email", "id"], "title": "UserPublic"}, "UserRegister": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Password"}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}}, "type": "object", "required": ["email", "password"], "title": "UserRegister"}, "UserUpdate": {"properties": {"email": {"anyOf": [{"type": "string", "maxLength": 255, "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "password": {"anyOf": [{"type": "string", "maxLength": 40, "minLength": 8}, {"type": "null"}], "title": "Password"}}, "type": "object", "title": "UserUpdate"}, "UserUpdateMe": {"properties": {"full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "email": {"anyOf": [{"type": "string", "maxLength": 255, "format": "email"}, {"type": "null"}], "title": "Email"}}, "type": "object", "title": "UserUpdateMe"}, "UsersPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/UserPublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "UsersPublic"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"OAuth2PasswordBearer": {"type": "oauth2", "flows": {"password": {"scopes": {}, "tokenUrl": "/api/v1/login/access-token"}}}}}} +{"openapi": "3.1.0", "info": {"title": "Study Companion Project", "version": "0.1.0"}, "paths": {"/api/v1/login/access-token": {"post": {"tags": ["login"], "summary": "Login Access Token", "description": "OAuth2 compatible token login, get an access token for future requests", "operationId": "login-login_access_token", "requestBody": {"content": {"application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Body_login-login_access_token"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Token"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/login/test-token": {"post": {"tags": ["login"], "summary": "Test Token", "description": "Test access token", "operationId": "login-test_token", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/password-recovery/{email}": {"post": {"tags": ["login"], "summary": "Recover Password", "description": "Password Recovery", "operationId": "login-recover_password", "parameters": [{"name": "email", "in": "path", "required": true, "schema": {"type": "string", "title": "Email"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/reset-password/": {"post": {"tags": ["login"], "summary": "Reset Password", "description": "Reset password", "operationId": "login-reset_password", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPassword"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/password-recovery-html-content/{email}": {"post": {"tags": ["login"], "summary": "Recover Password Html Content", "description": "HTML Content for Password Recovery", "operationId": "login-recover_password_html_content", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "email", "in": "path", "required": true, "schema": {"type": "string", "title": "Email"}}], "responses": {"200": {"description": "Successful Response", "content": {"text/html": {"schema": {"type": "string"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/": {"get": {"tags": ["users"], "summary": "Read Users", "description": "Retrieve users.", "operationId": "users-read_users", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UsersPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["users"], "summary": "Create User", "description": "Create new user.", "operationId": "users-create_user", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/me": {"get": {"tags": ["users"], "summary": "Read User Me", "description": "Get current user.", "operationId": "users-read_user_me", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "delete": {"tags": ["users"], "summary": "Delete User Me", "description": "Delete own user.", "operationId": "users-delete_user_me", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}, "patch": {"tags": ["users"], "summary": "Update User Me", "description": "Update own user.", "operationId": "users-update_user_me", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdateMe"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/users/me/password": {"patch": {"tags": ["users"], "summary": "Update Password Me", "description": "Update own password.", "operationId": "users-update_password_me", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdatePassword"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}, "security": [{"OAuth2PasswordBearer": []}]}}, "/api/v1/users/signup": {"post": {"tags": ["users"], "summary": "Register User", "description": "Create new user without the need to be logged in.", "operationId": "users-register_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRegister"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}": {"get": {"tags": ["users"], "summary": "Read User By Id", "description": "Get a specific user by id.", "operationId": "users-read_user_by_id", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Update User", "description": "Update a user.", "operationId": "users-update_user", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete User", "description": "Delete a user.", "operationId": "users-delete_user", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "User Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/utils/test-email/": {"post": {"tags": ["utils"], "summary": "Test Email", "description": "Test emails.", "operationId": "utils-test_email", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "email_to", "in": "query", "required": true, "schema": {"type": "string", "format": "email", "title": "Email To"}}], "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/utils/health-check/": {"get": {"tags": ["utils"], "summary": "Health Check", "operationId": "utils-health_check", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "boolean", "title": "Response Utils-Health Check"}}}}}}}, "/api/v1/items/": {"get": {"tags": ["items"], "summary": "Read Items", "description": "Retrieve items.", "operationId": "items-read_items", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemsPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["items"], "summary": "Create Item", "description": "Create new item.", "operationId": "items-create_item", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/items/{id}": {"get": {"tags": ["items"], "summary": "Read Item", "description": "Get item by ID.", "operationId": "items-read_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["items"], "summary": "Update Item", "description": "Update an item.", "operationId": "items-update_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ItemPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["items"], "summary": "Delete Item", "description": "Delete an item.", "operationId": "items-delete_item", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/": {"get": {"tags": ["courses"], "summary": "Read Courses", "description": "Retrieve courses with pagination and user-based security filtering.", "operationId": "courses-read_courses", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CoursesPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["courses"], "summary": "Create Course", "description": "Create new course.", "operationId": "courses-create_course", "security": [{"OAuth2PasswordBearer": []}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseCreate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Course"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}": {"get": {"tags": ["courses"], "summary": "Read Course", "description": "Get course by ID, including its documents.", "operationId": "courses-read_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseWithDocuments"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["courses"], "summary": "Update Course", "description": "Update an course.", "operationId": "courses-update_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CourseUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CoursePublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["courses"], "summary": "Delete Course", "description": "Delete an course.", "operationId": "courses-delete_course", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/documents": {"get": {"tags": ["courses"], "summary": "List Documents", "description": "List documents for a specific course.", "operationId": "courses-list_documents", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "title": "Id"}}, {"name": "skip", "in": "query", "required": false, "schema": {"type": "integer", "default": 0, "title": "Skip"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 100, "title": "Limit"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/DocumentPublic"}, "title": "Response Courses-List Documents"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/quizzes": {"get": {"tags": ["courses"], "summary": "List Quizzes", "description": "Fetches the first 10 Quiz objects related to a specific course,\nensuring the course is owned by the current user.", "operationId": "courses-list_quizzes", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "query", "required": true, "schema": {"type": "string", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 50, "exclusiveMinimum": 0, "default": 5, "title": "Limit"}}, {"name": "offset", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 0, "default": 0, "title": "Offset"}}, {"name": "order_by", "in": "query", "required": false, "schema": {"enum": ["created_at", "difficulty_level", "quiz_text"], "type": "string", "default": "created_at", "title": "Order By"}}, {"name": "difficulty", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/DifficultyLevel", "default": "easy"}}, {"name": "order_direction", "in": "query", "required": false, "schema": {"enum": ["asc", "desc"], "type": "string", "default": "desc", "title": "Order Direction"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizzesPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/attempts": {"get": {"tags": ["courses"], "summary": "Get Attempts Sessions", "description": "Fetch all incomplete quiz sessions for a given course and user.", "operationId": "courses-get_attempts_sessions", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSessionsList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{course_id}/quiz/start": {"post": {"tags": ["courses"], "summary": "Start New Quiz Session", "description": "Creates a new, immutable QuizSession, selects the initial set of questions,\nand returns the session details and the first batch of questions.", "operationId": "courses-start_new_quiz_session", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 50, "exclusiveMinimum": 0, "default": 5, "title": "Limit"}}, {"name": "offset", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 0, "default": 0, "title": "Offset"}}, {"name": "order_by", "in": "query", "required": false, "schema": {"enum": ["created_at", "difficulty_level", "quiz_text"], "type": "string", "default": "created_at", "title": "Order By"}}, {"name": "difficulty", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/DifficultyLevel", "default": "easy"}}, {"name": "order_direction", "in": "query", "required": false, "schema": {"enum": ["asc", "desc"], "type": "string", "default": "desc", "title": "Order Direction"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "prefixItems": [{"$ref": "#/components/schemas/QuizSessionPublic"}, {"$ref": "#/components/schemas/QuizzesPublic"}], "minItems": 2, "maxItems": 2, "title": "Response Courses-Start New Quiz Session"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{course_id}/stats": {"get": {"tags": ["courses"], "summary": "Get Quiz Stats", "description": "Fetches course statistics: overall average, total attempts, and the full\ndetails of the single best-scoring quiz session.", "operationId": "courses-get_quiz_stats", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizStats"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/courses/{id}/flashcards": {"get": {"tags": ["courses"], "summary": "Generate Flashcards By Course Id", "description": "Generate flashcards for the most recent document associated with a course.", "operationId": "courses-generate_flashcards_by_course_id", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/QAItem"}, "title": "Response Courses-Generate Flashcards By Course Id"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/chat/{course_id}/stream": {"post": {"tags": ["chat"], "summary": "Stream chat responses", "description": "Stream AI-generated responses based on course materials", "operationId": "chat-stream_chat", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChatMessage"}}}}, "responses": {"200": {"description": "Successful streaming response"}, "404": {"description": "Course not found"}, "401": {"description": "Not authenticated"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/chat/{course_id}/history": {"get": {"tags": ["chat"], "summary": "Get chat history", "description": "Retrieve chat history for a course", "operationId": "chat-get_chat_history", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "default": 50, "title": "Limit"}}], "responses": {"200": {"description": "List of chat messages", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ChatPublic"}, "title": "Response 200 Chat-Get Chat History"}}}}, "404": {"description": "Course not found"}, "401": {"description": "Not authenticated"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/documents/process": {"post": {"tags": ["documents"], "summary": "Process Multiple Documents", "description": "Accept multiple PDF uploads, save to temp files, and queue a background task for each.", "operationId": "documents-process_multiple_documents", "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_documents-process_multiple_documents"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/documents/{id}": {"get": {"tags": ["documents"], "summary": "Read Document", "description": "Get a document by its ID, ensuring the user has permissions.", "operationId": "documents-read_document", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DocumentPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["documents"], "summary": "Delete Document", "description": "Delete a document by its ID, ensuring the user has permissions.", "operationId": "documents-delete_document", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Message"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/podcasts/course/{course_id}": {"get": {"tags": ["podcasts"], "summary": "List Podcasts", "operationId": "podcasts-list_podcasts", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PodcastsPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/podcasts/course/{course_id}/generate": {"post": {"tags": ["podcasts"], "summary": "Generate Podcast", "operationId": "podcasts-generate_podcast", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "course_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Course Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GeneratePodcastRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PodcastPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/podcasts/{podcast_id}": {"get": {"tags": ["podcasts"], "summary": "Get Podcast", "operationId": "podcasts-get_podcast", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "podcast_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Podcast Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PodcastPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["podcasts"], "summary": "Delete Podcast", "operationId": "podcasts-delete_podcast", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "podcast_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Podcast Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "string"}, "title": "Response Podcasts-Delete Podcast"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/podcasts/{podcast_id}/audio": {"get": {"tags": ["podcasts"], "summary": "Stream Audio", "operationId": "podcasts-stream_audio", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "podcast_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Podcast Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/quiz-sessions/{id}": {"get": {"tags": ["quiz-sessions"], "summary": "Get Quiz Session Optimized", "description": "Retrieves a QuizSession, eagerly loading attempts ONLY if completed,\nor just the session and quizzes if in progress.", "operationId": "quiz-sessions-get_quiz_session_optimized", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Id"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSessionPublicWithResults"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/quiz-sessions/{id}/score": {"post": {"tags": ["quiz-sessions"], "summary": "Submit And Score Quiz Batch", "description": "API endpoint to receive a batch of user answers and score a specific\nQuizSession identified by the session_id.", "operationId": "quiz-sessions-submit_and_score_quiz_batch", "security": [{"OAuth2PasswordBearer": []}], "parameters": [{"name": "session_id", "in": "query", "required": true, "schema": {"type": "string", "format": "uuid", "title": "Session Id"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizSubmissionBatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QuizScoreSummary"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/private/users/": {"post": {"tags": ["private"], "summary": "Create User", "description": "Create a new user.", "operationId": "private-create_user", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PrivateUserCreate"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPublic"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"Body_documents-process_multiple_documents": {"properties": {"files": {"items": {"type": "string", "format": "binary"}, "type": "array", "title": "Files"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}}, "type": "object", "required": ["files", "course_id"], "title": "Body_documents-process_multiple_documents"}, "Body_login-login_access_token": {"properties": {"grant_type": {"anyOf": [{"type": "string", "pattern": "^password$"}, {"type": "null"}], "title": "Grant Type"}, "username": {"type": "string", "title": "Username"}, "password": {"type": "string", "format": "password", "title": "Password"}, "scope": {"type": "string", "title": "Scope", "default": ""}, "client_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Client Id"}, "client_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "format": "password", "title": "Client Secret"}}, "type": "object", "required": ["username", "password"], "title": "Body_login-login_access_token"}, "ChatMessage": {"properties": {"message": {"type": "string", "title": "Message"}, "continue_response": {"type": "boolean", "title": "Continue Response", "default": false}}, "type": "object", "required": ["message"], "title": "ChatMessage", "example": {"continue_response": false, "message": "What is the main topic of the course?"}}, "ChatPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "message": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Message"}, "is_system": {"type": "boolean", "title": "Is System"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "course_id", "is_system", "created_at", "updated_at"], "title": "ChatPublic", "description": "Public schema for Chat message entries (no quiz fields)."}, "Course": {"properties": {"name": {"type": "string", "maxLength": 255, "minLength": 3, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["name", "owner_id"], "title": "Course"}, "CourseCreate": {"properties": {"name": {"type": "string", "maxLength": 255, "minLength": 3, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}}, "type": "object", "required": ["name"], "title": "CourseCreate"}, "CoursePublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "documents": {"items": {"$ref": "#/components/schemas/DocumentPublic"}, "type": "array", "title": "Documents"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "owner_id", "name", "documents", "created_at", "updated_at"], "title": "CoursePublic"}, "CourseUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 255, "minLength": 3}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1020}, {"type": "null"}], "title": "Description"}}, "type": "object", "title": "CourseUpdate"}, "CourseWithDocuments": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "documents": {"items": {"$ref": "#/components/schemas/DocumentPublic"}, "type": "array", "title": "Documents", "default": []}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "owner_id", "name", "created_at", "updated_at"], "title": "CourseWithDocuments"}, "CoursesPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/CoursePublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "CoursesPublic"}, "DifficultyLevel": {"type": "string", "enum": ["easy", "medium", "hard", "expert", "all"], "title": "DifficultyLevel"}, "DocumentPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "title": {"type": "string", "title": "Title"}, "filename": {"type": "string", "title": "Filename"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "status": {"$ref": "#/components/schemas/DocumentStatus"}}, "type": "object", "required": ["id", "course_id", "title", "filename", "updated_at", "created_at", "status"], "title": "DocumentPublic"}, "DocumentStatus": {"type": "string", "enum": ["pending", "processing", "completed", "failed"], "title": "DocumentStatus"}, "GeneratePodcastRequest": {"properties": {"title": {"type": "string", "title": "Title"}, "mode": {"$ref": "#/components/schemas/ModeEnum", "default": "dialogue"}, "topics": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Topics"}, "teacher_voice": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Teacher Voice"}, "student_voice": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Student Voice"}, "narrator_voice": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Narrator Voice"}, "document_ids": {"anyOf": [{"items": {"type": "string", "format": "uuid"}, "type": "array"}, {"type": "null"}], "title": "Document Ids"}}, "type": "object", "required": ["title"], "title": "GeneratePodcastRequest", "description": "Request body for generating a podcast for a course.\n "}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "Item": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}}, "type": "object", "required": ["title", "owner_id"], "title": "Item"}, "ItemCreate": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}}, "type": "object", "required": ["title"], "title": "ItemCreate"}, "ItemPublic": {"properties": {"title": {"type": "string", "maxLength": 255, "minLength": 1, "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}, "id": {"type": "string", "format": "uuid", "title": "Id"}, "owner_id": {"type": "string", "format": "uuid", "title": "Owner Id"}}, "type": "object", "required": ["title", "id", "owner_id"], "title": "ItemPublic"}, "ItemUpdate": {"properties": {"title": {"anyOf": [{"type": "string", "maxLength": 255, "minLength": 1}, {"type": "null"}], "title": "Title"}, "description": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Description"}}, "type": "object", "title": "ItemUpdate"}, "ItemsPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/Item"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "ItemsPublic"}, "Message": {"properties": {"message": {"type": "string", "title": "Message"}}, "type": "object", "required": ["message"], "title": "Message"}, "ModeEnum": {"type": "string", "enum": ["dialogue", "presentation"], "title": "ModeEnum"}, "NewPassword": {"properties": {"token": {"type": "string", "title": "Token"}, "new_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["token", "new_password"], "title": "NewPassword"}, "PodcastPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "title": {"type": "string", "title": "Title"}, "transcript": {"type": "string", "title": "Transcript"}, "audio_path": {"type": "string", "title": "Audio Path"}, "storage_backend": {"type": "string", "title": "Storage Backend"}, "duration_seconds": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Duration Seconds"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "course_id", "title", "transcript", "audio_path", "storage_backend", "created_at", "updated_at"], "title": "PodcastPublic"}, "PodcastsPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/PodcastPublic"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "PodcastsPublic"}, "PrivateUserCreate": {"properties": {"email": {"type": "string", "title": "Email"}, "password": {"type": "string", "title": "Password"}, "full_name": {"type": "string", "title": "Full Name"}, "is_verified": {"type": "boolean", "title": "Is Verified", "default": false}}, "type": "object", "required": ["email", "password", "full_name"], "title": "PrivateUserCreate"}, "QAItem": {"properties": {"question": {"type": "string", "title": "Question"}, "answer": {"type": "string", "title": "Answer"}}, "type": "object", "required": ["question", "answer"], "title": "QAItem"}, "QuizAttemptPublic": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "selected_answer_text": {"type": "string", "title": "Selected Answer Text"}, "is_correct": {"type": "boolean", "title": "Is Correct"}, "correct_answer_text": {"type": "string", "title": "Correct Answer Text"}, "time_spent_seconds": {"type": "number", "title": "Time Spent Seconds"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}}, "type": "object", "required": ["quiz_id", "selected_answer_text", "is_correct", "correct_answer_text", "time_spent_seconds", "created_at"], "title": "QuizAttemptPublic", "description": "Public schema for a single QuizAttempt record.\nUsed to return the full history/results when a session is complete."}, "QuizChoice": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "text": {"type": "string", "title": "Text"}}, "type": "object", "required": ["id", "text"], "title": "QuizChoice"}, "QuizPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "quiz_text": {"type": "string", "title": "Quiz Text"}, "choices": {"items": {"$ref": "#/components/schemas/QuizChoice"}, "type": "array", "title": "Choices"}}, "type": "object", "required": ["id", "quiz_text", "choices"], "title": "QuizPublic"}, "QuizScoreSummary": {"properties": {"total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"type": "number", "title": "Score Percentage"}, "results": {"items": {"$ref": "#/components/schemas/SingleQuizScore"}, "type": "array", "title": "Results"}}, "type": "object", "required": ["total_submitted", "total_correct", "score_percentage", "results"], "title": "QuizScoreSummary", "description": "The overall score for the batch of submissions."}, "QuizSessionPublic": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Score Percentage"}, "is_completed": {"type": "boolean", "title": "Is Completed"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}}, "type": "object", "required": ["id", "course_id", "total_submitted", "total_correct", "is_completed", "created_at", "updated_at"], "title": "QuizSessionPublic", "description": "Public schema for a QuizSession."}, "QuizSessionPublicWithResults": {"properties": {"id": {"type": "string", "format": "uuid", "title": "Id"}, "course_id": {"type": "string", "format": "uuid", "title": "Course Id"}, "total_submitted": {"type": "integer", "title": "Total Submitted"}, "total_correct": {"type": "integer", "title": "Total Correct"}, "score_percentage": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Score Percentage"}, "is_completed": {"type": "boolean", "title": "Is Completed"}, "created_at": {"type": "string", "format": "date-time", "title": "Created At"}, "updated_at": {"type": "string", "format": "date-time", "title": "Updated At"}, "quizzes": {"items": {"$ref": "#/components/schemas/QuizPublic"}, "type": "array", "title": "Quizzes"}, "results": {"items": {"$ref": "#/components/schemas/QuizAttemptPublic"}, "type": "array", "title": "Results"}}, "type": "object", "required": ["id", "course_id", "total_submitted", "total_correct", "is_completed", "created_at", "updated_at"], "title": "QuizSessionPublicWithResults", "description": "Expanded schema that includes quiz attempts (results)\nwhen the session is marked as completed."}, "QuizSessionsList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QuizSessionPublic"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "QuizSessionsList"}, "QuizStats": {"properties": {"best_total_submitted": {"type": "integer", "title": "Best Total Submitted"}, "best_total_correct": {"type": "integer", "title": "Best Total Correct"}, "best_score_percentage": {"type": "number", "title": "Best Score Percentage"}, "average_score": {"type": "number", "title": "Average Score"}, "attempts": {"type": "integer", "title": "Attempts"}}, "type": "object", "required": ["best_total_submitted", "best_total_correct", "best_score_percentage", "average_score", "attempts"], "title": "QuizStats"}, "QuizSubmissionBatch": {"properties": {"submissions": {"items": {"$ref": "#/components/schemas/SingleQuizSubmission"}, "type": "array", "title": "Submissions"}, "total_time_seconds": {"type": "number", "title": "Total Time Seconds", "default": 0.0}}, "type": "object", "required": ["submissions"], "title": "QuizSubmissionBatch", "description": "Container for multiple quiz submissions."}, "QuizzesPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QuizPublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "QuizzesPublic"}, "SingleQuizScore": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "is_correct": {"type": "boolean", "title": "Is Correct"}, "correct_answer_text": {"type": "string", "title": "Correct Answer Text"}, "feedback": {"type": "string", "title": "Feedback"}}, "type": "object", "required": ["quiz_id", "is_correct", "correct_answer_text", "feedback"], "title": "SingleQuizScore", "description": "The result for a single question."}, "SingleQuizSubmission": {"properties": {"quiz_id": {"type": "string", "format": "uuid", "title": "Quiz Id"}, "selected_answer_text": {"type": "string", "title": "Selected Answer Text"}}, "type": "object", "required": ["quiz_id", "selected_answer_text"], "title": "SingleQuizSubmission", "description": "The user's answer for one question."}, "Token": {"properties": {"access_token": {"type": "string", "title": "Access Token"}, "token_type": {"type": "string", "title": "Token Type", "default": "bearer"}}, "type": "object", "required": ["access_token"], "title": "Token"}, "UpdatePassword": {"properties": {"current_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Current Password"}, "new_password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["current_password", "new_password"], "title": "UpdatePassword"}, "UserCreate": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "password"], "title": "UserCreate"}, "UserPublic": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "id": {"type": "string", "format": "uuid", "title": "Id"}}, "type": "object", "required": ["email", "id"], "title": "UserPublic"}, "UserRegister": {"properties": {"email": {"type": "string", "maxLength": 255, "format": "email", "title": "Email"}, "password": {"type": "string", "maxLength": 40, "minLength": 8, "title": "Password"}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}}, "type": "object", "required": ["email", "password"], "title": "UserRegister"}, "UserUpdate": {"properties": {"email": {"anyOf": [{"type": "string", "maxLength": 255, "format": "email"}, {"type": "null"}], "title": "Email"}, "is_active": {"type": "boolean", "title": "Is Active", "default": true}, "is_superuser": {"type": "boolean", "title": "Is Superuser", "default": false}, "full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "password": {"anyOf": [{"type": "string", "maxLength": 40, "minLength": 8}, {"type": "null"}], "title": "Password"}}, "type": "object", "title": "UserUpdate"}, "UserUpdateMe": {"properties": {"full_name": {"anyOf": [{"type": "string", "maxLength": 255}, {"type": "null"}], "title": "Full Name"}, "email": {"anyOf": [{"type": "string", "maxLength": 255, "format": "email"}, {"type": "null"}], "title": "Email"}}, "type": "object", "title": "UserUpdateMe"}, "UsersPublic": {"properties": {"data": {"items": {"$ref": "#/components/schemas/UserPublic"}, "type": "array", "title": "Data"}, "count": {"type": "integer", "title": "Count"}}, "type": "object", "required": ["data", "count"], "title": "UsersPublic"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}, "securitySchemes": {"OAuth2PasswordBearer": {"type": "oauth2", "flows": {"password": {"scopes": {}, "tokenUrl": "/api/v1/login/access-token"}}}}}} diff --git a/frontend/src/actions/documents.ts b/frontend/src/actions/documents.ts index 8e5c9b4..b5171ca 100644 --- a/frontend/src/actions/documents.ts +++ b/frontend/src/actions/documents.ts @@ -1,10 +1,29 @@ 'use server' -import {DocumentsService} from '@/client' +import {DocumentsService, CoursesService} from '@/client' import {Result} from '@/lib/result' import {IState} from '@/types/common' import {mapApiError} from '@/lib/mapApiError' +export async function getDocumentsByCourse(courseId: string): Promise> { + try { + const response = await CoursesService.getApiV1CoursesByIdDocuments({ + path: { id: courseId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + return { + ok: true, + data: response.data, + } + } catch (error) { + return { + ok: false, + error: mapApiError(error), + } + } +} + export async function deleteDocument( _state: IState, formData: FormData, diff --git a/frontend/src/actions/podcasts.ts b/frontend/src/actions/podcasts.ts new file mode 100644 index 0000000..0fbba0e --- /dev/null +++ b/frontend/src/actions/podcasts.ts @@ -0,0 +1,83 @@ +'use server' + +import { PodcastsService } from '@/client' +import { Result } from '@/lib/result' +import { mapApiError } from '@/lib/mapApiError' +import type { GeneratePodcastRequest, PodcastPublic, PodcastsPublic } from '@/client/types.gen' + +export async function getPodcasts(courseId: string): Promise> { + try { + const response = await PodcastsService.getApiV1PodcastsCourseByCourseId({ + path: { course_id: courseId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + return { + ok: true, + data: response.data, + } + } catch (error) { + return { + ok: false, + error: mapApiError(error), + } + } +} + +export async function generatePodcast(courseId: string, data: GeneratePodcastRequest): Promise> { + try { + const response = await PodcastsService.postApiV1PodcastsCourseByCourseIdGenerate({ + path: { course_id: courseId }, + body: data, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + return { + ok: true, + data: response.data, + } + } catch (error) { + return { + ok: false, + error: mapApiError(error), + } + } +} + +export async function getPodcast(podcastId: string): Promise> { + try { + const response = await PodcastsService.getApiV1PodcastsByPodcastId({ + path: { podcast_id: podcastId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + return { + ok: true, + data: response.data, + } + } catch (error) { + return { + ok: false, + error: mapApiError(error), + } + } +} + +export async function deletePodcast(podcastId: string): Promise> { + try { + await PodcastsService.deleteApiV1PodcastsByPodcastId({ + path: { podcast_id: podcastId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + return { + ok: true, + data: null, + } + } catch (error) { + return { + ok: false, + error: mapApiError(error), + } + } +} diff --git a/frontend/src/app/(routes)/(dashboard)/dashboard/courses/[id]/page.tsx b/frontend/src/app/(routes)/(dashboard)/dashboard/courses/[id]/page.tsx index 3b56ed6..b6fafe7 100644 --- a/frontend/src/app/(routes)/(dashboard)/dashboard/courses/[id]/page.tsx +++ b/frontend/src/app/(routes)/(dashboard)/dashboard/courses/[id]/page.tsx @@ -1,3 +1,4 @@ +import PodcastComponent from '@/components/podcast' import dynamic from 'next/dynamic' import {getCourse} from '@/actions/courses' @@ -53,9 +54,7 @@ export default async function Page(props: {params: Promise<{id: string}>}) { -
- Podcast content will be displayed here -
+
diff --git a/frontend/src/app/(routes)/(dashboard)/dashboard/layout.tsx b/frontend/src/app/(routes)/(dashboard)/dashboard/layout.tsx index c49316d..0a49baf 100644 --- a/frontend/src/app/(routes)/(dashboard)/dashboard/layout.tsx +++ b/frontend/src/app/(routes)/(dashboard)/dashboard/layout.tsx @@ -19,7 +19,7 @@ import { getMe } from '@/actions/users' let displayName = ''; // Configure axios client per request client.setConfig({ - baseURL: process.env.NEXT_PUBLIC_BACKEND_BASE_URL as string, + baseURL: process.env.NEXT_INTERNAL_BACKEND_BASE_URL as string, headers: token ? { Authorization: `Bearer ${token}` } : undefined, }) diff --git a/frontend/src/app/api/v1/podcasts/audio/[podcastId]/route.ts b/frontend/src/app/api/v1/podcasts/audio/[podcastId]/route.ts new file mode 100644 index 0000000..47b23cb --- /dev/null +++ b/frontend/src/app/api/v1/podcasts/audio/[podcastId]/route.ts @@ -0,0 +1,105 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { PodcastsService } from '@/client' +import { get } from '@/utils' + +interface ContextParams { + params: Promise<{ podcastId: string }> +} + +/** + * Stream audio for a podcast + * Uses the same pattern as chat streaming with generated client + */ +export async function GET(_req: NextRequest, context: ContextParams) { + try { + const { podcastId } = await context.params + + // First, try to get the response without specifying responseType to detect the content type + const response = await PodcastsService.getApiV1PodcastsByPodcastIdAudio({ + path: { podcast_id: podcastId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + }) + + // Check if response is JSON (i.e S3) or binary (local) + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + // S3 case: response contains {"url": "presigned_s3_url"} + const data = response.data as { url: string } + + // Redirect to the S3 URL + return NextResponse.redirect(data.url) + } else { + // Local case: response contains audio stream + // Now fetch stream with proper responseType + const streamResponse = await PodcastsService.getApiV1PodcastsByPodcastIdAudio({ + path: { podcast_id: podcastId }, + requestValidator: async () => {}, + responseValidator: async () => {}, + responseType: 'stream', + }) + + // Convert Node.js IncomingMessage to Web ReadableStream (same as chat) + const nodeStream = streamResponse.data as any + + if (!nodeStream || typeof nodeStream.pipe !== 'function') { + throw new Error('Expected Node readable stream from PodcastsService') + } + + const webStream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk: Buffer) => { + // Convert Buffer to Uint8Array for Web ReadableStream + controller.enqueue(new Uint8Array(chunk)) + }) + + nodeStream.on('end', () => { + controller.close() + }) + + nodeStream.on('error', (error: Error) => { + controller.error(error) + }) + }, + + cancel() { + nodeStream.destroy() + } + }) + + return new Response(webStream, { + headers: { + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'no-cache', + 'Accept-Ranges': 'bytes', + 'X-Accel-Buffering': 'no', + }, + }) + } + } catch (error) { + // Log error in development for debugging + if (process.env.NODE_ENV === 'development') { + console.error('[PodcastAudio] Audio stream error:', error) + } + + const status: number = get( + error as Record, + 'response.status', + 500, + ) + const body: { detail: string } = get( + error as Record, + 'response.data.detail', + { + detail: 'Internal Server Error', + }, + ) + return NextResponse.json(body, { status }) + } +} + +export const config = { + runtime: 'nodejs', + maxDuration: 300, +} diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 800e1cd..be50269 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -111,28 +111,20 @@ export const ChatPublicSchema = { format: 'uuid', title: 'Course Id' }, - total_submitted: { - type: 'integer', - title: 'Total Submitted' - }, - total_correct: { - type: 'integer', - title: 'Total Correct' - }, - score_percentage: { + message: { anyOf: [ { - type: 'number' + type: 'string' }, { type: 'null' } ], - title: 'Score Percentage' + title: 'Message' }, - is_completed: { + is_system: { type: 'boolean', - title: 'Is Completed' + title: 'Is System' }, created_at: { type: 'string', @@ -143,19 +135,12 @@ export const ChatPublicSchema = { type: 'string', format: 'date-time', title: 'Updated At' - }, - message: { - type: 'string', - title: 'Message' - }, - is_system: { - type: 'boolean', - title: 'Is System' } }, type: 'object', - required: ['id', 'course_id', 'total_submitted', 'total_correct', 'is_completed', 'created_at', 'updated_at', 'message', 'is_system'], - title: 'ChatPublic' + required: ['id', 'course_id', 'is_system', 'created_at', 'updated_at'], + title: 'ChatPublic', + description: 'Public schema for Chat message entries (no quiz fields).' } as const; export const CourseSchema = { @@ -388,82 +373,26 @@ export const DifficultyLevelSchema = { title: 'DifficultyLevel' } as const; -export const DocumentSchema = { +export const DocumentPublicSchema = { properties: { - title: { - type: 'string', - maxLength: 255, - minLength: 1, - title: 'Title' - }, id: { type: 'string', format: 'uuid', title: 'Id' }, - chunk_count: { - anyOf: [ - { - type: 'integer' - }, - { - type: 'null' - } - ], - title: 'Chunk Count' - }, course_id: { type: 'string', format: 'uuid', title: 'Course Id' }, - embedding_namespace: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], - title: 'Embedding Namespace' + title: { + type: 'string', + title: 'Title' }, filename: { type: 'string', title: 'Filename' }, - status: { - '$ref': '#/components/schemas/DocumentStatus', - default: 'pending' - }, - created_at: { - type: 'string', - format: 'date-time', - title: 'Created At' - }, - updated_at: { - type: 'string', - format: 'date-time', - title: 'Updated At' - } - }, - type: 'object', - required: ['title', 'course_id', 'filename'], - title: 'Document' -} as const; - -export const DocumentPublicSchema = { - properties: { - id: { - type: 'string', - format: 'uuid', - title: 'Id' - }, - course_id: { - type: 'string', - format: 'uuid', - title: 'Course Id' - }, updated_at: { type: 'string', format: 'date-time', @@ -479,7 +408,7 @@ export const DocumentPublicSchema = { } }, type: 'object', - required: ['id', 'course_id', 'updated_at', 'created_at', 'status'], + required: ['id', 'course_id', 'title', 'filename', 'updated_at', 'created_at', 'status'], title: 'DocumentPublic' } as const; @@ -489,6 +418,83 @@ export const DocumentStatusSchema = { title: 'DocumentStatus' } as const; +export const GeneratePodcastRequestSchema = { + properties: { + title: { + type: 'string', + title: 'Title' + }, + mode: { + '$ref': '#/components/schemas/ModeEnum', + default: 'dialogue' + }, + topics: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Topics' + }, + teacher_voice: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Teacher Voice' + }, + student_voice: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Student Voice' + }, + narrator_voice: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Narrator Voice' + }, + document_ids: { + anyOf: [ + { + items: { + type: 'string', + format: 'uuid' + }, + type: 'array' + }, + { + type: 'null' + } + ], + title: 'Document Ids' + } + }, + type: 'object', + required: ['title'], + title: 'GeneratePodcastRequest', + description: `Request body for generating a podcast for a course. + ` +} as const; + export const HTTPValidationErrorSchema = { properties: { detail: { @@ -664,6 +670,12 @@ export const MessageSchema = { title: 'Message' } as const; +export const ModeEnumSchema = { + type: 'string', + enum: ['dialogue', 'presentation'], + title: 'ModeEnum' +} as const; + export const NewPasswordSchema = { properties: { token: { @@ -682,6 +694,76 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; +export const PodcastPublicSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + course_id: { + type: 'string', + format: 'uuid', + title: 'Course Id' + }, + title: { + type: 'string', + title: 'Title' + }, + transcript: { + type: 'string', + title: 'Transcript' + }, + audio_path: { + type: 'string', + title: 'Audio Path' + }, + storage_backend: { + type: 'string', + title: 'Storage Backend' + }, + duration_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Duration Seconds' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'course_id', 'title', 'transcript', 'audio_path', 'storage_backend', 'created_at', 'updated_at'], + title: 'PodcastPublic' +} as const; + +export const PodcastsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/PodcastPublic' + }, + type: 'array', + title: 'Data' + } + }, + type: 'object', + required: ['data'], + title: 'PodcastsPublic' +} as const; + export const PrivateUserCreateSchema = { properties: { email: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index c25bfe3..fc2fe4c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,8 +2,8 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { DeleteApiV1CoursesByIdData, DeleteApiV1CoursesByIdErrors, DeleteApiV1CoursesByIdResponses, DeleteApiV1DocumentsByIdData, DeleteApiV1DocumentsByIdErrors, DeleteApiV1DocumentsByIdResponses, DeleteApiV1ItemsByIdData, DeleteApiV1ItemsByIdErrors, DeleteApiV1ItemsByIdResponses, DeleteApiV1UsersByUserIdData, DeleteApiV1UsersByUserIdErrors, DeleteApiV1UsersByUserIdResponses, DeleteApiV1UsersMeData, DeleteApiV1UsersMeResponses, GetApiV1ChatByCourseIdHistoryData, GetApiV1ChatByCourseIdHistoryErrors, GetApiV1ChatByCourseIdHistoryResponses, GetApiV1CoursesByCourseIdStatsData, GetApiV1CoursesByCourseIdStatsErrors, GetApiV1CoursesByCourseIdStatsResponses, GetApiV1CoursesByIdAttemptsData, GetApiV1CoursesByIdAttemptsErrors, GetApiV1CoursesByIdAttemptsResponses, GetApiV1CoursesByIdData, GetApiV1CoursesByIdDocumentsData, GetApiV1CoursesByIdDocumentsErrors, GetApiV1CoursesByIdDocumentsResponses, GetApiV1CoursesByIdErrors, GetApiV1CoursesByIdFlashcardsData, GetApiV1CoursesByIdFlashcardsErrors, GetApiV1CoursesByIdFlashcardsResponses, GetApiV1CoursesByIdQuizzesData, GetApiV1CoursesByIdQuizzesErrors, GetApiV1CoursesByIdQuizzesResponses, GetApiV1CoursesByIdResponses, GetApiV1CoursesData, GetApiV1CoursesErrors, GetApiV1CoursesResponses, GetApiV1DocumentsByIdData, GetApiV1DocumentsByIdErrors, GetApiV1DocumentsByIdResponses, GetApiV1ItemsByIdData, GetApiV1ItemsByIdErrors, GetApiV1ItemsByIdResponses, GetApiV1ItemsData, GetApiV1ItemsErrors, GetApiV1ItemsResponses, GetApiV1QuizSessionsByIdData, GetApiV1QuizSessionsByIdErrors, GetApiV1QuizSessionsByIdResponses, GetApiV1UsersByUserIdData, GetApiV1UsersByUserIdErrors, GetApiV1UsersByUserIdResponses, GetApiV1UsersData, GetApiV1UsersErrors, GetApiV1UsersMeData, GetApiV1UsersMeResponses, GetApiV1UsersResponses, GetApiV1UtilsHealthCheckData, GetApiV1UtilsHealthCheckResponses, PatchApiV1UsersByUserIdData, PatchApiV1UsersByUserIdErrors, PatchApiV1UsersByUserIdResponses, PatchApiV1UsersMeData, PatchApiV1UsersMeErrors, PatchApiV1UsersMePasswordData, PatchApiV1UsersMePasswordErrors, PatchApiV1UsersMePasswordResponses, PatchApiV1UsersMeResponses, PostApiV1ChatByCourseIdStreamData, PostApiV1ChatByCourseIdStreamErrors, PostApiV1ChatByCourseIdStreamResponses, PostApiV1CoursesByCourseIdQuizStartData, PostApiV1CoursesByCourseIdQuizStartErrors, PostApiV1CoursesByCourseIdQuizStartResponses, PostApiV1CoursesData, PostApiV1CoursesErrors, PostApiV1CoursesResponses, PostApiV1DocumentsProcessData, PostApiV1DocumentsProcessErrors, PostApiV1DocumentsProcessResponses, PostApiV1ItemsData, PostApiV1ItemsErrors, PostApiV1ItemsResponses, PostApiV1LoginAccessTokenData, PostApiV1LoginAccessTokenErrors, PostApiV1LoginAccessTokenResponses, PostApiV1LoginTestTokenData, PostApiV1LoginTestTokenResponses, PostApiV1PasswordRecoveryByEmailData, PostApiV1PasswordRecoveryByEmailErrors, PostApiV1PasswordRecoveryByEmailResponses, PostApiV1PasswordRecoveryHtmlContentByEmailData, PostApiV1PasswordRecoveryHtmlContentByEmailErrors, PostApiV1PasswordRecoveryHtmlContentByEmailResponses, PostApiV1PrivateUsersData, PostApiV1PrivateUsersErrors, PostApiV1PrivateUsersResponses, PostApiV1QuizSessionsByIdScoreData, PostApiV1QuizSessionsByIdScoreErrors, PostApiV1QuizSessionsByIdScoreResponses, PostApiV1ResetPasswordData, PostApiV1ResetPasswordErrors, PostApiV1ResetPasswordResponses, PostApiV1UsersData, PostApiV1UsersErrors, PostApiV1UsersResponses, PostApiV1UsersSignupData, PostApiV1UsersSignupErrors, PostApiV1UsersSignupResponses, PostApiV1UtilsTestEmailData, PostApiV1UtilsTestEmailErrors, PostApiV1UtilsTestEmailResponses, PutApiV1CoursesByIdData, PutApiV1CoursesByIdErrors, PutApiV1CoursesByIdResponses, PutApiV1ItemsByIdData, PutApiV1ItemsByIdErrors, PutApiV1ItemsByIdResponses } from './types.gen'; -import { zDeleteApiV1CoursesByIdData, zDeleteApiV1CoursesByIdResponse, zDeleteApiV1DocumentsByIdData, zDeleteApiV1ItemsByIdData, zDeleteApiV1ItemsByIdResponse, zDeleteApiV1UsersByUserIdData, zDeleteApiV1UsersByUserIdResponse, zDeleteApiV1UsersMeData, zDeleteApiV1UsersMeResponse, zGetApiV1ChatByCourseIdHistoryData, zGetApiV1ChatByCourseIdHistoryResponse, zGetApiV1CoursesByCourseIdStatsData, zGetApiV1CoursesByCourseIdStatsResponse, zGetApiV1CoursesByIdAttemptsData, zGetApiV1CoursesByIdAttemptsResponse, zGetApiV1CoursesByIdData, zGetApiV1CoursesByIdDocumentsData, zGetApiV1CoursesByIdDocumentsResponse, zGetApiV1CoursesByIdFlashcardsData, zGetApiV1CoursesByIdFlashcardsResponse, zGetApiV1CoursesByIdQuizzesData, zGetApiV1CoursesByIdQuizzesResponse, zGetApiV1CoursesByIdResponse, zGetApiV1CoursesData, zGetApiV1CoursesResponse, zGetApiV1DocumentsByIdData, zGetApiV1DocumentsByIdResponse, zGetApiV1ItemsByIdData, zGetApiV1ItemsByIdResponse, zGetApiV1ItemsData, zGetApiV1ItemsResponse, zGetApiV1QuizSessionsByIdData, zGetApiV1QuizSessionsByIdResponse, zGetApiV1UsersByUserIdData, zGetApiV1UsersByUserIdResponse, zGetApiV1UsersData, zGetApiV1UsersMeData, zGetApiV1UsersMeResponse, zGetApiV1UsersResponse, zGetApiV1UtilsHealthCheckData, zGetApiV1UtilsHealthCheckResponse, zPatchApiV1UsersByUserIdData, zPatchApiV1UsersByUserIdResponse, zPatchApiV1UsersMeData, zPatchApiV1UsersMePasswordData, zPatchApiV1UsersMePasswordResponse, zPatchApiV1UsersMeResponse, zPostApiV1ChatByCourseIdStreamData, zPostApiV1CoursesByCourseIdQuizStartData, zPostApiV1CoursesByCourseIdQuizStartResponse, zPostApiV1CoursesData, zPostApiV1CoursesResponse, zPostApiV1DocumentsProcessData, zPostApiV1ItemsData, zPostApiV1ItemsResponse, zPostApiV1LoginAccessTokenData, zPostApiV1LoginAccessTokenResponse, zPostApiV1LoginTestTokenData, zPostApiV1LoginTestTokenResponse, zPostApiV1PasswordRecoveryByEmailData, zPostApiV1PasswordRecoveryByEmailResponse, zPostApiV1PasswordRecoveryHtmlContentByEmailData, zPostApiV1PasswordRecoveryHtmlContentByEmailResponse, zPostApiV1PrivateUsersData, zPostApiV1PrivateUsersResponse, zPostApiV1QuizSessionsByIdScoreData, zPostApiV1QuizSessionsByIdScoreResponse, zPostApiV1ResetPasswordData, zPostApiV1ResetPasswordResponse, zPostApiV1UsersData, zPostApiV1UsersResponse, zPostApiV1UsersSignupData, zPostApiV1UsersSignupResponse, zPostApiV1UtilsTestEmailData, zPostApiV1UtilsTestEmailResponse, zPutApiV1CoursesByIdData, zPutApiV1CoursesByIdResponse, zPutApiV1ItemsByIdData, zPutApiV1ItemsByIdResponse } from './zod.gen'; +import type { DeleteApiV1CoursesByIdData, DeleteApiV1CoursesByIdErrors, DeleteApiV1CoursesByIdResponses, DeleteApiV1DocumentsByIdData, DeleteApiV1DocumentsByIdErrors, DeleteApiV1DocumentsByIdResponses, DeleteApiV1ItemsByIdData, DeleteApiV1ItemsByIdErrors, DeleteApiV1ItemsByIdResponses, DeleteApiV1PodcastsByPodcastIdData, DeleteApiV1PodcastsByPodcastIdErrors, DeleteApiV1PodcastsByPodcastIdResponses, DeleteApiV1UsersByUserIdData, DeleteApiV1UsersByUserIdErrors, DeleteApiV1UsersByUserIdResponses, DeleteApiV1UsersMeData, DeleteApiV1UsersMeResponses, GetApiV1ChatByCourseIdHistoryData, GetApiV1ChatByCourseIdHistoryErrors, GetApiV1ChatByCourseIdHistoryResponses, GetApiV1CoursesByCourseIdStatsData, GetApiV1CoursesByCourseIdStatsErrors, GetApiV1CoursesByCourseIdStatsResponses, GetApiV1CoursesByIdAttemptsData, GetApiV1CoursesByIdAttemptsErrors, GetApiV1CoursesByIdAttemptsResponses, GetApiV1CoursesByIdData, GetApiV1CoursesByIdDocumentsData, GetApiV1CoursesByIdDocumentsErrors, GetApiV1CoursesByIdDocumentsResponses, GetApiV1CoursesByIdErrors, GetApiV1CoursesByIdFlashcardsData, GetApiV1CoursesByIdFlashcardsErrors, GetApiV1CoursesByIdFlashcardsResponses, GetApiV1CoursesByIdQuizzesData, GetApiV1CoursesByIdQuizzesErrors, GetApiV1CoursesByIdQuizzesResponses, GetApiV1CoursesByIdResponses, GetApiV1CoursesData, GetApiV1CoursesErrors, GetApiV1CoursesResponses, GetApiV1DocumentsByIdData, GetApiV1DocumentsByIdErrors, GetApiV1DocumentsByIdResponses, GetApiV1ItemsByIdData, GetApiV1ItemsByIdErrors, GetApiV1ItemsByIdResponses, GetApiV1ItemsData, GetApiV1ItemsErrors, GetApiV1ItemsResponses, GetApiV1PodcastsByPodcastIdAudioData, GetApiV1PodcastsByPodcastIdAudioErrors, GetApiV1PodcastsByPodcastIdAudioResponses, GetApiV1PodcastsByPodcastIdData, GetApiV1PodcastsByPodcastIdErrors, GetApiV1PodcastsByPodcastIdResponses, GetApiV1PodcastsCourseByCourseIdData, GetApiV1PodcastsCourseByCourseIdErrors, GetApiV1PodcastsCourseByCourseIdResponses, GetApiV1QuizSessionsByIdData, GetApiV1QuizSessionsByIdErrors, GetApiV1QuizSessionsByIdResponses, GetApiV1UsersByUserIdData, GetApiV1UsersByUserIdErrors, GetApiV1UsersByUserIdResponses, GetApiV1UsersData, GetApiV1UsersErrors, GetApiV1UsersMeData, GetApiV1UsersMeResponses, GetApiV1UsersResponses, GetApiV1UtilsHealthCheckData, GetApiV1UtilsHealthCheckResponses, PatchApiV1UsersByUserIdData, PatchApiV1UsersByUserIdErrors, PatchApiV1UsersByUserIdResponses, PatchApiV1UsersMeData, PatchApiV1UsersMeErrors, PatchApiV1UsersMePasswordData, PatchApiV1UsersMePasswordErrors, PatchApiV1UsersMePasswordResponses, PatchApiV1UsersMeResponses, PostApiV1ChatByCourseIdStreamData, PostApiV1ChatByCourseIdStreamErrors, PostApiV1ChatByCourseIdStreamResponses, PostApiV1CoursesByCourseIdQuizStartData, PostApiV1CoursesByCourseIdQuizStartErrors, PostApiV1CoursesByCourseIdQuizStartResponses, PostApiV1CoursesData, PostApiV1CoursesErrors, PostApiV1CoursesResponses, PostApiV1DocumentsProcessData, PostApiV1DocumentsProcessErrors, PostApiV1DocumentsProcessResponses, PostApiV1ItemsData, PostApiV1ItemsErrors, PostApiV1ItemsResponses, PostApiV1LoginAccessTokenData, PostApiV1LoginAccessTokenErrors, PostApiV1LoginAccessTokenResponses, PostApiV1LoginTestTokenData, PostApiV1LoginTestTokenResponses, PostApiV1PasswordRecoveryByEmailData, PostApiV1PasswordRecoveryByEmailErrors, PostApiV1PasswordRecoveryByEmailResponses, PostApiV1PasswordRecoveryHtmlContentByEmailData, PostApiV1PasswordRecoveryHtmlContentByEmailErrors, PostApiV1PasswordRecoveryHtmlContentByEmailResponses, PostApiV1PodcastsCourseByCourseIdGenerateData, PostApiV1PodcastsCourseByCourseIdGenerateErrors, PostApiV1PodcastsCourseByCourseIdGenerateResponses, PostApiV1PrivateUsersData, PostApiV1PrivateUsersErrors, PostApiV1PrivateUsersResponses, PostApiV1QuizSessionsByIdScoreData, PostApiV1QuizSessionsByIdScoreErrors, PostApiV1QuizSessionsByIdScoreResponses, PostApiV1ResetPasswordData, PostApiV1ResetPasswordErrors, PostApiV1ResetPasswordResponses, PostApiV1UsersData, PostApiV1UsersErrors, PostApiV1UsersResponses, PostApiV1UsersSignupData, PostApiV1UsersSignupErrors, PostApiV1UsersSignupResponses, PostApiV1UtilsTestEmailData, PostApiV1UtilsTestEmailErrors, PostApiV1UtilsTestEmailResponses, PutApiV1CoursesByIdData, PutApiV1CoursesByIdErrors, PutApiV1CoursesByIdResponses, PutApiV1ItemsByIdData, PutApiV1ItemsByIdErrors, PutApiV1ItemsByIdResponses } from './types.gen'; +import { zDeleteApiV1CoursesByIdData, zDeleteApiV1CoursesByIdResponse, zDeleteApiV1DocumentsByIdData, zDeleteApiV1DocumentsByIdResponse, zDeleteApiV1ItemsByIdData, zDeleteApiV1ItemsByIdResponse, zDeleteApiV1PodcastsByPodcastIdData, zDeleteApiV1PodcastsByPodcastIdResponse, zDeleteApiV1UsersByUserIdData, zDeleteApiV1UsersByUserIdResponse, zDeleteApiV1UsersMeData, zDeleteApiV1UsersMeResponse, zGetApiV1ChatByCourseIdHistoryData, zGetApiV1ChatByCourseIdHistoryResponse, zGetApiV1CoursesByCourseIdStatsData, zGetApiV1CoursesByCourseIdStatsResponse, zGetApiV1CoursesByIdAttemptsData, zGetApiV1CoursesByIdAttemptsResponse, zGetApiV1CoursesByIdData, zGetApiV1CoursesByIdDocumentsData, zGetApiV1CoursesByIdDocumentsResponse, zGetApiV1CoursesByIdFlashcardsData, zGetApiV1CoursesByIdFlashcardsResponse, zGetApiV1CoursesByIdQuizzesData, zGetApiV1CoursesByIdQuizzesResponse, zGetApiV1CoursesByIdResponse, zGetApiV1CoursesData, zGetApiV1CoursesResponse, zGetApiV1DocumentsByIdData, zGetApiV1DocumentsByIdResponse, zGetApiV1ItemsByIdData, zGetApiV1ItemsByIdResponse, zGetApiV1ItemsData, zGetApiV1ItemsResponse, zGetApiV1PodcastsByPodcastIdAudioData, zGetApiV1PodcastsByPodcastIdData, zGetApiV1PodcastsByPodcastIdResponse, zGetApiV1PodcastsCourseByCourseIdData, zGetApiV1PodcastsCourseByCourseIdResponse, zGetApiV1QuizSessionsByIdData, zGetApiV1QuizSessionsByIdResponse, zGetApiV1UsersByUserIdData, zGetApiV1UsersByUserIdResponse, zGetApiV1UsersData, zGetApiV1UsersMeData, zGetApiV1UsersMeResponse, zGetApiV1UsersResponse, zGetApiV1UtilsHealthCheckData, zGetApiV1UtilsHealthCheckResponse, zPatchApiV1UsersByUserIdData, zPatchApiV1UsersByUserIdResponse, zPatchApiV1UsersMeData, zPatchApiV1UsersMePasswordData, zPatchApiV1UsersMePasswordResponse, zPatchApiV1UsersMeResponse, zPostApiV1ChatByCourseIdStreamData, zPostApiV1CoursesByCourseIdQuizStartData, zPostApiV1CoursesByCourseIdQuizStartResponse, zPostApiV1CoursesData, zPostApiV1CoursesResponse, zPostApiV1DocumentsProcessData, zPostApiV1ItemsData, zPostApiV1ItemsResponse, zPostApiV1LoginAccessTokenData, zPostApiV1LoginAccessTokenResponse, zPostApiV1LoginTestTokenData, zPostApiV1LoginTestTokenResponse, zPostApiV1PasswordRecoveryByEmailData, zPostApiV1PasswordRecoveryByEmailResponse, zPostApiV1PasswordRecoveryHtmlContentByEmailData, zPostApiV1PasswordRecoveryHtmlContentByEmailResponse, zPostApiV1PodcastsCourseByCourseIdGenerateData, zPostApiV1PodcastsCourseByCourseIdGenerateResponse, zPostApiV1PrivateUsersData, zPostApiV1PrivateUsersResponse, zPostApiV1QuizSessionsByIdScoreData, zPostApiV1QuizSessionsByIdScoreResponse, zPostApiV1ResetPasswordData, zPostApiV1ResetPasswordResponse, zPostApiV1UsersData, zPostApiV1UsersResponse, zPostApiV1UsersSignupData, zPostApiV1UsersSignupResponse, zPostApiV1UtilsTestEmailData, zPostApiV1UtilsTestEmailResponse, zPutApiV1CoursesByIdData, zPutApiV1CoursesByIdResponse, zPutApiV1ItemsByIdData, zPutApiV1ItemsByIdResponse } from './zod.gen'; export type Options = Options2 & { /** @@ -809,8 +809,7 @@ export class CoursesService { /** * Generate Flashcards By Course Id - * Generate flashcards for a specific course by retrieving relevant chunks and - * using an LLM to structure the content into Q&A items. + * Generate flashcards for the most recent document associated with a course. */ public static getApiV1CoursesByIdFlashcards(options: Options) { return (options.client ?? client).get({ @@ -914,6 +913,9 @@ export class DocumentsService { return await zDeleteApiV1DocumentsByIdData.parseAsync(data); }, responseType: 'json', + responseValidator: async (data) => { + return await zDeleteApiV1DocumentsByIdResponse.parseAsync(data); + }, security: [ { scheme: 'bearer', @@ -950,6 +952,124 @@ export class DocumentsService { } } +export class PodcastsService { + /** + * List Podcasts + */ + public static getApiV1PodcastsCourseByCourseId(options: Options) { + return (options.client ?? client).get({ + requestValidator: async (data) => { + return await zGetApiV1PodcastsCourseByCourseIdData.parseAsync(data); + }, + responseType: 'json', + responseValidator: async (data) => { + return await zGetApiV1PodcastsCourseByCourseIdResponse.parseAsync(data); + }, + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/podcasts/course/{course_id}', + ...options + }); + } + + /** + * Generate Podcast + */ + public static postApiV1PodcastsCourseByCourseIdGenerate(options: Options) { + return (options.client ?? client).post({ + requestValidator: async (data) => { + return await zPostApiV1PodcastsCourseByCourseIdGenerateData.parseAsync(data); + }, + responseType: 'json', + responseValidator: async (data) => { + return await zPostApiV1PodcastsCourseByCourseIdGenerateResponse.parseAsync(data); + }, + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/podcasts/course/{course_id}/generate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + } + + /** + * Delete Podcast + */ + public static deleteApiV1PodcastsByPodcastId(options: Options) { + return (options.client ?? client).delete({ + requestValidator: async (data) => { + return await zDeleteApiV1PodcastsByPodcastIdData.parseAsync(data); + }, + responseType: 'json', + responseValidator: async (data) => { + return await zDeleteApiV1PodcastsByPodcastIdResponse.parseAsync(data); + }, + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/podcasts/{podcast_id}', + ...options + }); + } + + /** + * Get Podcast + */ + public static getApiV1PodcastsByPodcastId(options: Options) { + return (options.client ?? client).get({ + requestValidator: async (data) => { + return await zGetApiV1PodcastsByPodcastIdData.parseAsync(data); + }, + responseType: 'json', + responseValidator: async (data) => { + return await zGetApiV1PodcastsByPodcastIdResponse.parseAsync(data); + }, + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/podcasts/{podcast_id}', + ...options + }); + } + + /** + * Stream Audio + */ + public static getApiV1PodcastsByPodcastIdAudio(options: Options) { + return (options.client ?? client).get({ + requestValidator: async (data) => { + return await zGetApiV1PodcastsByPodcastIdAudioData.parseAsync(data); + }, + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/podcasts/{podcast_id}/audio', + ...options + }); + } +} + export class QuizSessionsService { /** * Get Quiz Session Optimized diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 57ce0d2..2d7c938 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -64,6 +64,7 @@ export type ChatMessage = { /** * ChatPublic + * Public schema for Chat message entries (no quiz fields). */ export type ChatPublic = { /** @@ -75,21 +76,13 @@ export type ChatPublic = { */ course_id: string; /** - * Total Submitted - */ - total_submitted: number; - /** - * Total Correct - */ - total_correct: number; - /** - * Score Percentage + * Message */ - score_percentage?: number | null; + message?: string | null; /** - * Is Completed + * Is System */ - is_completed: boolean; + is_system: boolean; /** * Created At */ @@ -98,14 +91,6 @@ export type ChatPublic = { * Updated At */ updated_at: string; - /** - * Message - */ - message: string; - /** - * Is System - */ - is_system: boolean; }; /** @@ -254,72 +239,74 @@ export type CoursesPublic = { export type DifficultyLevel = 'easy' | 'medium' | 'hard' | 'expert' | 'all'; /** - * Document + * DocumentPublic */ -export type Document = { - /** - * Title - */ - title: string; +export type DocumentPublic = { /** * Id */ - id?: string; - /** - * Chunk Count - */ - chunk_count?: number | null; + id: string; /** * Course Id */ course_id: string; /** - * Embedding Namespace + * Title */ - embedding_namespace?: string | null; + title: string; /** * Filename */ filename: string; - status?: DocumentStatus; /** - * Created At + * Updated At */ - created_at?: string; + updated_at: string; /** - * Updated At + * Created At */ - updated_at?: string; + created_at: string; + status: DocumentStatus; }; /** - * DocumentPublic + * DocumentStatus */ -export type DocumentPublic = { +export type DocumentStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +/** + * GeneratePodcastRequest + * Request body for generating a podcast for a course. + * + */ +export type GeneratePodcastRequest = { /** - * Id + * Title */ - id: string; + title: string; + mode?: ModeEnum; /** - * Course Id + * Topics */ - course_id: string; + topics?: string | null; /** - * Updated At + * Teacher Voice */ - updated_at: string; + teacher_voice?: string | null; /** - * Created At + * Student Voice */ - created_at: string; - status: DocumentStatus; + student_voice?: string | null; + /** + * Narrator Voice + */ + narrator_voice?: string | null; + /** + * Document Ids + */ + document_ids?: Array | null; }; -/** - * DocumentStatus - */ -export type DocumentStatus = 'pending' | 'processing' | 'completed' | 'failed'; - /** * HTTPValidationError */ @@ -426,6 +413,11 @@ export type Message = { message: string; }; +/** + * ModeEnum + */ +export type ModeEnum = 'dialogue' | 'presentation'; + /** * NewPassword */ @@ -440,6 +432,58 @@ export type NewPassword = { new_password: string; }; +/** + * PodcastPublic + */ +export type PodcastPublic = { + /** + * Id + */ + id: string; + /** + * Course Id + */ + course_id: string; + /** + * Title + */ + title: string; + /** + * Transcript + */ + transcript: string; + /** + * Audio Path + */ + audio_path: string; + /** + * Storage Backend + */ + storage_backend: string; + /** + * Duration Seconds + */ + duration_seconds?: number | null; + /** + * Created At + */ + created_at: string; + /** + * Updated At + */ + updated_at: string; +}; + +/** + * PodcastsPublic + */ +export type PodcastsPublic = { + /** + * Data + */ + data: Array; +}; + /** * PrivateUserCreate */ @@ -1685,9 +1729,7 @@ export type GetApiV1CoursesByIdDocumentsResponses = { * Response Courses-List Documents * Successful Response */ - 200: Array<{ - [key: string]: unknown; - }>; + 200: Array; }; export type GetApiV1CoursesByIdDocumentsResponse = GetApiV1CoursesByIdDocumentsResponses[keyof GetApiV1CoursesByIdDocumentsResponses]; @@ -2008,12 +2050,13 @@ export type DeleteApiV1DocumentsByIdError = DeleteApiV1DocumentsByIdErrors[keyof export type DeleteApiV1DocumentsByIdResponses = { /** - * Response Documents-Delete Document * Successful Response */ - 200: unknown; + 200: Message; }; +export type DeleteApiV1DocumentsByIdResponse = DeleteApiV1DocumentsByIdResponses[keyof DeleteApiV1DocumentsByIdResponses]; + export type GetApiV1DocumentsByIdData = { body?: never; path: { @@ -2039,11 +2082,162 @@ export type GetApiV1DocumentsByIdResponses = { /** * Successful Response */ - 200: Document; + 200: DocumentPublic; }; export type GetApiV1DocumentsByIdResponse = GetApiV1DocumentsByIdResponses[keyof GetApiV1DocumentsByIdResponses]; +export type GetApiV1PodcastsCourseByCourseIdData = { + body?: never; + path: { + /** + * Course Id + */ + course_id: string; + }; + query?: never; + url: '/api/v1/podcasts/course/{course_id}'; +}; + +export type GetApiV1PodcastsCourseByCourseIdErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetApiV1PodcastsCourseByCourseIdError = GetApiV1PodcastsCourseByCourseIdErrors[keyof GetApiV1PodcastsCourseByCourseIdErrors]; + +export type GetApiV1PodcastsCourseByCourseIdResponses = { + /** + * Successful Response + */ + 200: PodcastsPublic; +}; + +export type GetApiV1PodcastsCourseByCourseIdResponse = GetApiV1PodcastsCourseByCourseIdResponses[keyof GetApiV1PodcastsCourseByCourseIdResponses]; + +export type PostApiV1PodcastsCourseByCourseIdGenerateData = { + body: GeneratePodcastRequest; + path: { + /** + * Course Id + */ + course_id: string; + }; + query?: never; + url: '/api/v1/podcasts/course/{course_id}/generate'; +}; + +export type PostApiV1PodcastsCourseByCourseIdGenerateErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PostApiV1PodcastsCourseByCourseIdGenerateError = PostApiV1PodcastsCourseByCourseIdGenerateErrors[keyof PostApiV1PodcastsCourseByCourseIdGenerateErrors]; + +export type PostApiV1PodcastsCourseByCourseIdGenerateResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type PostApiV1PodcastsCourseByCourseIdGenerateResponse = PostApiV1PodcastsCourseByCourseIdGenerateResponses[keyof PostApiV1PodcastsCourseByCourseIdGenerateResponses]; + +export type DeleteApiV1PodcastsByPodcastIdData = { + body?: never; + path: { + /** + * Podcast Id + */ + podcast_id: string; + }; + query?: never; + url: '/api/v1/podcasts/{podcast_id}'; +}; + +export type DeleteApiV1PodcastsByPodcastIdErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteApiV1PodcastsByPodcastIdError = DeleteApiV1PodcastsByPodcastIdErrors[keyof DeleteApiV1PodcastsByPodcastIdErrors]; + +export type DeleteApiV1PodcastsByPodcastIdResponses = { + /** + * Response Podcasts-Delete Podcast + * Successful Response + */ + 200: { + [key: string]: string; + }; +}; + +export type DeleteApiV1PodcastsByPodcastIdResponse = DeleteApiV1PodcastsByPodcastIdResponses[keyof DeleteApiV1PodcastsByPodcastIdResponses]; + +export type GetApiV1PodcastsByPodcastIdData = { + body?: never; + path: { + /** + * Podcast Id + */ + podcast_id: string; + }; + query?: never; + url: '/api/v1/podcasts/{podcast_id}'; +}; + +export type GetApiV1PodcastsByPodcastIdErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetApiV1PodcastsByPodcastIdError = GetApiV1PodcastsByPodcastIdErrors[keyof GetApiV1PodcastsByPodcastIdErrors]; + +export type GetApiV1PodcastsByPodcastIdResponses = { + /** + * Successful Response + */ + 200: PodcastPublic; +}; + +export type GetApiV1PodcastsByPodcastIdResponse = GetApiV1PodcastsByPodcastIdResponses[keyof GetApiV1PodcastsByPodcastIdResponses]; + +export type GetApiV1PodcastsByPodcastIdAudioData = { + body?: never; + path: { + /** + * Podcast Id + */ + podcast_id: string; + }; + query?: never; + url: '/api/v1/podcasts/{podcast_id}/audio'; +}; + +export type GetApiV1PodcastsByPodcastIdAudioErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetApiV1PodcastsByPodcastIdAudioError = GetApiV1PodcastsByPodcastIdAudioErrors[keyof GetApiV1PodcastsByPodcastIdAudioErrors]; + +export type GetApiV1PodcastsByPodcastIdAudioResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type GetApiV1QuizSessionsByIdData = { body?: never; path: { diff --git a/frontend/src/client/zod.gen.ts b/frontend/src/client/zod.gen.ts index 2fe39f9..9fe37a0 100644 --- a/frontend/src/client/zod.gen.ts +++ b/frontend/src/client/zod.gen.ts @@ -41,21 +41,18 @@ export const zChatMessage = z.object({ /** * ChatPublic + * Public schema for Chat message entries (no quiz fields). */ export const zChatPublic = z.object({ id: z.uuid(), course_id: z.uuid(), - total_submitted: z.int(), - total_correct: z.int(), - score_percentage: z.optional(z.union([ - z.number(), + message: z.optional(z.union([ + z.string(), z.null() ])), - is_completed: z.boolean(), + is_system: z.boolean(), created_at: z.iso.datetime(), - updated_at: z.iso.datetime(), - message: z.string(), - is_system: z.boolean() + updated_at: z.iso.datetime() }); /** @@ -100,6 +97,8 @@ export const zDocumentStatus = z.enum([ export const zDocumentPublic = z.object({ id: z.uuid(), course_id: z.uuid(), + title: z.string(), + filename: z.string(), updated_at: z.iso.datetime(), created_at: z.iso.datetime(), status: zDocumentStatus @@ -171,24 +170,41 @@ export const zDifficultyLevel = z.enum([ ]); /** - * Document + * ModeEnum */ -export const zDocument = z.object({ - title: z.string().min(1).max(255), - id: z.optional(z.uuid()), - chunk_count: z.optional(z.union([ - z.int(), +export const zModeEnum = z.enum([ + 'dialogue', + 'presentation' +]); + +/** + * GeneratePodcastRequest + * Request body for generating a podcast for a course. + * + */ +export const zGeneratePodcastRequest = z.object({ + title: z.string(), + mode: z.optional(zModeEnum), + topics: z.optional(z.union([ + z.string(), z.null() ])), - course_id: z.uuid(), - embedding_namespace: z.optional(z.union([ + teacher_voice: z.optional(z.union([ z.string(), z.null() ])), - filename: z.string(), - status: z.optional(zDocumentStatus), - created_at: z.optional(z.iso.datetime()), - updated_at: z.optional(z.iso.datetime()) + student_voice: z.optional(z.union([ + z.string(), + z.null() + ])), + narrator_voice: z.optional(z.union([ + z.string(), + z.null() + ])), + document_ids: z.optional(z.union([ + z.array(z.uuid()), + z.null() + ])) }); /** @@ -284,6 +300,31 @@ export const zNewPassword = z.object({ new_password: z.string().min(8).max(40) }); +/** + * PodcastPublic + */ +export const zPodcastPublic = z.object({ + id: z.uuid(), + course_id: z.uuid(), + title: z.string(), + transcript: z.string(), + audio_path: z.string(), + storage_backend: z.string(), + duration_seconds: z.optional(z.union([ + z.number(), + z.null() + ])), + created_at: z.iso.datetime(), + updated_at: z.iso.datetime() +}); + +/** + * PodcastsPublic + */ +export const zPodcastsPublic = z.object({ + data: z.array(zPodcastPublic) +}); + /** * PrivateUserCreate */ @@ -882,7 +923,7 @@ export const zGetApiV1CoursesByIdDocumentsData = z.object({ * Response Courses-List Documents * Successful Response */ -export const zGetApiV1CoursesByIdDocumentsResponse = z.array(z.record(z.string(), z.unknown())); +export const zGetApiV1CoursesByIdDocumentsResponse = z.array(zDocumentPublic); export const zGetApiV1CoursesByIdQuizzesData = z.object({ body: z.optional(z.never()), @@ -1017,6 +1058,11 @@ export const zDeleteApiV1DocumentsByIdData = z.object({ query: z.optional(z.never()) }); +/** + * Successful Response + */ +export const zDeleteApiV1DocumentsByIdResponse = zMessage; + export const zGetApiV1DocumentsByIdData = z.object({ body: z.optional(z.never()), path: z.object({ @@ -1028,7 +1074,68 @@ export const zGetApiV1DocumentsByIdData = z.object({ /** * Successful Response */ -export const zGetApiV1DocumentsByIdResponse = zDocument; +export const zGetApiV1DocumentsByIdResponse = zDocumentPublic; + +export const zGetApiV1PodcastsCourseByCourseIdData = z.object({ + body: z.optional(z.never()), + path: z.object({ + course_id: z.uuid() + }), + query: z.optional(z.never()) +}); + +/** + * Successful Response + */ +export const zGetApiV1PodcastsCourseByCourseIdResponse = zPodcastsPublic; + +export const zPostApiV1PodcastsCourseByCourseIdGenerateData = z.object({ + body: zGeneratePodcastRequest, + path: z.object({ + course_id: z.uuid() + }), + query: z.optional(z.never()) +}); + +/** + * Successful Response + */ +export const zPostApiV1PodcastsCourseByCourseIdGenerateResponse = zPodcastPublic; + +export const zDeleteApiV1PodcastsByPodcastIdData = z.object({ + body: z.optional(z.never()), + path: z.object({ + podcast_id: z.uuid() + }), + query: z.optional(z.never()) +}); + +/** + * Response Podcasts-Delete Podcast + * Successful Response + */ +export const zDeleteApiV1PodcastsByPodcastIdResponse = z.record(z.string(), z.string()); + +export const zGetApiV1PodcastsByPodcastIdData = z.object({ + body: z.optional(z.never()), + path: z.object({ + podcast_id: z.uuid() + }), + query: z.optional(z.never()) +}); + +/** + * Successful Response + */ +export const zGetApiV1PodcastsByPodcastIdResponse = zPodcastPublic; + +export const zGetApiV1PodcastsByPodcastIdAudioData = z.object({ + body: z.optional(z.never()), + path: z.object({ + podcast_id: z.uuid() + }), + query: z.optional(z.never()) +}); export const zGetApiV1QuizSessionsByIdData = z.object({ body: z.optional(z.never()), diff --git a/frontend/src/components/create-course/upload-documents.tsx b/frontend/src/components/create-course/upload-documents.tsx index 527502a..d97227d 100644 --- a/frontend/src/components/create-course/upload-documents.tsx +++ b/frontend/src/components/create-course/upload-documents.tsx @@ -8,7 +8,7 @@ import {Button} from '@/components/ui/button' import {Separator} from '@/components/ui/separator' import {CourseWithDocuments} from '@/client' import FileCard from '@/components/ui/file-card' -import {getCourse} from '@/lib/courses' +import {getCourse} from '@/actions/courses' import UploadComponent from '@/components/upload-component' import { mapApiError } from '@/lib/mapApiError' import { toast } from 'sonner' diff --git a/frontend/src/components/podcast.tsx b/frontend/src/components/podcast.tsx new file mode 100644 index 0000000..a6e52f1 --- /dev/null +++ b/frontend/src/components/podcast.tsx @@ -0,0 +1,391 @@ +"use client" + +import { useEffect, useRef, useState, useTransition } from 'react' +import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Trash2 } from 'lucide-react' +import { getDocumentsByCourse } from '@/actions/documents' +import { getPodcasts, generatePodcast, deletePodcast } from '@/actions/podcasts' +import type { ModeEnum } from '@/client/types.gen' + +type Podcast = { + id: string + course_id: string + title: string + transcript: string + audio_path: string + storage_backend: string +} + +function formatTime(seconds: number): string { + if (!isFinite(seconds)) return '0:00' + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${m}:${s.toString().padStart(2, '0')}` +} + +function PodcastPlayer({ podcastId, title, transcript, onDeleted }: { podcastId: string; title: string; transcript: string; onDeleted: () => void }) { + const audioRef = useRef(null) + const makeAudioUrl = (id: string) => `/api/v1/podcasts/audio/${id}` + const [src, setSrc] = useState(makeAudioUrl(podcastId)) + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolume] = useState(1) + const [muted, setMuted] = useState(false) + const [resolving, setResolving] = useState(false) + + // Resolve S3 presigned URL if backend returns JSON + async function resolveAudioSrc() { + try { + setResolving(true) + const res = await fetch(makeAudioUrl(podcastId), { + // Encourage JSON path for S3 presign; local stream will ignore this + headers: { Accept: 'application/json' }, + cache: 'no-store', + }) + const contentType = res.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + const data = await res.json().catch(() => ({} as any)) + if (data?.url) setSrc(data.url) + } else { + // Fallback to route-streaming + setSrc(makeAudioUrl(podcastId)) + } + } catch { + setSrc(makeAudioUrl(podcastId)) + } finally { + setResolving(false) + } + } + + useEffect(() => { + const a = audioRef.current + if (!a) return + const onTime = () => setCurrentTime(a.currentTime) + const onLoaded = () => setDuration(a.duration || 0) + const onEnded = () => setIsPlaying(false) + a.addEventListener('timeupdate', onTime) + a.addEventListener('loadedmetadata', onLoaded) + a.addEventListener('ended', onEnded) + return () => { + a.removeEventListener('timeupdate', onTime) + a.removeEventListener('loadedmetadata', onLoaded) + a.removeEventListener('ended', onEnded) + } + }, []) + + const togglePlay = async () => { + if (!audioRef.current) return + if (!src || src.endsWith(`/api/v1/podcasts/audio/${podcastId}`)) { + // Attempt to resolve S3 URL just-in-time + await resolveAudioSrc() + } + if (isPlaying) { + audioRef.current.pause() + setIsPlaying(false) + } else { + try { + await audioRef.current.play() + setIsPlaying(true) + } catch { + // try once more after resolution + await resolveAudioSrc() + await audioRef.current.play() + setIsPlaying(true) + } + } + } + + const seek = (value: number) => { + if (!audioRef.current) return + audioRef.current.currentTime = value + setCurrentTime(value) + } + + const step = (delta: number) => { + if (!audioRef.current) return + const next = Math.max(0, Math.min((audioRef.current.duration || 0), audioRef.current.currentTime + delta)) + audioRef.current.currentTime = next + setCurrentTime(next) + } + + const toggleMute = () => { + if (!audioRef.current) return + audioRef.current.muted = !audioRef.current.muted + setMuted(audioRef.current.muted) + } + + const changeVolume = (v: number) => { + if (!audioRef.current) return + audioRef.current.volume = v + setVolume(v) + if (v > 0 && audioRef.current.muted) { + audioRef.current.muted = false + setMuted(false) + } + } + + return ( +
+
+
+
{title}
+
+
+ + + + +
+
+ + changeVolume(parseFloat(e.target.value))} + className="w-full accent-cyan-600" + /> +
+
+ +
+ {formatTime(currentTime)} + seek(parseFloat(e.target.value))} + className="w-full accent-cyan-600" + /> + {formatTime(duration)} +
+ +
+ Transcript +
{transcript}
+
+ + {/* hidden audio element */} +
+ ) +} + +type DocItem = { id: string; title?: string; filename?: string; status?: string } + +export default function PodcastComponent({ courseId }: { courseId: string }) { + const [podcasts, setPodcasts] = useState([]) + const [title, setTitle] = useState('') + const [isPending, startTransition] = useTransition() + const [teacherVoice, setTeacherVoice] = useState('coral') + const [studentVoice, setStudentVoice] = useState('alloy') + const [mode, setMode] = useState('dialogue') + const [topics, setTopics] = useState('') + const [documents, setDocuments] = useState([]) + const [selectedDocs, setSelectedDocs] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function fetchList() { + try { + setLoading(true) + const result = await getPodcasts(courseId) + if (result.ok) { + setPodcasts(result.data.data ?? []) + setError(null) + } else { + setError(result.error.message || 'Failed to fetch podcasts') + } + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchList() + ;(async () => { + try { + const result = await getDocumentsByCourse(courseId) + if (result.ok) { + setDocuments(Array.isArray(result.data) ? result.data : []) + } else { + setDocuments([]) + } + } catch { + setDocuments([]) + } + })() + }, [courseId]) + + function handleGenerate() { + startTransition(async () => { + setError(null) + try { + if (!title.trim()) { + setError('Title is required') + return + } + const result = await generatePodcast(courseId, { + title, + mode, + topics: topics || undefined, + teacher_voice: mode === 'dialogue' ? teacherVoice : undefined, + student_voice: mode === 'dialogue' ? studentVoice : undefined, + narrator_voice: mode === 'presentation' ? teacherVoice : undefined, + document_ids: selectedDocs.length ? selectedDocs : undefined, + }) + + if (result.ok) { + await fetchList() + // Clear form fields after successful generation + setTitle('') + setTopics('') + setSelectedDocs([]) + } else { + setError(result.error.message || 'Failed to generate podcast') + } + } catch (e) { + setError((e as Error).message) + } + }) + } + + return ( +
+
+ setTitle(e.target.value)} + placeholder="Podcast title" + /> + + {mode === 'dialogue' ? ( + <> + + + + ) : ( + + )} + +
+
+ +