diff --git a/.env.example b/.env.example index fc19d62..40cb0fc 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ REDIS_DECODE_RESPONSES=true # Security SECRET_KEY=change-me-in-production-use-strong-random-key +# Fernet encryption key for OAuth tokens (must be 32+ characters) +ENCRYPTION_KEY=change-me-must-be-32-chars-padded! ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 @@ -40,6 +42,8 @@ YOUTUBE_REDIRECT_URI=http://localhost:8000/api/v1/youtube/auth/callback # Rate Limiting RATE_LIMIT_PER_MINUTE=60 RATE_LIMIT_BURST=10 +# Toggle rate limiting on/off +RATE_LIMIT_ENABLED=true # Quota Limits DEFAULT_DAILY_ANSWER_LIMIT=100 @@ -51,6 +55,13 @@ QUEUE_CLASSIFICATION=classification QUEUE_EMBEDDING=embedding QUEUE_CLUSTERING=clustering QUEUE_ANSWER_GENERATION=answer_generation +QUEUE_YOUTUBE_POSTING=youtube_posting + +# Worker Thresholds +# Minimum confidence to forward a classified question to embedding +CLASSIFICATION_CONFIDENCE_THRESHOLD=0.4 +# Minimum cosine similarity to join an existing cluster +CLUSTERING_SIMILARITY_THRESHOLD=0.65 # Logging LOG_LEVEL=INFO @@ -68,5 +79,27 @@ WEBSOCKET_TIMEOUT=300 WORKERS=classification,embeddings,clustering,answer_generation,trigger_monitor GEMINI_API_KEY=your-gemini-api-key +# Gemini model for classification, answers, and summarization +GEMINI_MODEL=gemini-2.5-flash +# Gemini model for generating text embeddings +GEMINI_EMBEDDING_MODEL=gemini-embedding-001 +# Number of questions needed before triggering cluster answer generation +CLUSTERING_THRESHOLD=5 + +# Mock / Testing +# Enable mock YouTube polling (no real API calls) +MOCK_YOUTUBE=false +# Seconds between mock YouTube messages +MOCK_MESSAGE_INTERVAL=2.0 + +# Frontend +# Absolute path to frontend/dist directory for static file serving +FRONTEND_DIR= + +# Prometheus +PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc -FRONTEND_DIR= \ No newline at end of file +# Grafana Cloud (Prometheus remote_write) +GCLOUD_HOSTED_METRICS_URL= +GCLOUD_HOSTED_METRICS_ID= +GCLOUD_RW_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index f134569..c45a475 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,8 @@ logs/ *.seed tmp/ *.bak + +CLAUDE.md +.claude + +data-alloy \ No newline at end of file diff --git a/Makefile b/Makefile index 0178af1..2c8636a 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ help: @echo " make clean - Clean generated files" run-backend: - cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + cd backend && PYTHONPATH=$(CURDIR) uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 run-workers: python -m workers.runner @@ -33,8 +33,11 @@ format: lint: ruff check backend workers scripts - flake8 backend workers scripts --max-line-length=119 --ignore=D107,D212,E501,W503,W605,D203,D100 - pylint backend workers scripts --disable=line-too-long,trailing-whitespace,missing-function-docstring,consider-using-f-string,import-error,too-few-public-methods,redefined-outer-name + flake8 backend workers scripts --max-line-length=119 --ignore=D107,D212,E501,W503,W605,D203,D100 \ + --per-file-ignores="backend/alembic/*:E402,F401 backend/app/main.py:E402,F824 backend/app/db/models/migrations/*:W391 workers/*/worker.py:E402,F824 workers/*/mock_worker.py:E402,F824 workers/runner.py:E402 scripts/*:E402,E226" + pylint backend workers scripts \ + --ignore-paths="backend/alembic/versions/" \ + --disable=line-too-long,trailing-whitespace,missing-function-docstring,missing-module-docstring,missing-class-docstring,consider-using-f-string,import-error,too-few-public-methods,redefined-outer-name,wrong-import-position,wrong-import-order,ungrouped-imports,invalid-name,logging-fstring-interpolation,global-statement,global-variable-not-assigned,unnecessary-pass,fixme,pointless-string-statement,broad-exception-caught,duplicate-code,too-many-locals,too-many-arguments,too-many-branches,too-many-statements,too-many-nested-blocks,too-many-instance-attributes,unused-argument,unused-import,unused-variable,no-member,import-outside-toplevel,raise-missing-from,not-callable,singleton-comparison,no-else-continue,implicit-str-concat,keyword-arg-before-vararg,missing-timeout,subprocess-run-check,protected-access test: pytest backend/tests workers -v diff --git a/README.md b/README.md index 6233a45..13c1cfb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,17 @@ youtube_polling worker ──► Redis queue Comments flow from YouTube → Redis workers → Gemini AI for classification and embedding → pgvector for semantic clustering → answer generation → real-time WebSocket delivery to the teacher dashboard (and optionally back to the stream). +## Features + +- **Real-time question clustering** — student comments are embedded and clustered live using nearest-centroid algorithm with milestone triggers +- **RAG-augmented answers** — AI-generated answers grounded in teacher-uploaded documents (PDF, DOCX, TXT) +- **YouTube integration** — polls live chat, posts answers directly back to YouTube +- **Content moderation** — Gemini-powered filtering before classification and before YouTube posting +- **WebSocket dashboard** — real-time updates with exponential backoff reconnection and 100-message cap +- **Teacher isolation** — every data endpoint enforces ownership; RAG retrieval is scoped per teacher +- **Observability** — Prometheus metrics, circuit breaker pattern on all Gemini calls, structured logging +- **Scheduled maintenance** — automatic daily quota reset and hourly expired token cleanup + ## Quick Start ### Prerequisites @@ -84,28 +95,47 @@ This starts PostgreSQL, Redis, the FastAPI backend, and all workers. The API is cd backend && alembic upgrade head ``` -## Running Without Docker +## Running Without Docker (Native Development) + +**Prerequisites:** +- Python 3.13+ +- Node.js 20+ +- PostgreSQL 15+ with the [pgvector extension](https://github.com/pgvector/pgvector) +- Redis 7+ + +**Steps:** + +1. **Clone and set up environment variables:** +```bash +cp .env.example .env.development +# Fill in your GEMINI_API_KEY, SECRET_KEY, ENCRYPTION_KEY, and YouTube OAuth credentials +``` -**Backend:** +2. **Install backend dependencies:** ```bash -pip install -r backend/requirements.txt -uvicorn backend.app.main:app --reload +cd backend +python -m venv venv && source venv/bin/activate +pip install -r requirements.txt ``` -**Workers:** +3. **Install frontend dependencies:** ```bash -python -m workers.classification.worker -python -m workers.embeddings.worker -python -m workers.clustering.worker -python -m workers.answer_generation.worker -python -m workers.trigger_monitor.worker +cd frontend && npm install ``` -**Chrome extension:** +4. **Run database migrations:** ```bash -cd chrome-extension && npm install && npm run build +make migrate ``` -Load `chrome-extension/dist` as an unpacked extension in Chrome. + +5. **Start all services in one command:** +```bash +./start_dev.sh +``` +This opens a tmux session with 9 panes: backend API, 6 AI workers, scheduler, and the Vite dev server. + +6. **Open the app:** + Visit `http://localhost:5173` ## API @@ -130,6 +160,13 @@ make lint # run linters make test # run tests ``` +## Known Limitations + +- **No production deployment config** — docker-compose is development-oriented; nginx and production Dockerfile are not included +- **Chrome extension** — functional but not published to the Chrome Web Store +- **YouTube quota** — the YouTube Data API v3 has daily quota limits; high-traffic sessions may hit limits +- **Single-region** — no multi-region or horizontal scaling configuration + ## License MIT diff --git a/alloy/config.alloy b/alloy/config.alloy new file mode 100644 index 0000000..dd397f3 --- /dev/null +++ b/alloy/config.alloy @@ -0,0 +1,22 @@ +prometheus.scrape "fastapi" { + targets = [{ + __address__ = "localhost:8000", + }] + metrics_path = "/metrics" + scrape_interval = "15s" + forward_to = [prometheus.remote_write.grafana_cloud.receiver] +} + +prometheus.remote_write "grafana_cloud" { + endpoint { + url = sys.env("GCLOUD_HOSTED_METRICS_URL") + basic_auth { + username = sys.env("GCLOUD_HOSTED_METRICS_ID") + password = sys.env("GCLOUD_RW_API_KEY") + } + } + external_labels = { + job = "ai_doubt_manager", + environment = "development", + } +} diff --git a/backend/alembic/versions/6f04ebe5f0fb_add_hnsw_indexes_comments_rag_documents.py b/backend/alembic/versions/6f04ebe5f0fb_add_hnsw_indexes_comments_rag_documents.py new file mode 100644 index 0000000..3ce0cfe --- /dev/null +++ b/backend/alembic/versions/6f04ebe5f0fb_add_hnsw_indexes_comments_rag_documents.py @@ -0,0 +1,35 @@ +"""add hnsw indexes to comments and rag_documents embedding columns + +Revision ID: 6f04ebe5f0fb +Revises: d4e5f6a7b8c9 +Create Date: 2026-03-15 00:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6f04ebe5f0fb" +down_revision = "d4e5f6a7b8c9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_comments_embedding_hnsw + ON comments + USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64) + """) + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_rag_documents_embedding_hnsw + ON rag_documents + USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_rag_documents_embedding_hnsw") + op.execute("DROP INDEX IF EXISTS idx_comments_embedding_hnsw") diff --git a/backend/alembic/versions/6fe440076f64_recreate_clusters_centroid_hnsw_with_params.py b/backend/alembic/versions/6fe440076f64_recreate_clusters_centroid_hnsw_with_params.py new file mode 100644 index 0000000..a4a2e99 --- /dev/null +++ b/backend/alembic/versions/6fe440076f64_recreate_clusters_centroid_hnsw_with_params.py @@ -0,0 +1,34 @@ +"""recreate clusters centroid hnsw index with tuning params + +Revision ID: 6fe440076f64 +Revises: 6f04ebe5f0fb +Create Date: 2026-03-15 00:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6fe440076f64" +down_revision = "6f04ebe5f0fb" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS clusters_centroid_hnsw_idx") + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_clusters_centroid_embedding_hnsw + ON clusters + USING hnsw (centroid_embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_clusters_centroid_embedding_hnsw") + op.execute(""" + CREATE INDEX IF NOT EXISTS clusters_centroid_hnsw_idx + ON clusters + USING hnsw (centroid_embedding vector_cosine_ops) + """) diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py b/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py new file mode 100644 index 0000000..11a7646 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py @@ -0,0 +1,35 @@ +"""add_hnsw_indexes + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-09 00:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_comments_embedding_hnsw + ON comments + USING hnsw (embedding vector_l2_ops) + WITH (m = 16, ef_construction = 64) + """) + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_rag_documents_embedding_hnsw + ON rag_documents + USING hnsw (embedding vector_l2_ops) + WITH (m = 16, ef_construction = 64) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_rag_documents_embedding_hnsw") + op.execute("DROP INDEX IF EXISTS idx_comments_embedding_hnsw") diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_clusters_centroid_hnsw.py b/backend/alembic/versions/d4e5f6a7b8c9_add_clusters_centroid_hnsw.py new file mode 100644 index 0000000..5a78da2 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_add_clusters_centroid_hnsw.py @@ -0,0 +1,27 @@ +"""add clusters centroid hnsw index for cosine similarity + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-03-12 00:00:00.000000 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE INDEX IF NOT EXISTS clusters_centroid_hnsw_idx + ON clusters + USING hnsw (centroid_embedding vector_cosine_ops) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS clusters_centroid_hnsw_idx") diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 4f1830d..23f7cec 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -1,6 +1,7 @@ """Teacher dashboard API routes.""" import logging +import re from datetime import ( datetime, timezone, @@ -32,6 +33,8 @@ BaseModel, Field, ) +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session router = APIRouter(prefix="/dashboard", tags=["dashboard"]) @@ -70,7 +73,7 @@ async def submit_manual_question( if not session: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") - texts = [t.strip() for t in payload.text.split("\n") if t.strip()] + texts = [re.sub(r"<[^>]+>", "", t).strip() for t in payload.text.split("\n") if t.strip()] created_count = 0 manager = QueueManager() @@ -82,7 +85,11 @@ async def submit_manual_question( text=text, ) db.add(comment) - db.flush() + try: + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Duplicate question submission.") manager.enqueue( QUEUE_CLASSIFICATION, ClassificationPayload( @@ -125,8 +132,18 @@ async def approve_answer( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Answer not found") answer, cluster, session = result - if answer.is_posted: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Already posted") + + rows_updated = db.execute( + update(Answer) + .where(Answer.id == answer_id, Answer.is_posted == False) # noqa: E712 + .values(is_posted=True, posted_at=datetime.now(timezone.utc)) + ).rowcount + + if rows_updated == 0: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already approved") + + db.commit() + db.refresh(answer) yt_token = db.query(YouTubeToken).filter(YouTubeToken.teacher_id == current_user.id).first() @@ -140,16 +157,11 @@ async def approve_answer( ).to_dict(), ) logger.info(f"Enqueued answer {answer_id} for YouTube posting") - else: - answer.is_posted = True - answer.posted_at = datetime.now(timezone.utc) - db.commit() - db.refresh(answer) return answer -@router.post("/answers/{answer_id}/edit", response_model=AnswerResponse) +@router.patch("/answers/{answer_id}", response_model=AnswerResponse) async def edit_answer( answer_id: UUID, payload: AnswerUpdate, @@ -217,3 +229,48 @@ async def get_session_stats( "answers_generated": answers_generated, "answers_posted": answers_posted, } + + +@router.get("/clusters/{cluster_id}/representative") +async def get_representative_question( + cluster_id: UUID, + current_user: Teacher = Depends(get_current_active_user), + db: Session = Depends(get_db), +) -> dict: + """Return the comment whose embedding is closest to the cluster centroid.""" + from sqlalchemy import text as sa_text + + cluster = ( + db.query(Cluster) + .join(StreamingSession, Cluster.session_id == StreamingSession.id) + .filter( + Cluster.id == cluster_id, + StreamingSession.teacher_id == current_user.id, + ) + .first() + ) + if not cluster: + raise HTTPException(status_code=404, detail="Cluster not found") + if cluster.centroid_embedding is None: + raise HTTPException(status_code=404, detail="No centroid available") + + centroid_str = "[" + ",".join(str(v) for v in cluster.centroid_embedding) + "]" + + row = db.execute( + sa_text(""" + SELECT c.id, c.text, + 1 - (c.embedding <=> CAST(:centroid AS vector)) AS similarity + FROM comments c + WHERE c.cluster_id = :cluster_id + AND c.is_question = TRUE + AND c.embedding IS NOT NULL + ORDER BY c.embedding <=> CAST(:centroid AS vector) + LIMIT 1 + """), + {"centroid": centroid_str, "cluster_id": str(cluster_id)}, + ).first() + + if not row: + raise HTTPException(status_code=404, detail="No representative question found") + + return {"comment_id": str(row.id), "text": row.text, "similarity": float(row.similarity)} diff --git a/backend/app/api/v1/metrics.py b/backend/app/api/v1/metrics.py index 080a5b4..9e2fd4c 100644 --- a/backend/app/api/v1/metrics.py +++ b/backend/app/api/v1/metrics.py @@ -2,6 +2,7 @@ from app.core.security import get_current_active_user from app.db.models.answer import Answer +from app.db.models.cluster import Cluster from app.db.models.comment import Comment from app.db.models.streaming_session import StreamingSession from app.db.models.teacher import Teacher @@ -20,13 +21,37 @@ async def get_metrics( current_user: Teacher = Depends(get_current_active_user), db: Session = Depends(get_db), ) -> dict: - """Get system metrics. Requires authentication. + """Get metrics scoped to current teacher. Requires authentication. Returns: Dict with active_sessions, questions_processed, and answers_generated counts. """ + active_sessions = ( + db.query(StreamingSession) + .filter( + StreamingSession.teacher_id == current_user.id, + StreamingSession.is_active.is_(True), + ) + .count() + ) + + questions_processed = ( + db.query(Comment) + .join(StreamingSession, Comment.session_id == StreamingSession.id) + .filter(StreamingSession.teacher_id == current_user.id) + .count() + ) + + answers_generated = ( + db.query(Answer) + .join(Cluster, Answer.cluster_id == Cluster.id) + .join(StreamingSession, Cluster.session_id == StreamingSession.id) + .filter(StreamingSession.teacher_id == current_user.id) + .count() + ) + return { - "active_sessions": db.query(StreamingSession).filter(StreamingSession.is_active.is_(True)).count(), - "questions_processed": db.query(Comment).count(), - "answers_generated": db.query(Answer).count(), + "active_sessions": active_sessions, + "questions_processed": questions_processed, + "answers_generated": answers_generated, } diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index 46aca60..d789f93 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -41,19 +41,31 @@ async def websocket_endpoint( try: raw = await websocket.receive_text() first_msg = json.loads(raw) - except (json.JSONDecodeError, Exception): - await websocket.close(code=4001, reason="Auth message required") + except WebSocketDisconnect: + manager.disconnect(session_id, conn_id) + return + except Exception: + try: + await websocket.close(code=4001, reason="Auth message required") + except Exception: + pass manager.disconnect(session_id, conn_id) return if first_msg.get("type") != "auth" or not first_msg.get("token"): - await websocket.close(code=4001, reason="Auth message required") + try: + await websocket.close(code=4001, reason="Auth message required") + except Exception: + pass manager.disconnect(session_id, conn_id) return payload = verify_token(first_msg["token"]) if not payload: - await websocket.close(code=4001, reason="Invalid token") + try: + await websocket.close(code=4001, reason="Invalid token") + except Exception: + pass manager.disconnect(session_id, conn_id) return @@ -61,7 +73,10 @@ async def websocket_endpoint( try: session_obj = db.query(StreamingSession).filter(StreamingSession.id == session_id).first() if not session_obj or str(session_obj.teacher_id) != payload.get("sub"): - await websocket.close(code=4003, reason="Forbidden") + try: + await websocket.close(code=4003, reason="Forbidden") + except Exception: + pass manager.disconnect(session_id, conn_id) return finally: @@ -103,3 +118,7 @@ async def websocket_endpoint( logger.error(f"WebSocket error: {e}") if conn_id: manager.disconnect(session_id, conn_id) + try: + await websocket.close(code=1011) + except Exception: + pass diff --git a/backend/app/api/v1/youtube.py b/backend/app/api/v1/youtube.py index 6d0105e..ed46673 100644 --- a/backend/app/api/v1/youtube.py +++ b/backend/app/api/v1/youtube.py @@ -105,8 +105,6 @@ async def oauth_callback( ) data = json.loads(state_data_raw) - _redis.delete(f"yt_state:{state}") - _redis.delete(f"yt_state_teacher:{data['teacher_id']}") oauth_service = YouTubeOAuthService() try: @@ -142,6 +140,10 @@ async def oauth_callback( db.commit() logger.info(f"YouTube token stored for teacher {teacher_id}") + # Delete Redis state after successful DB commit — if this fails, keys expire via TTL + _redis.delete(f"yt_state:{state}") + _redis.delete(f"yt_state_teacher:{data['teacher_id']}") + return HTMLResponse(content=_OAUTH_SUCCESS_HTML) @@ -158,9 +160,17 @@ async def refresh_token( detail="No YouTube token found", ) + try: + decrypted_refresh = decrypt_data(token.refresh_token) + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="YouTube token is invalid or corrupted. Please reconnect your YouTube account.", + ) + oauth_service = YouTubeOAuthService() try: - refreshed = oauth_service.refresh_token(decrypt_data(token.refresh_token)) + refreshed = oauth_service.refresh_token(decrypted_refresh) except Exception as e: logger.error(f"Token refresh failed: {e}") raise HTTPException( @@ -219,7 +229,14 @@ async def get_video_info( detail="YouTube not connected", ) - access_token = decrypt_data(token.access_token) + try: + access_token = decrypt_data(token.access_token) + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="YouTube token is invalid or corrupted. Please reconnect your YouTube account.", + ) + client = YouTubeClient(access_token) try: info = client.get_video_info(video_id) @@ -249,7 +266,14 @@ async def validate_video( if not token: return {"valid": False, "is_live": False, "title": ""} - access_token = decrypt_data(token.access_token) + try: + access_token = decrypt_data(token.access_token) + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="YouTube token is invalid or corrupted. Please reconnect your YouTube account.", + ) + client = YouTubeClient(access_token) try: info = client.get_video_info(video_id) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5956279..607ab1a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,6 +16,15 @@ SettingsConfigDict, ) +from workers.common.queue import ( + QUEUE_ANSWER_GENERATION, + QUEUE_CLASSIFICATION, + QUEUE_CLUSTERING, + QUEUE_COMMENT_INGEST, + QUEUE_EMBEDDING, + QUEUE_YOUTUBE_POSTING, +) + class Settings(BaseSettings): """Application settings loaded from environment variables.""" @@ -88,18 +97,35 @@ def validate_encryption_key(cls, v: str) -> str: default_monthly_session_limit: int = 30 # Worker queue names - queue_comment_ingest: str = "comment_ingest" - queue_classification: str = "classification" - queue_embedding: str = "embedding" - queue_clustering: str = "clustering" - queue_answer_generation: str = "answer_generation" + queue_comment_ingest: str = QUEUE_COMMENT_INGEST + queue_classification: str = QUEUE_CLASSIFICATION + queue_embedding: str = QUEUE_EMBEDDING + queue_clustering: str = QUEUE_CLUSTERING + queue_answer_generation: str = QUEUE_ANSWER_GENERATION + queue_youtube_posting: str = QUEUE_YOUTUBE_POSTING + + # Worker thresholds + classification_confidence_threshold: float = 0.4 + clustering_similarity_threshold: float = 0.65 # Gemini AI gemini_api_key: str = Field(default="", description="Gemini API key") + + @field_validator("gemini_api_key") + @classmethod + def validate_gemini_api_key(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("gemini_api_key must be set — Gemini workers cannot function without it") + return v + gemini_model: str = "gemini-2.5-flash" gemini_embedding_model: str = "gemini-embedding-001" clustering_threshold: int = Field(default=5, description="Questions needed to trigger clustering") + # Mock / Testing + mock_youtube: bool = False + mock_message_interval: float = 2.0 + # Logging log_level: str = "INFO" log_json: bool = False diff --git a/backend/app/core/metrics.py b/backend/app/core/metrics.py index 166139e..e6c16eb 100644 --- a/backend/app/core/metrics.py +++ b/backend/app/core/metrics.py @@ -1,15 +1,23 @@ """Prometheus metrics for observability.""" -from app.core.config import settings -from prometheus_client import ( +import os + +# Must be set BEFORE importing prometheus_client for multiprocess mode +os.environ.setdefault("PROMETHEUS_MULTIPROC_DIR", "/tmp/prometheus_multiproc") +os.makedirs("/tmp/prometheus_multiproc", exist_ok=True) + +from app.core.config import settings # noqa: E402 +from prometheus_client import ( # noqa: E402 CONTENT_TYPE_LATEST, + CollectorRegistry, Counter, Gauge, Histogram, generate_latest, + multiprocess, ) -from starlette.requests import Request -from starlette.responses import Response +from starlette.requests import Request # noqa: E402 +from starlette.responses import Response # noqa: E402 http_requests_total = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status"]) @@ -18,7 +26,10 @@ ) websocket_connections_active = Gauge( - "websocket_connections_active", "Number of active WebSocket connections", ["session_id"] + "websocket_connections_active", + "Number of active WebSocket connections", + ["session_id"], + multiprocess_mode="liveall", ) websocket_messages_total = Counter("websocket_messages_total", "Total WebSocket messages", ["type", "direction"]) @@ -31,30 +42,34 @@ redis_operations_total = Counter("redis_operations_total", "Total Redis operations", ["operation"]) -queue_size = Gauge("queue_size", "Number of items in queue", ["queue_name"]) +queue_size = Gauge("queue_size", "Number of items in queue", ["queue_name"], multiprocess_mode="liveall") queue_processed_total = Counter("queue_processed_total", "Total queue items processed", ["queue_name", "status"]) -worker_heartbeat = Gauge("worker_heartbeat", "Worker last heartbeat timestamp", ["worker_name"]) +worker_heartbeat = Gauge( + "worker_heartbeat", "Worker last heartbeat timestamp", ["worker_name"], multiprocess_mode="liveall" +) -quota_usage = Gauge("quota_usage", "Quota usage", ["teacher_id", "quota_type"]) +quota_usage = Gauge("quota_usage", "Quota usage", ["teacher_id", "quota_type"], multiprocess_mode="liveall") -quota_limit = Gauge("quota_limit", "Quota limit", ["teacher_id", "quota_type"]) +quota_limit = Gauge("quota_limit", "Quota limit", ["teacher_id", "quota_type"], multiprocess_mode="liveall") async def metrics_endpoint(request: Request) -> Response: - """Prometheus metrics endpoint. + """Prometheus metrics endpoint using multiprocess collector. Args: request: Incoming request. Returns: - Metrics response. + Metrics response in Prometheus text format. """ if not settings.enable_metrics: return Response("Metrics disabled", status_code=404) - metrics_data = generate_latest() + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + metrics_data = generate_latest(registry) return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST) diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index de35bfc..63f25dd 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -9,6 +9,10 @@ ) from app.core.logging import get_logger +from app.core.metrics import ( + increment_http_requests, + observe_request_duration, +) from fastapi import ( Request, Response, @@ -65,6 +69,9 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: response.headers["X-Request-ID"] = request_id response.headers["X-Process-Time"] = str(process_time) + increment_http_requests(request.method, request.url.path, response.status_code) + observe_request_duration(request.method, request.url.path, process_time) + logger.info( "Request completed", extra={ @@ -80,6 +87,8 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: except Exception as e: process_time = time.time() - start_time + increment_http_requests(request.method, request.url.path, 500) + observe_request_duration(request.method, request.url.path, process_time) logger.error( f"Request failed: {str(e)}", extra={ diff --git a/backend/app/db/models/migrations/__init__.py b/backend/app/db/models/migrations/__init__.py index d4f52dc..fd8058a 100644 --- a/backend/app/db/models/migrations/__init__.py +++ b/backend/app/db/models/migrations/__init__.py @@ -1,2 +1 @@ """Migrations directory.""" - diff --git a/backend/app/main.py b/backend/app/main.py index 14a3eca..8a550cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,11 +7,10 @@ """FastAPI application main entry point.""" import asyncio -import json +import glob import logging import os -import redis.asyncio as aioredis from app.api.v1 import ( answers, auth, @@ -32,7 +31,10 @@ from app.core.middleware import RequestContextMiddleware from app.core.rate_limit_middleware import RateLimitMiddleware from app.services.websocket.manager import manager -from fastapi import FastAPI +from fastapi import ( + FastAPI, + Request, +) from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -76,26 +78,6 @@ logger.info(f"Serving frontend from {settings.frontend_dir} at /app") -async def _relay_redis_events() -> None: - """Subscribe to worker-published Redis pub/sub events and relay via WebSocket.""" - try: - r = aioredis.from_url(settings.redis_url, decode_responses=True) - pubsub = r.pubsub() - await pubsub.psubscribe("ws:session:*") - async for message in pubsub.listen(): - if message["type"] != "pmessage": - continue - channel = message["channel"] # "ws:session:{session_id}" - session_id = channel.split(":")[-1] - try: - event = json.loads(message["data"]) - await manager.broadcast_to_session(session_id, event) - except Exception as e: - logger.error(f"Failed to relay Redis event for session {session_id}: {e}") - except Exception as e: - logger.error(f"Redis relay error: {e}") - - @app.get("/") async def root() -> dict: """Root endpoint.""" @@ -118,11 +100,8 @@ async def health() -> dict: @app.get("/metrics") -async def metrics(): +async def metrics(request: Request): """Prometheus metrics endpoint.""" - from starlette.requests import Request - - request = Request(scope={"type": "http"}) return await metrics_endpoint(request) @@ -133,11 +112,17 @@ async def metrics(): async def startup_event(): """Application startup event.""" global _relay_task + + # Clear stale multiprocess metric files from previous runs + multiproc_dir = os.environ.get("PROMETHEUS_MULTIPROC_DIR", "/tmp/prometheus_multiproc") + for f in glob.glob(os.path.join(multiproc_dir, "*.db")): + os.unlink(f) + logger.info( f"Starting {settings.app_name} v{settings.app_version}", extra={"environment": settings.environment, "debug": settings.debug}, ) - _relay_task = asyncio.create_task(_relay_redis_events()) + _relay_task = asyncio.create_task(manager.start_subscriber()) @app.on_event("shutdown") diff --git a/backend/app/services/gemini/circuit_breaker.py b/backend/app/services/gemini/circuit_breaker.py new file mode 100644 index 0000000..3a9960d --- /dev/null +++ b/backend/app/services/gemini/circuit_breaker.py @@ -0,0 +1,76 @@ +"""Circuit breaker for Gemini API calls.""" + +from __future__ import annotations + +import logging +import time + +logger = logging.getLogger(__name__) + + +class CircuitOpenError(Exception): + """Raised when circuit breaker is open — Gemini calls blocked.""" + + pass + + +class GeminiCircuitBreaker: + """Per-process circuit breaker that fails fast during sustained Gemini outages.""" + + def __init__( + self, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + state_change_callback: callable | None = None, + ): + self._failure_threshold = failure_threshold + self._recovery_timeout = recovery_timeout + self._failure_count = 0 + self._state = "closed" + self._opened_at = None + self._state_change_callback = state_change_callback + + @property + def state(self) -> str: + """Returns 'closed', 'open', or 'half_open'.""" + if self._state == "open" and time.monotonic() - self._opened_at >= self._recovery_timeout: + return "half_open" + return self._state + + def record_success(self) -> None: + """Reset failure count, close circuit.""" + was_open = self._state != "closed" + self._failure_count = 0 + self._state = "closed" + self._opened_at = None + if was_open: + logger.info("Gemini circuit breaker closed after successful probe") + if self._state_change_callback: + self._state_change_callback(self.state) + + def record_failure(self) -> None: + """Increment failure count. Open circuit if threshold reached.""" + self._failure_count += 1 + if self._failure_count >= self._failure_threshold and self._state == "closed": + self._state = "open" + self._opened_at = time.monotonic() + logger.warning( + "Gemini circuit breaker OPEN after %d consecutive failures. " "Calls blocked for %.0fs.", + self._failure_count, + self._recovery_timeout, + ) + if self._state_change_callback: + self._state_change_callback(self.state) + elif self._state == "open" and self.state == "half_open": + self._opened_at = time.monotonic() + logger.warning("Gemini circuit breaker re-opened after failed probe") + if self._state_change_callback: + self._state_change_callback(self.state) + + def ensure_closed(self) -> None: + """Raise CircuitOpenError if circuit is open. Allow if half_open or closed.""" + current = self.state + if current == "open": + raise CircuitOpenError(f"Gemini circuit breaker is open. Retry after {self._recovery_timeout}s.") + if current == "half_open": + logger.info("Gemini circuit breaker half-open, allowing probe request") diff --git a/backend/app/services/gemini/client.py b/backend/app/services/gemini/client.py index caa2f7b..5bb147b 100644 --- a/backend/app/services/gemini/client.py +++ b/backend/app/services/gemini/client.py @@ -6,6 +6,10 @@ import numpy as np from app.core.config import settings +from app.services.gemini.circuit_breaker import ( + CircuitOpenError, + GeminiCircuitBreaker, +) from google import genai from google.genai import types from tenacity import ( @@ -28,6 +32,7 @@ class GeminiClient: def __init__(self): self._client = genai.Client(api_key=settings.gemini_api_key) self._semaphore = threading.Semaphore(5) + self._circuit_breaker = GeminiCircuitBreaker() @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) def generate_embedding(self, text: str) -> list[float]: @@ -36,20 +41,52 @@ def generate_embedding(self, text: str) -> list[float]: Note: Google's embedding model requires normalization for dimensions other than 3072 to ensure accurate semantic similarity. """ - with self._semaphore: - result = self._client.models.embed_content( - model=settings.gemini_embedding_model, - contents=text, - config=types.EmbedContentConfig(output_dimensionality=768), - ) - # Get embedding values - embedding_values = result.embeddings[0].values - - # Normalize (required for 768-dim per Google docs) - embedding_np = np.array(embedding_values) - normed_embedding = embedding_np / np.linalg.norm(embedding_np) - - return normed_embedding.tolist() + self._circuit_breaker.ensure_closed() + try: + with self._semaphore: + result = self._client.models.embed_content( + model=settings.gemini_embedding_model, + contents=text, + config=types.EmbedContentConfig(output_dimensionality=768), + ) + # Get embedding values + embedding_values = result.embeddings[0].values + + # Normalize (required for 768-dim per Google docs) + embedding_np = np.array(embedding_values) + normed_embedding = embedding_np / np.linalg.norm(embedding_np) + + self._circuit_breaker.record_success() + return normed_embedding.tolist() + except CircuitOpenError: + raise + except Exception: + self._circuit_breaker.record_failure() + raise + + CLASSIFICATION_SYSTEM_INSTRUCTION = ( + "You are a Teaching Assistant for a Live Stream. Your job is to identify " + '"Student Doubts." A student doubt is any inquiry, confusion, or request ' + "for clarification. Classify as a question if the student is seeking an " + "answer, even if they use informal language or omit question marks." + ) + + CLASSIFICATION_FEW_SHOT = ( + "Examples:\n" + 'Comment: "I dont get the list part" -> {"is_question": true, "confidence": 0.92}\n' + 'Comment: "Wait, why is that true?" -> {"is_question": true, "confidence": 0.97}\n' + 'Comment: "Hello from London" -> {"is_question": false, "confidence": 0.95}\n' + 'Comment: "Can you repeat the last step?" -> {"is_question": true, "confidence": 0.96}\n' + ) + + CLASSIFICATION_RESPONSE_SCHEMA = { + "type": "object", + "properties": { + "is_question": {"type": "boolean"}, + "confidence": {"type": "number"}, + }, + "required": ["is_question", "confidence"], + } @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) def classify_question(self, text: str) -> dict: @@ -58,18 +95,31 @@ def classify_question(self, text: str) -> dict: Returns: dict with keys 'is_question' (bool) and 'confidence' (float 0-1). """ - with self._semaphore: - prompt = ( - f'Is this a question? Return JSON only: {{"is_question": bool, "confidence": float 0-1}}\n' - f"Comment: {text}" - ) - response = self._client.models.generate_content(model=settings.gemini_model, contents=prompt) - raw = response.text.strip().removeprefix("```json").removesuffix("```").strip() - result = json.loads(raw) - logger.debug( - f"Classified comment: is_question={result.get('is_question')}, confidence={result.get('confidence')}" - ) - return result + self._circuit_breaker.ensure_closed() + try: + with self._semaphore: + prompt = f"{self.CLASSIFICATION_FEW_SHOT}\nComment: {text}" + response = self._client.models.generate_content( + model=settings.gemini_model, + contents=prompt, + config=types.GenerateContentConfig( + system_instruction=self.CLASSIFICATION_SYSTEM_INSTRUCTION, + response_mime_type="application/json", + response_schema=self.CLASSIFICATION_RESPONSE_SCHEMA, + ), + ) + result = json.loads(response.text) + logger.debug( + f"Classified comment: is_question={result.get('is_question')}, " + f"confidence={result.get('confidence')}" + ) + self._circuit_breaker.record_success() + return result + except CircuitOpenError: + raise + except Exception: + self._circuit_breaker.record_failure() + raise @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) def generate_answer(self, question: str, context: str | None) -> str: @@ -82,21 +132,54 @@ def generate_answer(self, question: str, context: str | None) -> str: Returns: Answer text. """ - with self._semaphore: - if context: + self._circuit_breaker.ensure_closed() + try: + with self._semaphore: + if context: + prompt = ( + f"You are a teaching assistant answering student questions during a live class.\n" + f"Answer concisely and clearly using only the provided context.\n\n" + f"Context:\n{context}\n\n" + f"Question(s):\n{question}" + ) + else: + prompt = ( + f"You are a teaching assistant answering student questions during a live class.\n" + f"No teacher-uploaded context is available. Answer concisely and clearly " + f"using your general knowledge.\n\n" + f"Question(s):\n{question}" + ) + response = self._client.models.generate_content(model=settings.gemini_model, contents=prompt) + logger.debug("Answer generated successfully") + self._circuit_breaker.record_success() + return response.text + except CircuitOpenError: + raise + except Exception: + self._circuit_breaker.record_failure() + raise + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) + def summarize_cluster(self, questions: list[str]) -> str: + """Summarize a cluster of questions in 8 words or less.""" + self._circuit_breaker.ensure_closed() + try: + with self._semaphore: + joined = "\n".join(f"- {q}" for q in questions) prompt = ( - f"You are a teaching assistant answering student questions during a live class.\n" - f"Answer concisely and clearly using only the provided context.\n\n" - f"Context:\n{context}\n\n" - f"Question(s):\n{question}" + "Summarize what these questions are asking in 8 words or less. " + "Return only the summary, no punctuation.\n\n" + f"{joined}" ) - else: - prompt = ( - f"You are a teaching assistant answering student questions during a live class.\n" - f"No teacher-uploaded context is available. Answer concisely and clearly " - f"using your general knowledge.\n\n" - f"Question(s):\n{question}" + response = self._client.models.generate_content( + model=settings.gemini_model, + contents=prompt, + config=types.GenerateContentConfig(max_output_tokens=20), ) - response = self._client.models.generate_content(model=settings.gemini_model, contents=prompt) - logger.debug("Answer generated successfully") - return response.text + self._circuit_breaker.record_success() + return response.text.strip() + except CircuitOpenError: + raise + except Exception: + self._circuit_breaker.record_failure() + raise diff --git a/backend/app/services/moderation.py b/backend/app/services/moderation.py index 999ad4b..f055c02 100644 --- a/backend/app/services/moderation.py +++ b/backend/app/services/moderation.py @@ -1,35 +1,117 @@ -"""Content moderation service.""" +"""Content moderation service using Gemini AI.""" +import json +import logging from typing import Optional +from app.core.config import settings +from app.services.gemini.client import GeminiClient +from google.genai import types + +logger = logging.getLogger(__name__) + +MODERATION_SYSTEM_INSTRUCTION = ( + "You are a content moderator for an educational platform where students ask " + "questions to teachers during live YouTube sessions. " + "Your job is to identify content that is inappropriate for an academic setting." +) + +COMMENT_MODERATION_SCHEMA = { + "type": "object", + "properties": { + "approved": {"type": "boolean"}, + "reason": {"type": "string"}, + "category": { + "type": "string", + "enum": ["safe", "spam", "offensive", "harmful", "irrelevant"], + }, + }, + "required": ["approved", "reason", "category"], +} + +COMMENT_MODERATION_PROMPT = """Analyze this student comment from a live educational stream. + +Comment: "{text}" + +Approve if: genuine academic question or statement, non-English content, minor typos, informal language. +Reject if: profanity, personal attacks, spam or promotional content, self-harm, explicit content. + +Respond only with JSON.""" + +ANSWER_MODERATION_PROMPT = """Analyze this AI-generated answer that will be posted publicly to YouTube on behalf of a teacher. + +Answer: "{text}" + +Approve if: factual academic content, clear explanation, appropriate tone. +Reject if: harmful advice, offensive language, factual content that could cause harm if wrong. + +Respond only with JSON.""" + class ModerationService: - """Service for content moderation.""" + """Service for content moderation using Gemini AI.""" def __init__(self): - """Initialize moderation service.""" - pass + self._gemini = GeminiClient() def moderate_comment(self, text: str) -> tuple[bool, Optional[str]]: - """Moderate a comment for inappropriate content. - - Args: - text: Comment text to moderate. + """Moderate a student comment for inappropriate content. Returns: Tuple of (is_safe, reason_if_unsafe). + On any Gemini failure, approves by default to avoid blocking the pipeline. """ - # TODO: Implement actual moderation logic - return (True, None) + try: + prompt = COMMENT_MODERATION_PROMPT.format(text=text) + response = self._gemini._client.models.generate_content( + model=settings.gemini_model, + contents=prompt, + config=types.GenerateContentConfig( + system_instruction=MODERATION_SYSTEM_INSTRUCTION, + response_mime_type="application/json", + response_schema=COMMENT_MODERATION_SCHEMA, + ), + ) + result = json.loads(response.text) + approved = result.get("approved", True) + reason = result.get("reason") if not approved else None + category = result.get("category", "safe") + logger.info( + "Comment moderation result", + extra={"approved": approved, "category": category, "reason": reason}, + ) + return (approved, reason) + except Exception as e: + logger.error(f"Moderation failed for comment, approving by default: {e}") + return (True, None) def moderate_answer(self, text: str) -> tuple[bool, Optional[str]]: - """Moderate an answer for inappropriate content. - - Args: - text: Answer text to moderate. + """Moderate an AI-generated answer before it is posted to YouTube. Returns: Tuple of (is_safe, reason_if_unsafe). + On any Gemini failure, approves by default to avoid blocking the pipeline. """ - # TODO: Implement actual moderation logic - return (True, None) + try: + prompt = ANSWER_MODERATION_PROMPT.format(text=text) + response = self._gemini._client.models.generate_content( + model=settings.gemini_model, + contents=prompt, + config=types.GenerateContentConfig( + system_instruction=MODERATION_SYSTEM_INSTRUCTION, + response_mime_type="application/json", + response_schema=COMMENT_MODERATION_SCHEMA, + ), + ) + result = json.loads(response.text) + approved = result.get("approved", True) + reason = result.get("reason") if not approved else None + category = result.get("category", "safe") + logger.info( + "Answer moderation result", + extra={"approved": approved, "category": category, "reason": reason}, + ) + return (approved, reason) + except Exception as e: + logger.error(f"Moderation failed for answer, approving by default: {e}") + return (True, None) diff --git a/backend/app/services/rag/document_service.py b/backend/app/services/rag/document_service.py index 16c9430..d382928 100644 --- a/backend/app/services/rag/document_service.py +++ b/backend/app/services/rag/document_service.py @@ -103,7 +103,12 @@ async def upload_document( base_title = file.filename or "document" for i, chunk in enumerate(chunks): - embedding = gemini.generate_embedding(chunk) + try: + embedding = gemini.generate_embedding(chunk) + except Exception as e: + logger.error(f"Embedding generation failed at chunk {i + 1}/{len(chunks)}: {e}") + db.rollback() + raise HTTPException(status_code=502, detail="Embedding generation failed. Please try again.") doc = RAGDocument( teacher_id=teacher_id, title=f"{base_title} (chunk {i + 1}/{len(chunks)})", diff --git a/backend/app/services/sessions/__init__.py b/backend/app/services/sessions/__init__.py deleted file mode 100644 index df2ca2f..0000000 --- a/backend/app/services/sessions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Sessions services package.""" diff --git a/backend/app/services/sessions/lifecycle.py b/backend/app/services/sessions/lifecycle.py deleted file mode 100644 index 4f52018..0000000 --- a/backend/app/services/sessions/lifecycle.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Streaming session lifecycle management.""" - -from typing import Optional - - -class SessionLifecycleService: - """Service for managing streaming session lifecycle.""" - - def __init__(self): - """Initialize session lifecycle service.""" - pass - - def create_session(self, teacher_id: int, youtube_video_id: str, title: Optional[str] = None) -> dict: - """Create a new streaming session. - - Args: - teacher_id: Teacher ID. - youtube_video_id: YouTube video ID. - title: Optional session title. - - Returns: - Session data dictionary. - """ - # TODO: Implement actual session creation - return { - "id": 1, - "teacher_id": teacher_id, - "youtube_video_id": youtube_video_id, - "title": title, - "is_active": True, - } - - def end_session(self, session_id: int) -> bool: - """End a streaming session. - - Args: - session_id: Session ID to end. - - Returns: - True if successful, False otherwise. - """ - # TODO: Implement actual session ending - return True - - def get_active_session(self, teacher_id: int) -> Optional[dict]: - """Get active session for a teacher. - - Args: - teacher_id: Teacher ID. - - Returns: - Session data dictionary or None if not found. - """ - # TODO: Implement actual session retrieval - return None diff --git a/backend/app/services/sessions/stats.py b/backend/app/services/sessions/stats.py deleted file mode 100644 index 9c8417c..0000000 --- a/backend/app/services/sessions/stats.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Streaming session statistics service.""" - - -class SessionStatsService: - """Service for calculating session statistics.""" - - def __init__(self): - """Initialize session stats service.""" - pass - - def get_session_stats(self, session_id: int) -> dict: - """Get statistics for a session. - - Args: - session_id: Session ID. - - Returns: - Statistics dictionary. - """ - # TODO: Implement actual stats calculation - return { - "total_comments": 0, - "total_questions": 0, - "total_clusters": 0, - "total_answers": 0, - } - - def get_teacher_stats(self, teacher_id: int) -> dict: - """Get statistics for a teacher. - - Args: - teacher_id: Teacher ID. - - Returns: - Statistics dictionary. - """ - # TODO: Implement actual stats calculation - return { - "total_sessions": 0, - "total_comments": 0, - "total_answers": 0, - } diff --git a/backend/app/services/websocket/events.py b/backend/app/services/websocket/events.py index c2777f3..0709152 100644 --- a/backend/app/services/websocket/events.py +++ b/backend/app/services/websocket/events.py @@ -23,6 +23,7 @@ class WebSocketEventType(str, Enum): COMMENT_CREATED = "comment_created" COMMENT_CLASSIFIED = "comment_classified" + COMMENT_EMBEDDED = "comment_embedded" CLUSTER_CREATED = "cluster_created" CLUSTER_UPDATED = "cluster_updated" @@ -107,6 +108,21 @@ def create_comment_classified_event(self, comment_id: str, is_question: bool, co message=f"Comment classified as {'question' if is_question else 'not a question'}", ) + def create_comment_embedded_event(self, comment_id: str) -> Dict[str, Any]: + """Create a comment embedded event. + + Args: + comment_id: Comment identifier. + + Returns: + Event message dictionary. + """ + return self.create_base_event( + WebSocketEventType.COMMENT_EMBEDDED, + data={"comment_id": comment_id}, + message="Comment embedding generated", + ) + def create_cluster_created_event(self, cluster_data: Dict[str, Any]) -> Dict[str, Any]: """Create a cluster created event. diff --git a/backend/app/services/websocket/manager.py b/backend/app/services/websocket/manager.py index 4130e0b..b531891 100644 --- a/backend/app/services/websocket/manager.py +++ b/backend/app/services/websocket/manager.py @@ -1,7 +1,9 @@ """WebSocket connection manager.""" import asyncio +import json import logging +import uuid from datetime import ( datetime, timezone, @@ -12,6 +14,7 @@ Optional, ) +import redis.asyncio as aioredis from app.core.config import settings from fastapi import ( WebSocket, @@ -45,6 +48,7 @@ def __init__(self): """Initialize WebSocket manager.""" self.active_connections: Dict[str, Dict[str, ConnectionInfo]] = {} self.heartbeat_task: Optional[asyncio.Task] = None + self._redis: Optional[aioredis.Redis] = None async def connect(self, session_id: str, websocket: WebSocket, connection_id: Optional[str] = None) -> str: """Connect a WebSocket to a session. @@ -60,8 +64,6 @@ async def connect(self, session_id: str, websocket: WebSocket, connection_id: Op await websocket.accept() if connection_id is None: - import uuid - connection_id = str(uuid.uuid4()) if session_id not in self.active_connections: @@ -99,6 +101,52 @@ def disconnect(self, session_id: str, connection_id: str) -> None: if not self.active_connections[session_id]: del self.active_connections[session_id] + async def publish(self, session_id: str, message: Dict[str, Any]) -> None: + """Publish a message to Redis channel ws:{session_id}. + + Any subscriber process that owns a connection for this session + will pick it up and deliver it locally. + """ + try: + if self._redis is None: + self._redis = aioredis.from_url(settings.redis_url, decode_responses=True) + await self._redis.publish(f"ws:{session_id}", json.dumps(message)) + except Exception as e: + logger.error(f"Redis publish failed, resetting connection: {e}") + self._redis = None + raise + + async def start_subscriber(self) -> None: + """Subscribe to ws:* and deliver messages to locally held connections. + + Reconnects with exponential backoff on Redis failure. + """ + backoff = 1 + while True: + r = None + try: + r = aioredis.from_url(settings.redis_url, decode_responses=True) + pubsub = r.pubsub() + await pubsub.psubscribe("ws:*") + backoff = 1 + async for message in pubsub.listen(): + if message["type"] != "pmessage": + continue + channel: str = message["channel"] # "ws:{session_id}" + session_id = channel[3:] # strip "ws:" prefix + try: + event = json.loads(message["data"]) + await self.broadcast_to_session(session_id, event) + except Exception as e: + logger.error(f"Failed to deliver WS event for session {session_id}: {e}") + except Exception as e: + logger.error(f"WS subscriber error, reconnecting in {backoff}s: {e}") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 30) + finally: + if r: + await r.aclose() + async def send_personal_message(self, session_id: str, connection_id: str, message: Dict[str, Any]) -> bool: """Send a message to a specific WebSocket. diff --git a/backend/app/tasks/monitoring.py b/backend/app/tasks/monitoring.py deleted file mode 100644 index a381f89..0000000 --- a/backend/app/tasks/monitoring.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Monitoring background task.""" - -from app.core.logging import get_logger - -logger = get_logger(__name__) - - -async def collect_metrics() -> None: - """Collect system metrics. - - This task should run periodically to collect and report metrics. - """ - logger.info("Starting metrics collection") - # TODO: Implement actual metrics collection - logger.info("Metrics collection completed") - - -async def schedule_monitoring() -> None: - """Schedule monitoring tasks.""" - # TODO: Implement scheduling logic - pass diff --git a/backend/app/tasks/quota_reset.py b/backend/app/tasks/quota_reset.py index e415508..2e38fd4 100644 --- a/backend/app/tasks/quota_reset.py +++ b/backend/app/tasks/quota_reset.py @@ -1,21 +1,45 @@ -"""Quota reset background task.""" +"""Quota reset task — resets used counts for all expired quota periods.""" -from app.core.logging import get_logger +import logging +from datetime import ( + datetime, + timedelta, + timezone, +) -logger = get_logger(__name__) +from app.db.models.quota import Quota +from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) -async def reset_quotas() -> None: - """Reset quotas for all teachers. - This task should run periodically to reset quota usage. +def reset_quotas(db: Session) -> None: + """Reset all quotas whose reset_at timestamp has passed. + + Sets used=0 and advances reset_at to the next period boundary. + Safe to run frequently — only touches rows where reset_at <= now. """ - logger.info("Starting quota reset task") - # TODO: Implement actual quota reset logic - logger.info("Quota reset task completed") + now = datetime.now(timezone.utc) + + expired = db.query(Quota).filter(Quota.reset_at <= now).all() + + if not expired: + logger.debug("No quotas due for reset") + return + reset_count = 0 + for quota in expired: + quota.used = 0 + if quota.period == "daily": + quota.reset_at = now + timedelta(days=1) + elif quota.period == "monthly": + # Advance by roughly one month (30 days) + quota.reset_at = now + timedelta(days=30) + else: + # Unknown period — advance by 1 day as safe fallback + logger.warning(f"Unknown quota period '{quota.period}' for quota {quota.id}, defaulting to daily") + quota.reset_at = now + timedelta(days=1) + reset_count += 1 -async def schedule_quota_reset() -> None: - """Schedule quota reset task.""" - # TODO: Implement scheduling logic - pass + db.commit() + logger.info(f"Reset {reset_count} quota(s)") diff --git a/backend/app/tasks/token_cleanup.py b/backend/app/tasks/token_cleanup.py index c281d02..daaf6ab 100644 --- a/backend/app/tasks/token_cleanup.py +++ b/backend/app/tasks/token_cleanup.py @@ -1,21 +1,41 @@ -"""Token cleanup background task.""" +"""Token cleanup task — removes unrecoverable expired YouTube tokens.""" -from app.core.logging import get_logger +import logging +from datetime import ( + datetime, + timezone, +) -logger = get_logger(__name__) +from app.db.models.youtube_token import YouTubeToken +from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) -async def cleanup_expired_tokens() -> None: - """Clean up expired OAuth tokens. - This task should run periodically to remove expired tokens. +def cleanup_expired_tokens(db: Session) -> None: + """Delete YouTubeToken rows that are expired and have no refresh token. + + Tokens with a refresh_token can be renewed by the application — those are + left alone. Only tokens that are both expired AND unrefreshable are removed. """ - logger.info("Starting token cleanup task") - # TODO: Implement actual token cleanup logic - logger.info("Token cleanup task completed") + now = datetime.now(timezone.utc) + + deleted = ( + db.query(YouTubeToken) + .filter( + YouTubeToken.expires_at <= now, + YouTubeToken.refresh_token.is_(None), + ) + .all() + ) + + if not deleted: + logger.debug("No expired unrecoverable tokens found") + return + count = len(deleted) + for token in deleted: + db.delete(token) -async def schedule_token_cleanup() -> None: - """Schedule token cleanup task.""" - # TODO: Implement scheduling logic - pass + db.commit() + logger.info(f"Deleted {count} expired unrecoverable YouTube token(s)") diff --git a/backend/app/utils/text.py b/backend/app/utils/text.py deleted file mode 100644 index 01e2949..0000000 --- a/backend/app/utils/text.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Text processing utilities.""" - -from typing import List - - -def clean_text(text: str) -> str: - """Clean and normalize text. - - Args: - text: Raw text input. - - Returns: - Cleaned text string. - """ - # TODO: Implement actual text cleaning - return text.strip() - - -def extract_hashtags(text: str) -> List[str]: - """Extract hashtags from text. - - Args: - text: Text to extract hashtags from. - - Returns: - List of hashtag strings. - """ - # TODO: Implement actual hashtag extraction - return [] - - -def is_question(text: str) -> bool: - """Check if text appears to be a question. - - Args: - text: Text to check. - - Returns: - True if text is a question, False otherwise. - """ - # TODO: Implement actual question detection - return text.strip().endswith("?") diff --git a/backend/app/utils/time.py b/backend/app/utils/time.py deleted file mode 100644 index 41d42e2..0000000 --- a/backend/app/utils/time.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Time utility functions.""" - -from datetime import ( - datetime, - timezone, -) -from typing import Optional - - -def utc_now() -> datetime: - """Get current UTC datetime. - - Returns: - Current UTC datetime. - """ - return datetime.now(timezone.utc) - - -def parse_datetime(dt_str: str) -> Optional[datetime]: - """Parse datetime string to datetime object. - - Args: - dt_str: Datetime string. - - Returns: - Datetime object or None if parsing fails. - """ - # TODO: Implement actual datetime parsing - try: - return datetime.fromisoformat(dt_str.replace("Z", "+00:00")) - except Exception: - return None diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..105bcac --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,4 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt index 8270680..61b504d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,7 @@ aiosignal==1.4.0 alembic==1.14.0 annotated-types==0.7.0 anyio==4.12.0 +apscheduler==3.10.4 attrs==25.4.0 bcrypt==4.1.2 black==26.3.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..383b994 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,166 @@ +"""Shared test fixtures: test DB, async client, auth helpers.""" + +import os +import uuid +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +import pytest_asyncio +from httpx import ( + ASGITransport, + AsyncClient, +) +from sqlalchemy import ( + create_engine, + event, + text, +) +from sqlalchemy.orm import sessionmaker + +os.environ.setdefault("GEMINI_API_KEY", "test-key-placeholder") + +TEST_DATABASE_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql://sarthak@localhost:5432/ai_doubt_manager_test", +) + +engine = create_engine(TEST_DATABASE_URL, echo=False) + + +@event.listens_for(engine, "connect") +def _enable_pgvector(dbapi_conn, connection_record): + cursor = dbapi_conn.cursor() + try: + cursor.execute("CREATE EXTENSION IF NOT EXISTS vector") + except Exception: + pass + finally: + cursor.close() + + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="session", autouse=True) +def _create_tables(): + """Create all tables once per test session, drop after.""" + from app.db.base import Base + from app.db.models import ( # noqa: F401 — force model registration + Answer, + Cluster, + Comment, + Quota, + RAGDocument, + StreamingSession, + Teacher, + YouTubeToken, + ) + + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +def _override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(autouse=True) +def _clean_tables(): + """Truncate all tables between tests for isolation.""" + yield + db = TestingSessionLocal() + try: + db.execute( + text( + "TRUNCATE answers, comments, clusters, streaming_sessions, teachers, youtube_tokens, quotas, rag_documents CASCADE" + ) + ) + db.commit() + finally: + db.close() + + +@pytest.fixture() +def db_session(): + """Provide a raw SQLAlchemy session for DB-level tests.""" + db = TestingSessionLocal() + try: + yield db + finally: + db.rollback() + db.close() + + +@pytest_asyncio.fixture() +async def client() -> AsyncGenerator[AsyncClient, None]: + """Async HTTP client wired to the FastAPI app with test DB + mocked Redis.""" + from app.db.session import get_db + from app.main import app + + app.dependency_overrides[get_db] = _override_get_db + + with ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=_passthrough_dispatch), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), + ): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +async def _passthrough_dispatch(request, call_next): + """Bypass rate limiting in tests.""" + return await call_next(request) + + +# --------------------------------------------------------------------------- +# Auth helper fixtures +# --------------------------------------------------------------------------- + + +async def _register_and_login(client: AsyncClient, email: str, name: str, password: str) -> dict: + """Register a teacher and log in, return auth headers.""" + await client.post( + "/api/v1/auth/register", + json={"email": email, "password": password, "name": name}, + ) + resp = await client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture() +async def auth_headers(client: AsyncClient) -> dict: + """Register + login Teacher A, return Bearer headers.""" + return await _register_and_login(client, "teacher_a@test.com", "Teacher A", "password123") + + +@pytest_asyncio.fixture() +async def second_auth_headers(client: AsyncClient) -> dict: + """Register + login Teacher B, return Bearer headers.""" + return await _register_and_login(client, "teacher_b@test.com", "Teacher B", "password456") + + +@pytest_asyncio.fixture() +async def session_id(client: AsyncClient, auth_headers: dict) -> str: + """Create a streaming session for Teacher A, return its UUID string.""" + resp = await client.post( + "/api/v1/sessions/", + json={"youtube_video_id": f"test_video_{uuid.uuid4().hex[:8]}"}, + headers=auth_headers, + ) + return resp.json()["id"] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..e0dedcf --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,92 @@ +"""Black-box contract tests for auth endpoints.""" + +import pytest +from httpx import AsyncClient + + +@pytest.fixture() +def _teacher_data(): + return {"email": "auth_test@test.com", "password": "password123", "name": "Auth Tester"} + + +async def test_register_success(client: AsyncClient, _teacher_data: dict): + resp = await client.post("/api/v1/auth/register", json=_teacher_data) + assert resp.status_code == 201 + body = resp.json() + assert "id" in body + assert body["email"] == _teacher_data["email"] + assert body["name"] == _teacher_data["name"] + assert body["is_active"] is True + + +async def test_register_duplicate_email(client: AsyncClient, _teacher_data: dict): + await client.post("/api/v1/auth/register", json=_teacher_data) + resp = await client.post("/api/v1/auth/register", json=_teacher_data) + assert resp.status_code == 400 + + +async def test_login_success(client: AsyncClient, _teacher_data: dict): + await client.post("/api/v1/auth/register", json=_teacher_data) + resp = await client.post( + "/api/v1/auth/login", + json={"email": _teacher_data["email"], "password": _teacher_data["password"]}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "access_token" in body + assert "refresh_token" in body + assert body["token_type"] == "bearer" + assert "expires_in" in body + + +async def test_login_wrong_password(client: AsyncClient, _teacher_data: dict): + await client.post("/api/v1/auth/register", json=_teacher_data) + resp = await client.post( + "/api/v1/auth/login", + json={"email": _teacher_data["email"], "password": "wrongpassword"}, + ) + assert resp.status_code == 401 + + +async def test_login_nonexistent_email(client: AsyncClient): + resp = await client.post( + "/api/v1/auth/login", + json={"email": "nobody@test.com", "password": "password123"}, + ) + assert resp.status_code == 401 + + +async def test_me_with_valid_token(client: AsyncClient, _teacher_data: dict): + await client.post("/api/v1/auth/register", json=_teacher_data) + login_resp = await client.post( + "/api/v1/auth/login", + json={"email": _teacher_data["email"], "password": _teacher_data["password"]}, + ) + token = login_resp.json()["access_token"] + + resp = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + body = resp.json() + assert body["email"] == _teacher_data["email"] + assert body["name"] == _teacher_data["name"] + + +async def test_me_without_token(client: AsyncClient): + resp = await client.get("/api/v1/auth/me") + assert resp.status_code in [401, 403] + + +async def test_refresh_token(client: AsyncClient, _teacher_data: dict): + await client.post("/api/v1/auth/register", json=_teacher_data) + login_resp = await client.post( + "/api/v1/auth/login", + json={"email": _teacher_data["email"], "password": _teacher_data["password"]}, + ) + refresh_token = login_resp.json()["refresh_token"] + + resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 200 + body = resp.json() + assert "access_token" in body + assert "refresh_token" in body + assert body["token_type"] == "bearer" diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..f0f8ed6 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,119 @@ +"""Black-box contract tests for dashboard endpoints.""" + +import uuid + +from httpx import AsyncClient +from sqlalchemy.orm import sessionmaker +from tests.conftest import engine + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def _create_cluster_and_answer(session_id: str) -> tuple[str, str]: + """Insert a cluster + answer directly via SQLAlchemy, return (answer_id, cluster_id).""" + from app.db.models.answer import Answer + from app.db.models.cluster import Cluster + + db = TestingSessionLocal() + try: + cluster = Cluster( + session_id=session_id, + title="Test Cluster", + similarity_threshold=0.8, + ) + db.add(cluster) + db.flush() + + answer = Answer( + cluster_id=cluster.id, + text="Original answer text", + ) + db.add(answer) + db.commit() + return str(answer.id), str(cluster.id) + finally: + db.close() + + +async def test_manual_question_creates_comments(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.post( + f"/api/v1/dashboard/sessions/{session_id}/manual-question", + json={"text": "Q1\nQ2\nQ3"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + assert resp.json() == {"created": 3} + + +async def test_manual_question_max_10(client: AsyncClient, auth_headers: dict, session_id: str): + questions = "\n".join(f"Question {i}" for i in range(15)) + resp = await client.post( + f"/api/v1/dashboard/sessions/{session_id}/manual-question", + json={"text": questions}, + headers=auth_headers, + ) + assert resp.status_code == 201 + assert resp.json() == {"created": 10} + + +async def test_manual_question_nonexistent_session(client: AsyncClient, auth_headers: dict): + fake_id = str(uuid.uuid4()) + resp = await client.post( + f"/api/v1/dashboard/sessions/{fake_id}/manual-question", + json={"text": "Some question"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + +async def test_session_stats_empty(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.get( + f"/api/v1/dashboard/sessions/{session_id}/stats", + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["total_comments"] == 0 + assert body["questions"] == 0 + assert body["answered"] == 0 + assert body["clusters"] == 0 + assert body["answers_generated"] == 0 + assert body["answers_posted"] == 0 + + +async def test_edit_answer(client: AsyncClient, auth_headers: dict, session_id: str): + answer_id, _ = _create_cluster_and_answer(session_id) + resp = await client.patch( + f"/api/v1/dashboard/answers/{answer_id}", + json={"text": "Edited answer text"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["text"] == "Edited answer text" + assert "id" in body + assert "cluster_id" in body + assert "created_at" in body + + +async def test_edit_answer_not_found(client: AsyncClient, auth_headers: dict): + fake_id = str(uuid.uuid4()) + resp = await client.patch( + f"/api/v1/dashboard/answers/{fake_id}", + json={"text": "Doesn't matter"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + +async def test_approve_answer(client: AsyncClient, auth_headers: dict, session_id: str): + answer_id, _ = _create_cluster_and_answer(session_id) + resp = await client.post( + f"/api/v1/dashboard/answers/{answer_id}/approve", + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["id"] == answer_id + assert "text" in body + assert "cluster_id" in body diff --git a/backend/tests/test_db_constraints.py b/backend/tests/test_db_constraints.py new file mode 100644 index 0000000..3263ed6 --- /dev/null +++ b/backend/tests/test_db_constraints.py @@ -0,0 +1,65 @@ +"""DB-level constraint tests via direct SQLAlchemy inserts.""" + +import uuid + +import pytest +from app.db.models.comment import Comment +from app.db.models.streaming_session import StreamingSession +from app.db.models.teacher import Teacher +from sqlalchemy.exc import IntegrityError + + +def _create_session_in_db(db) -> str: + """Insert a teacher + session directly, return session UUID string.""" + teacher = Teacher( + email=f"constraint_{uuid.uuid4().hex[:8]}@test.com", + name="Constraint Tester", + hashed_password="fakehash", + ) + db.add(teacher) + db.flush() + + session = StreamingSession( + teacher_id=teacher.id, + youtube_video_id=f"vid_{uuid.uuid4().hex[:8]}", + ) + db.add(session) + db.flush() + return str(session.id) + + +def test_comment_youtube_comment_id_not_null(db_session): + session_id = _create_session_in_db(db_session) + comment = Comment( + session_id=session_id, + youtube_comment_id=None, + author_name="Test", + text="Should fail", + ) + db_session.add(comment) + with pytest.raises(IntegrityError): + db_session.flush() + + +def test_comment_youtube_comment_id_unique(db_session): + session_id = _create_session_in_db(db_session) + yt_id = f"dup_{uuid.uuid4().hex[:8]}" + + c1 = Comment( + session_id=session_id, + youtube_comment_id=yt_id, + author_name="Test", + text="First", + ) + db_session.add(c1) + db_session.flush() + + c2 = Comment( + session_id=session_id, + youtube_comment_id=yt_id, + author_name="Test", + text="Duplicate", + ) + db_session.add(c2) + with pytest.raises(IntegrityError): + db_session.flush() diff --git a/backend/tests/test_rag.py b/backend/tests/test_rag.py new file mode 100644 index 0000000..41fa40f --- /dev/null +++ b/backend/tests/test_rag.py @@ -0,0 +1,141 @@ +"""Black-box contract tests for RAG document endpoints. + +Endpoint paths (from backend/app/api/v1/rag.py + main.py mount): + POST /api/v1/rag/documents — upload (multipart, field name "file") + GET /api/v1/rag/documents — list owned documents + DELETE /api/v1/rag/documents/{id} — delete owned document (204) + +Upload calls upload_document() which invokes GeminiClient.generate_embedding +— that is already patched to None by the client fixture (QueueManager.enqueue +is stubbed), but we also need to patch the Gemini call inside upload_document. +""" + +import io +import uuid +from unittest.mock import patch + +from httpx import AsyncClient + + +async def test_upload_document_appears_in_list(client: AsyncClient, auth_headers: dict): + """Upload a small PDF → 2xx → GET list → document appears in response.""" + pdf_content = b"%PDF-1.4 fake pdf content with enough words " + b"word " * 50 + + with ( + patch("app.services.rag.document_service.GeminiClient") as mock_gemini_cls, + patch("app.services.rag.document_service._extract_text_pdf", return_value="Some test content " * 50), + ): + mock_gemini = mock_gemini_cls.return_value + mock_gemini.generate_embedding.return_value = [0.1] * 768 + + resp = await client.post( + "/api/v1/rag/documents", + files={"file": ("test.pdf", io.BytesIO(pdf_content), "application/pdf")}, + headers=auth_headers, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["chunks_created"] >= 1 + assert len(body["document_ids"]) >= 1 + + # Verify it appears in the list + list_resp = await client.get("/api/v1/rag/documents", headers=auth_headers) + assert list_resp.status_code == 200 + docs = list_resp.json() + uploaded_ids = set(body["document_ids"]) + listed_ids = {d["id"] for d in docs} + assert uploaded_ids.issubset(listed_ids) + + +async def test_teacher_only_sees_own_documents(client: AsyncClient, auth_headers: dict, second_auth_headers: dict): + """Teacher A uploads → Teacher B lists → Teacher B sees 0 documents.""" + pdf_content = b"%PDF-1.4 fake pdf content with enough words " + b"word " * 50 + + with ( + patch("app.services.rag.document_service.GeminiClient") as mock_gemini_cls, + patch("app.services.rag.document_service._extract_text_pdf", return_value="Some test content " * 50), + ): + mock_gemini = mock_gemini_cls.return_value + mock_gemini.generate_embedding.return_value = [0.1] * 768 + + await client.post( + "/api/v1/rag/documents", + files={"file": ("test.pdf", io.BytesIO(pdf_content), "application/pdf")}, + headers=auth_headers, + ) + + # Teacher B should see nothing + list_resp = await client.get("/api/v1/rag/documents", headers=second_auth_headers) + assert list_resp.status_code == 200 + assert list_resp.json() == [] + + +async def test_delete_document_removes_it_from_list(client: AsyncClient, auth_headers: dict): + """Upload → delete → list → document gone.""" + pdf_content = b"%PDF-1.4 fake pdf content with enough words " + b"word " * 50 + + with ( + patch("app.services.rag.document_service.GeminiClient") as mock_gemini_cls, + patch("app.services.rag.document_service._extract_text_pdf", return_value="Some test content " * 50), + ): + mock_gemini = mock_gemini_cls.return_value + mock_gemini.generate_embedding.return_value = [0.1] * 768 + + resp = await client.post( + "/api/v1/rag/documents", + files={"file": ("test.pdf", io.BytesIO(pdf_content), "application/pdf")}, + headers=auth_headers, + ) + + doc_id = resp.json()["document_ids"][0] + + del_resp = await client.delete(f"/api/v1/rag/documents/{doc_id}", headers=auth_headers) + assert del_resp.status_code == 204 + + list_resp = await client.get("/api/v1/rag/documents", headers=auth_headers) + listed_ids = {d["id"] for d in list_resp.json()} + assert doc_id not in listed_ids + + +async def test_delete_nonexistent_document_returns_404(client: AsyncClient, auth_headers: dict): + """DELETE with random UUID → 404.""" + fake_id = str(uuid.uuid4()) + resp = await client.delete(f"/api/v1/rag/documents/{fake_id}", headers=auth_headers) + assert resp.status_code == 404 + + +async def test_teacher_b_cannot_delete_teacher_a_document( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + """Teacher A uploads → Teacher B tries to delete → 404 (ownership boundary).""" + pdf_content = b"%PDF-1.4 fake pdf content with enough words " + b"word " * 50 + + with ( + patch("app.services.rag.document_service.GeminiClient") as mock_gemini_cls, + patch("app.services.rag.document_service._extract_text_pdf", return_value="Some test content " * 50), + ): + mock_gemini = mock_gemini_cls.return_value + mock_gemini.generate_embedding.return_value = [0.1] * 768 + + resp = await client.post( + "/api/v1/rag/documents", + files={"file": ("test.pdf", io.BytesIO(pdf_content), "application/pdf")}, + headers=auth_headers, + ) + + doc_id = resp.json()["document_ids"][0] + + # Teacher B tries to delete Teacher A's document + del_resp = await client.delete(f"/api/v1/rag/documents/{doc_id}", headers=second_auth_headers) + assert del_resp.status_code == 404 + + +async def test_upload_invalid_file_type_is_rejected(client: AsyncClient, auth_headers: dict): + """Upload a .exe file → 415 (unsupported media type).""" + resp = await client.post( + "/api/v1/rag/documents", + files={"file": ("malware.exe", io.BytesIO(b"MZ fake exe"), "application/octet-stream")}, + headers=auth_headers, + ) + assert resp.status_code == 415 diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..a2b1a7e --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,72 @@ +"""Black-box ownership boundary tests: teacher A cannot access teacher B's resources.""" + +from httpx import AsyncClient + + +async def _create_session(client: AsyncClient, auth_headers: dict) -> str: + """Create a session via API, return its UUID string.""" + resp = await client.post( + "/api/v1/sessions/", + json={"youtube_video_id": "vid_security_test"}, + headers=auth_headers, + ) + return resp.json()["id"] + + +async def test_teacher_b_cannot_get_teacher_a_session( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + session_id = await _create_session(client, auth_headers) + resp = await client.get( + f"/api/v1/sessions/{session_id}", + headers=second_auth_headers, + ) + assert resp.status_code in [403, 404] + + +async def test_teacher_b_cannot_update_teacher_a_session( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + session_id = await _create_session(client, auth_headers) + resp = await client.patch( + f"/api/v1/sessions/{session_id}", + json={"title": "Hijacked"}, + headers=second_auth_headers, + ) + assert resp.status_code in [403, 404] + + +async def test_teacher_b_cannot_end_teacher_a_session( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + session_id = await _create_session(client, auth_headers) + resp = await client.post( + f"/api/v1/sessions/{session_id}/end", + headers=second_auth_headers, + ) + assert resp.status_code in [403, 404] + + +async def test_teacher_b_cannot_see_teacher_a_sessions_in_list( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + a_session_id = await _create_session(client, auth_headers) + b_session_id = await _create_session(client, second_auth_headers) + + resp = await client.get("/api/v1/sessions/", headers=second_auth_headers) + assert resp.status_code == 200 + ids = [s["id"] for s in resp.json()] + assert b_session_id in ids + assert a_session_id not in ids + + +async def test_teacher_b_cannot_submit_manual_question_to_teacher_a_session( + client: AsyncClient, auth_headers: dict, second_auth_headers: dict +): + session_id = await _create_session(client, auth_headers) + resp = await client.post( + f"/api/v1/dashboard/sessions/{session_id}/manual-question", + json={"text": "Unauthorized question"}, + headers=second_auth_headers, + ) + assert resp.status_code in [403, 404] diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py new file mode 100644 index 0000000..1343196 --- /dev/null +++ b/backend/tests/test_sessions.py @@ -0,0 +1,69 @@ +"""Black-box contract tests for session endpoints.""" + +import uuid + +from httpx import AsyncClient + + +async def test_create_session(client: AsyncClient, auth_headers: dict): + resp = await client.post( + "/api/v1/sessions/", + json={"youtube_video_id": "vid_create_test"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + body = resp.json() + assert "id" in body + assert body["youtube_video_id"] == "vid_create_test" + assert body["is_active"] is True + assert "teacher_id" in body + + +async def test_list_sessions_empty(client: AsyncClient, auth_headers: dict): + resp = await client.get("/api/v1/sessions/", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == [] + + +async def test_list_sessions_returns_own(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.get("/api/v1/sessions/", headers=auth_headers) + assert resp.status_code == 200 + sessions = resp.json() + assert len(sessions) >= 1 + ids = [s["id"] for s in sessions] + assert session_id in ids + + +async def test_get_session_by_id(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.get(f"/api/v1/sessions/{session_id}", headers=auth_headers) + assert resp.status_code == 200 + body = resp.json() + assert body["id"] == session_id + assert body["is_active"] is True + + +async def test_update_session(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.patch( + f"/api/v1/sessions/{session_id}", + json={"title": "Updated Title"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "Updated Title" + + +async def test_end_session(client: AsyncClient, auth_headers: dict, session_id: str): + resp = await client.post( + f"/api/v1/sessions/{session_id}/end", + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["is_active"] is False + assert body["ended_at"] is not None + + +async def test_get_nonexistent_session(client: AsyncClient, auth_headers: dict): + fake_id = str(uuid.uuid4()) + resp = await client.get(f"/api/v1/sessions/{fake_id}", headers=auth_headers) + assert resp.status_code == 404 diff --git a/backend/tests/test_websocket.py b/backend/tests/test_websocket.py new file mode 100644 index 0000000..aed756e --- /dev/null +++ b/backend/tests/test_websocket.py @@ -0,0 +1,159 @@ +"""Black-box contract tests for the WebSocket endpoint. + +WebSocket path: /ws/{session_id} +Auth: first message must be JSON {"type": "auth", "token": ""} +Close codes: 4001 (auth required / invalid token), 4003 (forbidden — wrong owner) + +Uses Starlette's TestClient for WebSocket since httpx does not support WS upgrade. + +Note: The WS handler uses SessionLocal() directly (not dependency injection), +so we patch app.api.v1.websocket.SessionLocal to return a test DB session. +""" + +import uuid +from unittest.mock import patch + +import pytest +from app.main import app +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect +from tests.conftest import ( + TestingSessionLocal, + _override_get_db, +) + +_PATCHES = ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=None), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), +) + + +def _get_test_app(): + """Return the FastAPI app with test DB + mocked externals.""" + from app.db.session import get_db + + app.dependency_overrides[get_db] = _override_get_db + return app + + +def _register_and_login_sync(client: TestClient, email: str, name: str, password: str) -> str: + """Register + login synchronously via TestClient, return JWT access token.""" + client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name}) + resp = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + return resp.json()["access_token"] + + +def _create_session_sync(client: TestClient, token: str) -> str: + """Create a streaming session, return its UUID string.""" + resp = client.post( + "/api/v1/sessions/", + json={"youtube_video_id": f"ws_test_{uuid.uuid4().hex[:8]}"}, + headers={"Authorization": f"Bearer {token}"}, + ) + return resp.json()["id"] + + +async def _passthrough_dispatch(request, call_next): + """Bypass rate limiting in tests.""" + return await call_next(request) + + +def test_websocket_rejects_connection_without_token(): + """Connect then send non-auth message → server closes with code 4001.""" + test_app = _get_test_app() + session_id = str(uuid.uuid4()) + + with ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=_passthrough_dispatch), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), + ): + with TestClient(test_app) as tc: + with pytest.raises(WebSocketDisconnect) as exc_info: + with tc.websocket_connect(f"/ws/{session_id}") as ws: + # Send a message that is valid JSON but not an auth message + ws.send_json({"type": "not_auth"}) + ws.receive_json() # triggers the disconnect exception + + assert exc_info.value.code == 4001 + + app.dependency_overrides.clear() + + +def test_websocket_rejects_invalid_token(): + """Connect with token='garbage' in auth message → close code 4001.""" + test_app = _get_test_app() + session_id = str(uuid.uuid4()) + + with ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=_passthrough_dispatch), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), + ): + with TestClient(test_app) as tc: + with pytest.raises(WebSocketDisconnect) as exc_info: + with tc.websocket_connect(f"/ws/{session_id}") as ws: + ws.send_json({"type": "auth", "token": "garbage"}) + ws.receive_json() + + assert exc_info.value.code == 4001 + + app.dependency_overrides.clear() + + +def test_websocket_accepts_valid_owner_connection(): + """Register → login → create session → WS connect with valid token → connection accepted.""" + test_app = _get_test_app() + + with ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=_passthrough_dispatch), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), + patch("app.api.v1.websocket.SessionLocal", TestingSessionLocal), + ): + with TestClient(test_app) as tc: + token = _register_and_login_sync(tc, "ws_owner@test.com", "WS Owner", "password123") + session_id = _create_session_sync(tc, token) + + with tc.websocket_connect(f"/ws/{session_id}") as ws: + ws.send_json({"type": "auth", "token": token}) + msg = ws.receive_json() + assert msg["type"] == "connected" + + app.dependency_overrides.clear() + + +def test_websocket_rejects_wrong_teacher_session(): + """Teacher A creates session → Teacher B connects with own token → close code 4003.""" + test_app = _get_test_app() + + with ( + patch("app.services.token_blacklist.token_blacklist.is_blacklisted", return_value=False), + patch("app.services.token_blacklist.token_blacklist.blacklist_token"), + patch("app.core.rate_limit_middleware.RateLimitMiddleware.dispatch", side_effect=_passthrough_dispatch), + patch("workers.common.queue.QueueManager.enqueue", return_value=None), + patch("app.services.websocket.manager.manager.start_subscriber", return_value=None), + patch("app.api.v1.websocket.SessionLocal", TestingSessionLocal), + ): + with TestClient(test_app) as tc: + token_a = _register_and_login_sync(tc, "ws_a@test.com", "Teacher A", "password123") + token_b = _register_and_login_sync(tc, "ws_b@test.com", "Teacher B", "password456") + session_id = _create_session_sync(tc, token_a) + + with pytest.raises(WebSocketDisconnect) as exc_info: + with tc.websocket_connect(f"/ws/{session_id}") as ws: + ws.send_json({"type": "auth", "token": token_b}) + ws.receive_json() + + assert exc_info.value.code == 4003 + + app.dependency_overrides.clear() diff --git a/docker-compose.yml b/docker-compose.yml index 0e672fd..f14e2b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + command: postgres -c max_connections=100 redis: image: redis:7-alpine diff --git a/docs/SYSTEM_DESIGN_REPORT.md b/docs/SYSTEM_DESIGN_REPORT.md new file mode 100644 index 0000000..ae446f0 --- /dev/null +++ b/docs/SYSTEM_DESIGN_REPORT.md @@ -0,0 +1,613 @@ +# System Design Specification Report: AI Live Doubt Manager + +> **Prepared for**: Senior Consultant Deep-Dive Audit +> **Date**: 2026-03-13 +> **Stack**: FastAPI 0.115 / React 19 / PostgreSQL 15 (pgvector) / Redis 7 / Google Gemini AI +> **Scope**: Full-stack architecture — backend, worker pipeline, frontend, infrastructure + +--- + +## 1. High-Level Architecture + +### 1.1 Core Pattern: Event-Driven Pipeline with Real-Time Relay + +The system follows a **producer-consumer pipeline architecture** with three distinct runtime tiers: + +| Tier | Technology | Role | +|------|-----------|------| +| **API Tier** | FastAPI, uvicorn (2 workers) | HTTP/WS ingress, auth, CRUD, real-time relay | +| **Worker Tier** | 6 independent Python processes | Asynchronous AI processing pipeline | +| **Data Tier** | PostgreSQL 15 + Redis 7 | Persistence, coordination, pub/sub | + +**Why this pattern**: Live YouTube sessions generate bursty comment traffic. Synchronous AI processing (classification → embedding → clustering → answer generation) would block API responses and create unacceptable latency. The pipeline decouples ingress from processing, allowing each stage to scale independently and fail independently without losing data — Redis queues persist tasks until consumed. + +### 1.2 System Topology + +``` +YouTube Live Chat API + │ + [Polling Worker] ──enqueue──▶ [Classification] ──▶ [Embedding] ──▶ [Clustering] ──▶ [Answer Gen] ──▶ [YT Posting] + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + PostgreSQL ◀──────────── all workers write directly ──────────▶ Redis pub/sub ──▶ WS Manager ──▶ Browser + ▲ ▲ + │ │ + [FastAPI API] ◀─── HTTP/WS ─── [React 19 SPA] │ + │ │ │ + └─── Redis (queues, pub/sub, rate limits, quota, token blacklist, caching) ──────────┘ +``` + +**Why direct DB writes from workers (not routing through the API)**: Workers need transactional guarantees — e.g., the clustering worker must atomically update the centroid vector and assign the comment in one transaction. Routing writes through the API would add latency, create a single point of failure, and complicate error handling. Each worker manages its own DB connection pool (`pool_size=2, max_overflow=3`) to keep total connections manageable. + +### 1.3 Communication Patterns + +| Pattern | Where Used | Why This Pattern | +|---------|-----------|-----------------| +| **Request-Response (HTTP)** | All API endpoints | Standard CRUD, synchronous auth flows | +| **Priority Queue (Redis ZSET)** | Worker pipeline (6 queues) | Ordered processing, priority support, atomic dequeue via `ZPOPMIN`. ZSET over LIST because it enables delayed retry (future timestamps as scores) | +| **Pub/Sub (Redis)** | WebSocket relay (`ws:{session_id}`) | Workers publish events; API subscriber delivers to browsers. Decouples worker processes from WebSocket connection state | +| **Polling (HTTP)** | YouTube Live Chat ingestion | YouTube API offers no webhooks for live chat; polling at 5s intervals is the only option. Capped at 10 concurrent threads via `ThreadPoolExecutor` | + +--- + +## 2. Data Modeling & Persistence + +### 2.1 PostgreSQL Schema (pgvector-enabled) + +**Entity-Relationship Overview**: + +``` +Teachers (1) ──▶ (M) StreamingSessions (1) ──▶ (M) Comments + (1) ──▶ (M) Clusters (1) ──▶ (M) Answers +Teachers (1) ──▶ (1) YouTubeTokens +Teachers (1) ──▶ (M) Quotas +Teachers (1) ──▶ (M) RAGDocuments +Comments (M) ──▶ (1) Clusters [SET NULL on cluster delete] +``` + +**Vector-enabled tables**: + +| Model | Column | Dimensions | Index | Purpose | +|-------|--------|-----------|-------|---------| +| `comments` | `embedding` | Vector(768) | None (sequential scan) | Semantic similarity for clustering | +| `clusters` | `centroid_embedding` | Vector(768) | None (sequential scan) | Running centroid for nearest-centroid matching | +| `rag_documents` | `embedding` | Vector(768) | None (sequential scan) | Cosine distance retrieval for answer grounding | + +**Why 768 dimensions (not Gemini's native 3072)**: 768 is the balance point — reduces storage and index cost by ~75% while retaining sufficient semantic fidelity for question clustering (not fine-grained retrieval). The code explicitly normalizes embeddings post-generation because Google requires normalization for non-3072 dimensions. + +**Why pgvector over a dedicated vector DB (Qdrant, Pinecone, etc.)**: The system already requires PostgreSQL for relational data. pgvector eliminates an infrastructure dependency. At the expected scale (hundreds to low thousands of comments per session), sequential scan is fast enough. The tradeoff: pgvector cannot handle billion-scale ANN, but this system doesn't need it. + +### 2.2 Key Schema Design Decisions + +| Decision | Implementation | Why | +|----------|---------------|-----| +| `youtube_comment_id` NOT NULL + UNIQUE | Manual comments use `f"manual:{uuid4()}"` prefix | Enables idempotent ingestion — polling worker checks for existing ID before insert, preventing duplicates across polling cycles | +| `Cluster.comment_count` denormalized | Updated atomically in clustering worker | Centroid update formula `(old * n + new) / (n + 1)` needs `n` in the same transaction. `COUNT(*)` would be slower and subject to phantom reads | +| Answers persisted before posting | `is_posted` boolean + async YouTube posting | Separates "answer generated" (always succeeds) from "answer posted" (may fail due to quota/auth). Dashboard shows pending answers immediately | +| CASCADE on `session_id` | Deleting session removes all child data | Session deletion is a "nuke everything" operation | +| SET NULL on `cluster_id` | Cluster deletion preserves comments | Comments may be re-clustered; destroying them would lose data | +| Fernet encryption on YouTube tokens | `encrypt_data`/`decrypt_data` via `backend/app/core/encryption.py` | A DB breach without the encryption key cannot expose YouTube OAuth tokens | + +### 2.3 Composite Indexes + +``` +idx_session_teacher_active (teacher_id, is_active) — session list queries +idx_comment_session_question (session_id, is_question) — clustering worker queries +idx_comment_session_answered (session_id, is_answered) — dashboard stats +idx_comment_cluster (cluster_id) — cluster detail views +idx_answer_cluster_posted (cluster_id, is_posted) — pending answers filter +idx_quota_teacher_type (teacher_id, quota_type) — quota lookups +``` + +### 2.4 Redis Usage Map (6 Distinct Roles) + +| Role | Key Pattern | Data Structure | TTL | Why Redis | +|------|------------|---------------|-----|-----------| +| Task queues | `classification`, `embedding`, etc. | Sorted Set (ZSET) | None | Priority ordering via `score = priority * 1M + timestamp` | +| Dead letter queues | `{queue}_dlq` | ZSET | None | Failed task inspection after 3 retries | +| Rate limiting | `ratelimit:{ip}` | String (INCR) | 60s | Sliding window counter, per-IP | +| YouTube quota | `yt_quota:{teacher_id}:{date}` | String (INCRBY) | TTL to midnight | Per-teacher daily quota (10,000 units) | +| Token blacklist | `blacklist:token:{hash}` | String | Token's remaining TTL | JWT logout revocation (Redis GET per request) | +| YouTube cache | `youtube:poll:{session_id}:*` | String | 3600s | Caches `live_chat_id` to avoid repeated API calls (1 unit each) | +| WebSocket relay | `ws:{session_id}` | Pub/Sub channel | N/A | Cross-process event delivery to browsers | +| CSRF state | `yt_state:{state}` | String | 600s | OAuth flow protection (10 min window) | + +**Why `volatile-lru` eviction policy**: Queue entries have no TTL and are protected from eviction. TTL-bearing keys (rate limits, cached chat IDs) are evictable under memory pressure — acceptable because they are regenerable. + +--- + +## 3. Component Interactions + +### 3.1 API Layer — Middleware Stack + +Execution order (outermost → innermost): + +1. **RateLimitMiddleware** (`backend/app/core/rate_limit_middleware.py`) — Redis-backed IP throttling (60 req/min). Skips `/health`, `/metrics`, `/docs`. **Why outermost**: Reject abusive traffic before auth or DB work. +2. **RequestContextMiddleware** (`backend/app/core/middleware.py`) — Injects `X-Request-ID` and tracks execution time via `contextvars`. **Why**: Distributed tracing without a full APM solution. +3. **CORSMiddleware** — Configured origins from env (`:5173` for Vite dev, `:8000`, `:8080`). + +### 3.2 Auth Flow + +``` +Client ──Bearer token──▶ HTTPBearer ──▶ verify_token() ──▶ Redis blacklist check ──▶ DB Teacher lookup ──▶ is_active check +``` + +**Why Redis blacklist**: JWTs are stateless; without a blacklist, a logged-out token remains valid until expiry. Cost: one Redis GET per authenticated request. + +**Multi-tenancy enforcement**: Every data-access endpoint JOINs to `StreamingSession` to verify `teacher_id == current_user.id`. This is the authorization boundary — there are no role-based permissions beyond ownership. + +### 3.3 Worker Pipeline Detail + +``` +[1. Classification] Gemini 2.5-flash: "Is this a question?" → {is_question, confidence} + │ GATE: Only enqueues if is_question=True (filters ~60-70% of comments) + ▼ +[2. Embedding] Gemini embedding-001: text → 768-dim normalized vector + │ IDEMPOTENT: Skips if comment.embedding already exists + ▼ +[3. Clustering] pgvector cosine distance against session's cluster centroids + │ Threshold ≥ 0.65 → join cluster; < 0.65 → create new + │ Centroid update: new = normalize((old * n + vec) / (n + 1)) + │ Triggers answer gen on: new cluster OR milestones {3, 10, 25} + ▼ +[4. Answer Gen] RAG: top-5 nearest documents by centroid embedding + │ Two-prompt: with-context vs. without-context (prevents hallucination) + │ Semaphore: max 5 concurrent Gemini calls + │ Auto-enqueues to YT posting if teacher has YouTube connected + ▼ +[5. YT Posting] Posts to YouTube Live Chat (200 char limit) + │ Publishes WebSocket event on success + ▼ + Done + +[6. YT Polling] Independent 5s cycle, ThreadPoolExecutor (max 10 threads) + Deduplicates via youtube_comment_id UNIQUE constraint + Token auto-refresh on HTTP 401 +``` + +**Why milestones {3, 10, 25}**: At 3, the cluster has enough signal for a meaningful title (also generated here). At 10 and 25, accumulated question variants provide richer context for a more comprehensive answer. This avoids regenerating on every question (Gemini quota) while updating answers as understanding deepens. + +**Why semaphore(5)**: Rate-limits Gemini API calls across all threads sharing a `GeminiClient` instance. Uses `threading.Semaphore` (not asyncio) because workers are synchronous Python processes. + +### 3.4 WebSocket Relay Architecture + +``` +Worker process ──publish──▶ Redis channel "ws:{session_id}" ──subscribe──▶ API process ──broadcast──▶ Browser WS clients +``` + +**Why not direct worker-to-browser**: Workers are separate OS processes (potentially separate containers). They have no access to the API's in-memory WebSocket registry. Redis pub/sub provides the cross-process bridge. Multiple API instances each subscribe independently and deliver to their own local connections — natural load distribution. + +**WebSocket auth**: Client sends `{"type": "auth", "token": ""}` as first message. **Why first-message auth over query param**: Query params appear in server logs and browser history. + +### 3.5 YouTube OAuth Flow + +``` +Frontend ──GET /youtube/auth/url──▶ Backend generates OAuth URL + CSRF state (Redis, 10min TTL) + │ + ▼ (popup window) +Google OAuth consent ──redirect──▶ Backend callback validates state, exchanges code + │ Encrypts tokens (Fernet), stores in DB + ▼ Returns HTML that postMessages to opener +Frontend receives message, closes popup, updates connection status +``` + +--- + +## 4. State Management & AI/ML Memory + +### 4.1 Online Learning — No Model Persistence + +The system uses **online nearest-centroid clustering**, not batch KMeans. State is the centroid vectors stored in the `clusters` table: + +```python +# Centroid update formula (workers/clustering/worker.py) +new_centroid = (old_centroid * comment_count + new_embedding) / (comment_count + 1) +normalized_centroid = new_centroid / ||new_centroid|| +``` + +**Why online over batch KMeans**: Live sessions require immediate clustering of each incoming question. Batch KMeans would require periodic re-processing of all comments. The tradeoff: online nearest-centroid is order-dependent (different insertion orders produce different clusters), but for grouping similar student questions in real-time, this is acceptable. + +### 4.2 RAG Architecture + +``` +Teacher uploads document ──▶ Chunked + embedded (768-dim) ──▶ Stored in rag_documents table + │ +Answer Generation Worker ──pgvector cosine distance──▶ Top 5 nearest docs by cluster centroid + │ + ┌─────────────────────────┘ + ▼ + Two-prompt system: + ├─ WITH context: "Answer using only the provided context" + └─ WITHOUT context: "No context available, use general knowledge" +``` + +**Why centroid as query vector (not individual question text)**: The centroid represents the semantic center of all questions in the cluster. This retrieves documents relevant to the cluster's *theme*, not just one question's phrasing. + +**Why two separate prompts**: A single prompt with optional context leads to inconsistent behavior — the model may hallucinate when no context is provided, or ignore context when it exists. Explicit prompts produce predictable outputs. + +### 4.3 Gemini Model Usage + +| Operation | Model | Where | Concurrency | +|-----------|-------|-------|-------------| +| Classification | `gemini-2.5-flash` | Classification worker | Sequential (1 at a time) | +| Embeddings | `gemini-embedding-001` | Embedding worker | Sequential | +| Cluster summarization | `gemini-2.0-flash` | Clustering worker (at count=3) | Sequential | +| Answer generation | `gemini-2.5-flash` | Answer generation worker | Semaphore(5) | + +All operations use `tenacity` retry: 3 attempts, exponential backoff 1s → 10s. + +### 4.4 Frontend State Architecture + +| Scope | Mechanism | What Lives Here | +|-------|-----------|----------------| +| **Global** | `AuthContext` | JWT token, user email/name, login/logout/register | +| **Global** | `ThemeContext` | dark/light mode (localStorage, cross-tab sync) | +| **Session-scoped** | `DashboardPage` state | activeSession, sessionEvents (cap 200), quotaAlert | +| **Component-local** | `useState` hooks | Fetch states, forms, UI toggles, caches | +| **Real-time** | `useWebSocket` hook | WS messages (cap 100), connected/reconnecting status | + +**Why refetch-on-WS-event (not pure WS-driven state)**: WS events are notifications ("something changed"), not complete state snapshots. Debounced refetching (500-2000ms) ensures the dashboard always shows authoritative server state, preventing drift from missed or out-of-order messages. + +**Why localStorage for JWT (not httpOnly cookie)**: The WebSocket auth flow requires JavaScript access to the token (sent as first WS message). httpOnly cookies cannot be read by JavaScript. + +--- + +## 5. Infrastructure & DevOps + +### 5.1 Docker Compose Services + +| Service | Image | Port | Key Config | +|---------|-------|------|-----------| +| `postgres` | `pgvector/pgvector:pg15` | 127.0.0.1:5432 | `max_connections=100`, volume: `postgres_data` | +| `redis` | `redis:7-alpine` | 127.0.0.1:6379 | `maxmemory 256mb`, `volatile-lru`, RDB snapshots | +| `api` | Custom (Python 3.13-slim) | 8000 | `uvicorn --workers 2` | +| `workers` | Custom (Python 3.13-slim) | None | `python -m workers.runner` (all 6 workers in one container) | + +**Why `max_connections=100`**: Connection budget is 15 (API) + 30 (workers) = 45 active. 100 provides ~2x headroom for admin connections, migrations, and monitoring. + +**Why single workers container**: Development simplicity. Production should separate workers into individual containers for independent scaling and failure isolation. + +**Why `volatile-lru`**: Queue entries (no TTL) are protected from eviction. TTL-bearing keys (rate limits, caches) are evictable under memory pressure — they are regenerable. + +### 5.2 CI/CD Pipeline (GitHub Actions) + +``` +[Lint Job] [Test Job] + Python 3.13 Python 3.13 + black + isort + ruff Services: pgvector:pg15 + redis:7 + Checks: backend/, workers/ pytest backend/tests workers -v + Line length: 119 chars Dummy GEMINI_API_KEY +``` + +**Lint → Test** dependency chain. Both jobs run on push. + +### 5.3 Configuration Management + +`backend/app/core/config.py` — Pydantic `BaseSettings` with `@lru_cache` singleton. + +Key configuration groups: +- **Database**: pool_size=5, max_overflow=10, pool_recycle=3600s, pool_pre_ping=True +- **Redis**: max_connections=10, decode_responses=True +- **Security**: HS256, access_token=30min, refresh_token=7d, bcrypt_rounds=12 +- **Gemini**: api_key, model names, clustering_threshold +- **YouTube**: client_id, client_secret, redirect_uri +- **WebSocket**: heartbeat_interval=30s, timeout=300s +- **Rate limiting**: requests_per_minute, enabled flag +- **Encryption**: encryption_key (≥32 chars, validated at startup) + +Environment loading: `.env.{environment}` files with fallback. + +--- + +## 6. Critical Paths + +### 6.1 Path 1: Comment → Posted Answer (Highest Complexity) + +**Stages**: YouTube comment → Polling worker → DB insert → Classification → Embedding → Clustering → Answer generation → YouTube posting → WebSocket notification + +**Latency budget**: ~10-30 seconds total. Gemini API calls dominate: classification (~500ms), embedding (~300ms), answer generation (~2-5s). Each inter-stage hop adds ~1s (poll interval). + +**Failure recovery**: Tasks remain in Redis queues on worker crash (ZPOPMIN is atomic). `tenacity` retries transient Gemini failures. Queue-level retry (3 attempts, 60s delay) catches persistent failures. DLQ captures exhausted retries for manual inspection. + +### 6.2 Path 2: WebSocket Real-Time Delivery + +**Risk**: Redis pub/sub is fire-and-forget. If the API subscriber task crashes or disconnects, events are lost during reconnection (exponential backoff, max 30s). Frontend's debounced refetch partially mitigates stale data. + +### 6.3 Path 3: YouTube OAuth Token Lifecycle + +**Risk**: If the encryption key (`settings.encryption_key[:32]`) is lost or changed, all stored YouTube tokens become undecryptable, severing all YouTube connections system-wide. The key must be treated as infrastructure state, not a rotatable secret. + +--- + +## 7. Potential Bottlenecks & Availability Risks + +### 7.1 Single-Threaded Worker Bottleneck — **SEVERITY: HIGH** + +Each pipeline worker is a single-threaded Python process. During traffic spikes (popular live session, hundreds of comments/minute), the classification worker becomes the bottleneck — ~2 comments/second throughput (Gemini-bound). A spike of 10 comments/second creates a growing backlog. + +**Mitigation**: Horizontal scaling — run multiple instances per worker. `ZPOPMIN`-based dequeue is safe for concurrent consumers. No code changes needed. + +### 7.2 Redis Pub/Sub Message Loss — **SEVERITY: MEDIUM** + +Pub/sub delivers only to currently connected subscribers. API restart = lost events during reconnection window. Dashboard may show stale data. + +**Mitigation**: Replace pub/sub with Redis Streams (`XADD`/`XREAD` with consumer groups). Streams persist messages and support "read from last acknowledged" semantics. + +### 7.3 No pgvector Index on Vector Columns — **SEVERITY: MEDIUM (time-bomb)** + +Clustering worker's `ORDER BY centroid_embedding <=> :emb` performs sequential scan. Fine at O(100) clusters. Becomes a latency cliff at O(10,000+). + +**Mitigation**: Add HNSW index on `clusters.centroid_embedding` and `rag_documents.embedding`. Migration files exist for HNSW indexes but may not cover all vector columns. + +### 7.4 YouTube Quota Exhaustion — **SEVERITY: MEDIUM** + +Daily quota: 10,000 units/teacher. Poll cost: 5 units. At 5-second intervals: `5 × (86400/5) = 86,400 units/day` — **far exceeding the limit**. Polling effectively stops after ~2000 polls (~2.8 hours). + +**Mitigation**: Use YouTube API's `pollingIntervalMillis` response field for adaptive polling. Implement exponential backoff when no new messages arrive. + +### 7.5 Gemini API as Single Point of Failure — **SEVERITY: HIGH** + +All AI operations depend on one Gemini API key. Key revocation, rate limiting, or Gemini outage stalls the entire pipeline. `tenacity` handles transient failures but not sustained outages. + +**Mitigation**: Circuit breaker pattern with degraded-mode fallbacks (e.g., regex-based classification). Multiple API keys with rotation. + +### 7.6 Connection Pool Exhaustion Under Scale — **SEVERITY: LOW-MEDIUM** + +Current budget: 45/100 connections. Each horizontally-scaled worker instance adds 5 connections. 11+ additional instances exhaust the pool. + +**Mitigation**: Deploy PgBouncer for connection multiplexing between workers and PostgreSQL. + +### 7.7 JWT in localStorage — **SEVERITY: LOW (security note)** + +localStorage is vulnerable to XSS. A single XSS vulnerability exposes the JWT. However, this is a deliberate tradeoff — WebSocket auth requires JavaScript access to the token, and httpOnly cookies cannot be read by JS. + +**Mitigation**: Strict CSP headers, input sanitization, and regular XSS audits. Consider a dual-auth scheme (cookie for HTTP, short-lived token for WS). + +--- + +## Appendix: Key File Paths + +| Component | Path | +|-----------|------| +| API entry point | `backend/app/main.py` | +| Configuration | `backend/app/core/config.py` | +| Auth & security | `backend/app/core/security.py`, `encryption.py` | +| Database models | `backend/app/db/models/*.py` | +| API routes | `backend/app/api/v1/*.py` | +| WebSocket manager | `backend/app/services/websocket/manager.py` | +| Gemini AI client | `backend/app/services/gemini/client.py` | +| YouTube services | `backend/app/services/youtube/client.py`, `oauth.py`, `quota.py` | +| Queue infrastructure | `workers/common/queue.py` | +| Worker payloads | `workers/common/schemas.py` | +| Classification worker | `workers/classification/worker.py` | +| Embedding worker | `workers/embeddings/worker.py` | +| Clustering worker | `workers/clustering/worker.py` | +| Answer generation worker | `workers/answer_generation/worker.py` | +| YouTube polling worker | `workers/youtube_polling/worker.py` | +| YouTube posting worker | `workers/youtube_posting/worker.py` | +| Frontend entry | `frontend/src/main.jsx` → `App.jsx` | +| API service layer | `frontend/src/services/api.js` | +| WebSocket hook | `frontend/src/hooks/useWebSocket.js` | +| Dashboard page | `frontend/src/pages/DashboardPage.jsx` | +| Docker Compose | `docker-compose.yml` | +| CI/CD | `.github/workflows/ci.yml` | + +--- + +## 8. Scaling Roadmap + +### 8.1 Current Capacity Baseline + +| Resource | Current Limit | Utilization | Headroom | +|----------|--------------|-------------|----------| +| Gemini API concurrency | 5 (semaphore) | Burst-dependent | Low under load | +| YouTube quota | 10,000 units/teacher/day | 5 units/poll × 12 polls/min = 3,600/hr | Exhausts in ~2.8 hrs | +| DB connections (API) | 5 + 10 overflow = 15 | Request-dependent | 55 of 100 unused | +| DB connections (Workers) | 6 × (2 + 3) = 30 | Steady | See above | +| Redis memory | 256 MB | ~70-200 KB for queues | Ample | +| API rate limit | 60 req/min/IP | Per-IP | No per-user limiting | +| Worker throughput | 1 process per stage | ~2 comments/sec (Gemini-bound) | Backlog at >2/sec | + +### 8.2 Phase 1: Quick Wins (Week 1-2) + +| Change | Current | Target | Effort | Impact | +|--------|---------|--------|--------|--------| +| Gemini semaphore | 5 | 15-20 | 1 line (`client.py:30`) | 3-4x answer gen throughput | +| YouTube poll interval | 5s fixed | Adaptive (use API's `pollingIntervalMillis`) | Medium | 50-70% quota savings | +| Redis memory | 256 MB | 512 MB | Config change | 2x headroom | +| Worker pool size | 2+3 | 5+5 per worker | Config change (`common/db.py`) | Handle connection bursts | +| PostgreSQL max_connections | 100 | 200 | Docker config | Support horizontal workers | + +### 8.3 Phase 2: Horizontal Scaling (Month 1-2) + +**Worker horizontal scaling** — The ZPOPMIN-based queue dequeue is inherently safe for concurrent consumers. No code changes needed to run N instances of any worker: + +``` +# Scale classification to 3 instances +docker-compose up --scale classification-worker=3 +``` + +**Requires**: Split `workers` Docker service into per-worker services. Current single-container design bundles all 6 workers. + +**PgBouncer for connection multiplexing** — Each new worker instance adds 5 DB connections. At 20+ worker instances, PostgreSQL's 100-connection limit is reached. PgBouncer in transaction mode multiplexes many worker connections over fewer PostgreSQL connections. + +``` +Workers (100 connections) → PgBouncer (20 connections) → PostgreSQL +``` + +**Redis Sentinel for HA** — Single Redis instance is a SPOF for all queues, pub/sub, rate limits, and quota tracking. Redis Sentinel provides automatic failover with minimal configuration. + +### 8.4 Phase 3: High Volume (Month 3-6, 1000+ concurrent users) + +| Component | Strategy | Trigger | +|-----------|----------|---------| +| Comments table | Partition by `session_id` (range) | >10M rows | +| Embeddings | Local model fallback (e.g., `all-MiniLM-L6-v2`) | Gemini cost > $100/day | +| YouTube quota | Quota tiering (premium teachers get 50K/day) | Teachers hitting limits daily | +| Worker autoscaling | Scale on queue depth (>100 tasks → spawn pod) | Sustained backlog | +| pgvector | Add HNSW index on vector columns | >10K clusters per session | +| Redis | Redis Cluster (sharding) | >1GB memory or >50K ops/sec | +| API | Multiple uvicorn instances behind load balancer | >500 concurrent WebSocket connections | + +### 8.5 Phase 4: Multi-Region (Month 6-12) + +- **Database**: Read replicas for dashboard queries, write primary for ingestion +- **Workers**: Deploy regionally, share Redis Cluster +- **CDN**: Frontend static assets via CloudFront/Cloudflare +- **Gemini API**: Multi-key rotation across regions for quota distribution + +--- + +## 9. Cost Modeling + +### 9.1 Gemini API Costs + +**Per-comment pipeline cost** (assuming ~60% of comments are questions): + +| Operation | Model | Input Tokens (est.) | Output Tokens (est.) | Cost/Call | +|-----------|-------|-------------------|---------------------|-----------| +| Classification | gemini-2.5-flash | ~100 | ~30 | ~$0.000015 | +| Embedding | gemini-embedding-001 | ~100 | N/A | ~$0.000010 | +| Cluster summary | gemini-2.0-flash | ~200 | ~20 | ~$0.000005 | +| Answer generation | gemini-2.5-flash | ~500 (with RAG context) | ~200 | ~$0.000060 | + +**Per-session cost estimate** (1-hour session, 500 comments): + +| Stage | Calls | Cost | +|-------|-------|------| +| Classification | 500 | $0.0075 | +| Embedding | 300 (60% questions) | $0.003 | +| Cluster summaries | ~30 clusters | $0.00015 | +| Answer generation | ~60 (30 clusters × 1-2 milestones) | $0.0036 | +| **Total per session** | **~890 calls** | **~$0.014** | + +**Monthly projection** (10 teachers, 5 sessions/week each): + +``` +10 teachers × 5 sessions/week × 4 weeks × $0.014 = $2.80/month (Gemini only) +``` + +At scale (100 teachers, 10 sessions/week): **~$56/month** in Gemini costs. + +### 9.2 YouTube Quota Cost Analysis + +**Per-session quota burn** (1 hour, 5-second polling): + +| Operation | Calls | Units | Total | +|-----------|-------|-------|-------| +| Polling | 720 (3600s ÷ 5s) | 5 | 3,600 | +| Get chat ID | 1 (cached after first) | 1 | 1 | +| Post answers | ~30 (1 per cluster) | 50 | 1,500 | +| **Total** | | | **5,101 units** | + +**Daily capacity**: 10,000 units → ~1.96 sessions/day at current polling rate. + +**With adaptive polling** (10s average): ~3.5 sessions/day. + +**With YouTube-recommended interval** (~15-30s): ~5-8 sessions/day. + +### 9.3 Infrastructure Costs (Docker/Cloud) + +| Component | Minimum Spec | Est. Monthly Cost (AWS) | +|-----------|-------------|------------------------| +| PostgreSQL (pgvector) | db.t3.medium (2 vCPU, 4GB) | ~$50 | +| Redis | cache.t3.micro (0.5GB) | ~$15 | +| API (ECS/EC2) | t3.small (2 vCPU, 2GB) | ~$20 | +| Workers (ECS/EC2) | t3.medium (2 vCPU, 4GB) | ~$35 | +| Frontend (S3 + CloudFront) | Static hosting | ~$5 | +| **Total** | | **~$125/month** | + +### 9.4 Cost Optimization Opportunities + +| Optimization | Savings | Effort | +|-------------|---------|--------| +| Embedding cache by text hash (avoid re-embedding identical questions) | 10-30% Gemini cost | Low | +| Local embedding model fallback (sentence-transformers) | 90% embedding cost | Medium | +| Adaptive YouTube polling (back off when idle) | 50-70% quota savings | Low | +| Batch classification (group 5-10 comments per Gemini call) | 60-80% classification cost | Medium | +| Gemini response caching (identical question patterns) | 20-40% answer gen cost | Medium | + +--- + +## 10. Threat Model (STRIDE Analysis) + +### 10.1 Threat Matrix + +#### Spoofing + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| JWT token theft | XSS → localStorage access | **HIGH** | Token blacklist on logout | Token in localStorage is XSS-vulnerable | +| Teacher impersonation | Stolen refresh token (7-day lifetime) | **HIGH** | Refresh token rotation (not implemented) | No refresh token rotation on use | +| WebSocket hijacking | Intercepted auth message | **MEDIUM** | First-message JWT auth + ownership check | No message-level encryption (relies on WSS) | +| OAuth state forgery | Brute-force 128-bit state | **LOW** | 10-min TTL + single-use deletion | Secure by design | + +#### Tampering + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| Answer text injection | Edit answer endpoint with malicious content | **MEDIUM** | Auth + ownership check | No content sanitization before YouTube posting | +| Comment text overflow | Unbounded text field in schema | **MEDIUM** | SQLAlchemy Text column (no DB limit) | No `max_length` on CommentCreate schema | +| Cluster centroid poisoning | Submit adversarial embeddings via manual questions | **LOW** | Online centroid averaging dilutes adversarial inputs | Requires many adversarial inputs to shift centroid significantly | +| Queue payload manipulation | Direct Redis access | **LOW** | Redis bound to 127.0.0.1 | No authentication on Redis (password not configured) | + +#### Repudiation + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| Denied answer approval | Teacher claims they didn't approve a post | **MEDIUM** | `posted_at` timestamp on Answer model | No audit log of who approved what | +| Session data deletion | Teacher deletes session (CASCADE) | **LOW** | Intentional feature | No soft-delete or audit trail | + +#### Information Disclosure + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| YouTube token exposure | Database breach | **HIGH** | Fernet encryption at rest | Encryption key in env var — single key for all tokens | +| Cross-tenant data leakage | Broken ownership check | **HIGH** | JOIN-based ownership verification on all endpoints | Relies on developer discipline (no automated test) | +| JWT secret exposure | Env var leak / config dump | **HIGH** | Single HS256 secret in settings | No key rotation mechanism | +| Error message leakage | Unhandled exceptions in API | **MEDIUM** | FastAPI default error handling | Debug mode configurable — ensure `debug=False` in production | + +#### Denial of Service + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| API rate limit bypass | Distributed IPs | **HIGH** | Per-IP rate limiting (60/min) | No per-user or per-session rate limiting | +| WebSocket flood | Send thousands of messages per second | **HIGH** | None detected | No per-connection message rate limit | +| Queue flooding | Rapid manual question submission (10/request, no cooldown) | **MEDIUM** | Max 10 questions per request | No per-session cooldown or daily limit | +| YouTube quota exhaustion | Create many sessions with polling | **MEDIUM** | Quota check per poll | No limit on concurrent active sessions per teacher | +| Gemini API exhaustion | Trigger thousands of classifications | **MEDIUM** | Semaphore(5) limits concurrency | No per-teacher Gemini call budget | + +#### Elevation of Privilege + +| Threat | Vector | Severity | Current Mitigation | Gap | +|--------|--------|----------|-------------------|-----| +| Access other teacher's data | Modify session_id in API calls | **HIGH** | Ownership JOIN on every query | No RBAC — only owner/not-owner model | +| Admin escalation | No admin role exists | **N/A** | Single-role system (teacher only) | No admin panel or elevated access | +| Worker process compromise | Worker has direct DB write access | **MEDIUM** | Workers share DB credentials with API | No least-privilege DB users per component | + +### 10.2 Top 5 Actionable Recommendations + +| Priority | Recommendation | Addresses | Effort | +|----------|---------------|-----------|--------| +| **P0** | Add `max_length` validation on all text fields in Pydantic schemas | Tampering, DoS | Low | +| **P0** | Add per-connection WebSocket message rate limit (e.g., 10 msg/sec) | DoS | Low | +| **P1** | Migrate JWT from localStorage to SessionStorage; add CSP headers | Spoofing | Medium | +| **P1** | Add Redis AUTH password in production | Tampering | Low | +| **P2** | Implement audit log table for answer approvals and session deletions | Repudiation | Medium | + +### 10.3 Security Configuration Checklist (Production Deployment) + +``` +[ ] Set debug=False in FastAPI +[ ] Set LOG_JSON=True for structured logging +[ ] Set CORS origins to production domain only +[ ] Configure Redis AUTH password +[ ] Ensure ENCRYPTION_KEY is unique per environment (≥32 chars) +[ ] Ensure SECRET_KEY is unique per environment +[ ] Set RATE_LIMIT_ENABLED=True +[ ] Verify PostgreSQL connections are SSL-encrypted +[ ] Disable /docs and /redoc endpoints in production +[ ] Add CSP, X-Frame-Options, X-Content-Type-Options headers +[ ] Configure HTTPS termination (TLS 1.2+) +[ ] Set up log aggregation for security event monitoring +``` diff --git a/frontend/css/styles.css b/frontend/css/styles.css deleted file mode 100644 index c0c0129..0000000 --- a/frontend/css/styles.css +++ /dev/null @@ -1,428 +0,0 @@ -/* ============================================================ - Base Reset & Variables - ============================================================ */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -[hidden] { display: none !important; } - -:root { - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - --color-danger: #dc2626; - --color-danger-hover: #b91c1c; - --color-success: #16a34a; - --color-bg: #f8fafc; - --color-surface: #ffffff; - --color-border: #e2e8f0; - --color-text: #1e293b; - --color-muted: #64748b; - --radius: 8px; - --shadow: 0 1px 3px rgba(0,0,0,0.1); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--color-bg); - color: var(--color-text); - font-size: 14px; - line-height: 1.5; -} - -/* ============================================================ - Auth View - ============================================================ */ -#auth-view { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.auth-card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 32px; - width: 100%; - max-width: 400px; -} - -.auth-card h1 { - font-size: 20px; - font-weight: 700; - color: var(--color-primary); - margin-bottom: 24px; - text-align: center; -} - -.auth-card h2 { - font-size: 16px; - font-weight: 600; - margin-bottom: 16px; -} - -.auth-card form { - display: flex; - flex-direction: column; - gap: 12px; -} - -.auth-card label { - display: flex; - flex-direction: column; - gap: 4px; - font-weight: 500; -} - -.error-msg { - background: #fef2f2; - border: 1px solid #fecaca; - color: var(--color-danger); - border-radius: 4px; - padding: 8px 12px; - font-size: 13px; - margin-bottom: 8px; -} - -/* ============================================================ - Header - ============================================================ */ -.app-header { - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - padding: 0 24px; - height: 56px; - display: flex; - align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 10; - box-shadow: var(--shadow); -} - -.logo { font-weight: 700; font-size: 16px; color: var(--color-primary); } -.user-name { color: var(--color-muted); margin-right: 12px; } -.header-right { display: flex; align-items: center; } - -/* ============================================================ - Main Layout - ============================================================ */ -.app-main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; } - -.panels-grid { - display: grid; - grid-template-columns: 360px 1fr; - gap: 20px; -} - -.left-column, .right-column { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* ============================================================ - Panel - ============================================================ */ -.panel { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 16px; - box-shadow: var(--shadow); -} - -.panel h2 { - font-size: 14px; - font-weight: 600; - margin-bottom: 12px; - display: flex; - align-items: center; - gap: 8px; -} - -/* ============================================================ - Forms - ============================================================ */ -label { - display: flex; - flex-direction: column; - gap: 4px; - font-weight: 500; - font-size: 13px; - margin-bottom: 8px; -} - -input[type="text"], -input[type="email"], -input[type="password"], -textarea { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 13px; - font-family: inherit; - color: var(--color-text); - background: var(--color-bg); - transition: border-color 0.15s; -} - -input:focus, textarea:focus { - outline: none; - border-color: var(--color-primary); - background: #fff; -} - -textarea { resize: vertical; min-height: 80px; } - -.hint { font-size: 12px; color: var(--color-muted); margin-bottom: 6px; } - -/* ============================================================ - Buttons - ============================================================ */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 7px 14px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - background: var(--color-surface); - color: var(--color-text); - transition: background 0.15s, border-color 0.15s; - white-space: nowrap; -} - -.btn:hover { background: #f1f5f9; } - -.btn-primary { - background: var(--color-primary); - color: #fff; - border-color: var(--color-primary); - width: 100%; - margin-top: 4px; -} - -.btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); } - -.btn-danger { - background: var(--color-danger); - color: #fff; - border-color: var(--color-danger); - width: 100%; - margin-top: 8px; -} - -.btn-danger:hover { background: var(--color-danger-hover); } - -.btn-danger-sm { - background: #fff; - color: var(--color-danger); - border-color: #fecaca; - font-size: 12px; - padding: 4px 10px; -} - -.btn-danger-sm:hover { background: #fef2f2; } - -.btn-sm { padding: 5px 10px; font-size: 12px; } - -.btn-loading { - opacity: 0.65; - pointer-events: none; -} - -button[disabled] { opacity: 0.5; pointer-events: none; } - -/* ============================================================ - Badges - ============================================================ */ -.badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} - -.badge-question { background: #dbeafe; color: #1e40af; } -.badge-not-question { background: #f1f5f9; color: #64748b; } -.badge-classifying { background: #fef9c3; color: #92400e; } -.badge-posted { background: #dcfce7; color: #166534; } -.badge-active { background: #dcfce7; color: #166534; } -.badge-disconnected { background: #f1f5f9; color: #64748b; } -.badge-connected { background: #dbeafe; color: #1e40af; } -.badge-pending { background: #fef3c7; color: #92400e; } - -/* ============================================================ - Questions Feed - ============================================================ */ -.questions-feed { - height: 400px; - overflow-y: auto; - scroll-behavior: smooth; - display: flex; - flex-direction: column; - gap: 6px; -} - -.feed-item { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-bg); - font-size: 13px; -} - -.feed-item-author { - font-weight: 600; - color: var(--color-primary); - white-space: nowrap; - flex-shrink: 0; - font-size: 12px; -} - -.feed-item-text { flex: 1; } -.feed-item-badge { flex-shrink: 0; } - -.empty-msg { color: var(--color-muted); font-size: 13px; text-align: center; padding: 24px 0; } - -/* ============================================================ - Clusters Panel - ============================================================ */ -#clusters-list { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 600px; - overflow-y: auto; -} - -.cluster-card { - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 12px; - background: var(--color-bg); -} - -.cluster-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.cluster-title { font-weight: 600; font-size: 13px; } -.cluster-count { color: var(--color-muted); font-size: 12px; } - -.cluster-answer { - background: #fff; - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 10px; - font-size: 13px; - margin-bottom: 8px; - white-space: pre-wrap; - max-height: 120px; - overflow-y: auto; -} - -.cluster-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -/* ============================================================ - YouTube Panel - ============================================================ */ -.yt-status-row { - margin-bottom: 10px; -} - -/* ============================================================ - Metrics Grid - ============================================================ */ -.metrics-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.metric-card { - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 10px 12px; - text-align: center; -} - -.metric-value { font-size: 24px; font-weight: 700; color: var(--color-primary); } -.metric-label { font-size: 11px; color: var(--color-muted); margin-top: 2px; } - -/* ============================================================ - Session Info - ============================================================ */ -.session-info { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; -} - -/* ============================================================ - Toast Notifications - ============================================================ */ -#toast-container { - position: fixed; - bottom: 24px; - right: 24px; - display: flex; - flex-direction: column; - gap: 8px; - z-index: 1000; -} - -.toast { - padding: 10px 16px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - animation: slideIn 0.2s ease; - max-width: 320px; -} - -.toast-success { background: #166534; color: #fff; } -.toast-error { background: #991b1b; color: #fff; } -.toast-info { background: #1e40af; color: #fff; } -.toast-warning { background: #92400e; color: #fff; } - -@keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } -} - -/* ============================================================ - Responsive - ============================================================ */ -@media (max-width: 900px) { - .panels-grid { grid-template-columns: 1fr; } -} - -/* ============================================================ - Hidden utility - ============================================================ */ -.hidden { display: none !important; } diff --git a/frontend/index.html b/frontend/index.html index 1c366fa..98e0244 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ AI Live Doubt Manager + + +
diff --git a/frontend/js/api.js b/frontend/js/api.js deleted file mode 100644 index a185284..0000000 --- a/frontend/js/api.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * API client for AI Live Doubt Manager. - * Handles all fetch calls with auth, error handling, and 401 auto-logout. - */ - -class API { - constructor() { - this.token = localStorage.getItem('token'); - } - - async request(method, path, body = null) { - const opts = { - method, - headers: { - 'Content-Type': 'application/json', - ...(this.token ? { 'Authorization': `Bearer ${this.token}` } : {}), - }, - ...(body !== null ? { body: JSON.stringify(body) } : {}), - }; - - const resp = await fetch(path, opts); - - if (resp.status === 401) { - this.token = null; - localStorage.removeItem('token'); - // Emit custom event so app can react without causing reload loops - window.dispatchEvent(new CustomEvent('auth:expired')); - throw new Error('Unauthorized'); - } - - if (resp.status === 429) { - throw new Error('rate_limit'); - } - - if (!resp.ok) { - let message = `HTTP ${resp.status}`; - try { - const errBody = await resp.json(); - message = errBody.detail || errBody.message || message; - } catch { - // ignore parse error - } - throw new Error(message); - } - - if (resp.status === 204) return null; - return resp.json(); - } - - // ---------------------------------------------------------------- - // Auth - // ---------------------------------------------------------------- - - async login(email, password) { - const data = await this.request('POST', '/api/v1/auth/login', { - email, - password, - }); - this.token = data.access_token; - localStorage.setItem('token', this.token); - return data; - } - - async register(email, password, name) { - const data = await this.request('POST', '/api/v1/auth/register', { - email, password, name, - }); - return data; - } - - async logout() { - try { await this.request('POST', '/api/v1/auth/logout'); } catch {} - this.token = null; - localStorage.removeItem('token'); - window.location.reload(); - } - - async getMe() { - return this.request('GET', '/api/v1/auth/me'); - } - - // ---------------------------------------------------------------- - // Sessions - // ---------------------------------------------------------------- - - async getSessions() { - return this.request('GET', '/api/v1/sessions/'); - } - - async createSession(data) { - return this.request('POST', '/api/v1/sessions/', data); - } - - async endSession(id) { - return this.request('POST', `/api/v1/sessions/${id}/end`); - } - - async getSessionComments(id, limit = 100, offset = 0) { - return this.request('GET', `/api/v1/sessions/${id}/comments?limit=${limit}&offset=${offset}`); - } - - async getSessionClusters(id) { - return this.request('GET', `/api/v1/sessions/${id}/clusters`); - } - - async getSessionStats(id) { - return this.request('GET', `/api/v1/dashboard/sessions/${id}/stats`); - } - - // ---------------------------------------------------------------- - // YouTube - // ---------------------------------------------------------------- - - async getYouTubeAuthURL(returnUrl = '/app') { - return this.request('GET', `/api/v1/youtube/auth/url?return_url=${encodeURIComponent(returnUrl)}`); - } - - async getYouTubeStatus() { - return this.request('GET', '/api/v1/youtube/auth/status'); - } - - async disconnectYouTube() { - return this.request('DELETE', '/api/v1/youtube/auth/disconnect'); - } - - async validateVideo(videoId) { - return this.request('GET', `/api/v1/youtube/videos/${videoId}/validate`); - } - - // ---------------------------------------------------------------- - // Dashboard - // ---------------------------------------------------------------- - - async submitManualQuestion(sessionId, text) { - return this.request('POST', `/api/v1/dashboard/sessions/${sessionId}/manual-question`, { text }); - } - - async approveAnswer(answerId) { - return this.request('POST', `/api/v1/dashboard/answers/${answerId}/approve`); - } - - async editAnswer(answerId, text) { - return this.request('POST', `/api/v1/dashboard/answers/${answerId}/edit`, { text }); - } - - // ---------------------------------------------------------------- - // Metrics - // ---------------------------------------------------------------- - - async getMetrics() { - return this.request('GET', '/api/v1/metrics'); - } -} - -window.api = new API(); diff --git a/frontend/js/app.js b/frontend/js/app.js deleted file mode 100644 index a29fd7d..0000000 --- a/frontend/js/app.js +++ /dev/null @@ -1,616 +0,0 @@ -/** - * Main application logic for AI Live Doubt Manager dashboard. - */ - -const app = (() => { - // ---------------------------------------------------------------- - // State - // ---------------------------------------------------------------- - let currentUser = null; - let activeSession = null; - let feedCount = 0; - let statsRefreshTimer = null; - - // ---------------------------------------------------------------- - // Toast Notifications - // ---------------------------------------------------------------- - function showToast(message, type = 'info') { - const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => toast.remove(), 4000); - } - - // ---------------------------------------------------------------- - // Loading state helpers - // ---------------------------------------------------------------- - function setLoading(btn, loading, loadingText = 'Loading...') { - if (!btn) return; - if (loading) { - btn._originalText = btn.textContent; - btn.textContent = loadingText; - btn.classList.add('btn-loading'); - btn.disabled = true; - } else { - btn.textContent = btn._originalText || btn.textContent; - btn.classList.remove('btn-loading'); - btn.disabled = false; - } - } - - // ---------------------------------------------------------------- - // View switching - // ---------------------------------------------------------------- - function showDashboard() { - document.getElementById('auth-view').hidden = true; - document.getElementById('dashboard-view').hidden = false; - } - - function showAuth() { - document.getElementById('auth-view').hidden = false; - document.getElementById('dashboard-view').hidden = true; - } - - function showLogin() { - document.getElementById('login-form').classList.remove('hidden'); - document.getElementById('register-form').classList.add('hidden'); - document.getElementById('login-error').classList.add('hidden'); - } - - function showRegister() { - document.getElementById('login-form').classList.add('hidden'); - document.getElementById('register-form').classList.remove('hidden'); - document.getElementById('register-error').classList.add('hidden'); - } - - // ---------------------------------------------------------------- - // Auth Handlers - // ---------------------------------------------------------------- - async function handleLogin(event) { - event.preventDefault(); - const email = document.getElementById('login-email').value; - const password = document.getElementById('login-password').value; - const btn = document.getElementById('login-btn'); - const errEl = document.getElementById('login-error'); - - errEl.classList.add('hidden'); - setLoading(btn, true, 'Signing in...'); - try { - await api.login(email, password); - await initDashboard(); - } catch (e) { - errEl.textContent = e.message || 'Login failed'; - errEl.classList.remove('hidden'); - } finally { - setLoading(btn, false); - } - } - - async function handleRegister(event) { - event.preventDefault(); - const name = document.getElementById('register-name').value; - const email = document.getElementById('register-email').value; - const password = document.getElementById('register-password').value; - const btn = document.getElementById('register-btn'); - const errEl = document.getElementById('register-error'); - - errEl.classList.add('hidden'); - setLoading(btn, true, 'Creating account...'); - try { - await api.register(email, password, name); - // Auto-login after registration - await api.login(email, password); - await initDashboard(); - } catch (e) { - errEl.textContent = e.message || 'Registration failed'; - errEl.classList.remove('hidden'); - } finally { - setLoading(btn, false); - } - } - - async function logout() { - await api.logout(); - } - - // ---------------------------------------------------------------- - // Dashboard Initialization - // ---------------------------------------------------------------- - async function initDashboard() { - try { - currentUser = await api.getMe(); - } catch (e) { - showAuth(); - return; - } - - document.getElementById('user-name').textContent = - currentUser.name || currentUser.email || ''; - - showDashboard(); - await loadYouTubeStatus(); - - // Load most recent active session if any - try { - const sessions = await api.getSessions(); - const active = sessions.find(s => s.is_active); - if (active) { - setActiveSession(active); - } - } catch (e) { - // ignore - } - } - - // ---------------------------------------------------------------- - // Session Management - // ---------------------------------------------------------------- - async function handleCreateSession(event) { - event.preventDefault(); - const title = document.getElementById('session-title').value.trim(); - const videoId = document.getElementById('session-video-id').value.trim(); - const btn = document.getElementById('create-session-btn'); - - setLoading(btn, true, 'Starting...'); - try { - const session = await api.createSession({ - title, - youtube_video_id: videoId || null, - }); - setActiveSession(session); - showToast('Session started!', 'success'); - } catch (e) { - showToast(e.message || 'Failed to create session', 'error'); - } finally { - setLoading(btn, false); - } - } - - function setActiveSession(session) { - activeSession = session; - - document.getElementById('no-session').hidden = true; - document.getElementById('active-session').hidden = false; - document.getElementById('active-session-title').textContent = session.title; - - const videoEl = document.getElementById('active-session-video'); - if (session.youtube_video_id) { - videoEl.textContent = `YouTube: ${session.youtube_video_id}`; - } else { - videoEl.textContent = 'Manual mode (no YouTube video)'; - } - - // Connect WebSocket - dashboardWS.disconnect(); - registerWebSocketHandlers(); - dashboardWS.connect(session.id); - - // Start stats refresh - refreshStats(); - if (statsRefreshTimer) clearInterval(statsRefreshTimer); - statsRefreshTimer = setInterval(refreshStats, 10000); - - // Load existing comments and clusters - loadComments(); - loadClusters(); - } - - async function endSession() { - if (!activeSession) return; - const btn = document.getElementById('end-session-btn'); - setLoading(btn, true, 'Ending...'); - try { - await api.endSession(activeSession.id); - activeSession = null; - dashboardWS.disconnect(); - if (statsRefreshTimer) clearInterval(statsRefreshTimer); - - document.getElementById('no-session').hidden = false; - document.getElementById('active-session').hidden = true; - document.getElementById('questions-feed').innerHTML = - '

Session ended.

'; - document.getElementById('clusters-list').innerHTML = - '

No clusters yet.

'; - feedCount = 0; - document.getElementById('feed-count').textContent = '0'; - showToast('Session ended', 'info'); - } catch (e) { - showToast(e.message || 'Failed to end session', 'error'); - } finally { - setLoading(btn, false); - } - } - - // ---------------------------------------------------------------- - // YouTube OAuth - // ---------------------------------------------------------------- - async function connectYouTube() { - const btn = document.getElementById('yt-connect-btn'); - setLoading(btn, true, 'Connecting...'); - try { - const data = await api.getYouTubeAuthURL('/app'); - const popup = window.open( - data.url, - 'youtube_oauth', - 'width=600,height=700,noopener' - ); - - // Listen for postMessage from OAuth result page - const onMessage = (event) => { - if (event.origin !== location.origin) return; - if (event.data && event.data.type === 'youtube_oauth_complete') { - window.removeEventListener('message', onMessage); - if (popup && !popup.closed) popup.close(); - loadYouTubeStatus(); - showToast('YouTube connected!', 'success'); - } - }; - window.addEventListener('message', onMessage); - - // Clean up if popup is closed without completing - const pollClosed = setInterval(() => { - if (popup && popup.closed) { - clearInterval(pollClosed); - window.removeEventListener('message', onMessage); - setLoading(btn, false); - loadYouTubeStatus(); - } - }, 500); - } catch (e) { - showToast(e.message || 'Failed to start YouTube OAuth', 'error'); - setLoading(btn, false); - } - } - - async function disconnectYouTube() { - const btn = document.getElementById('yt-disconnect-btn'); - setLoading(btn, true, 'Disconnecting...'); - try { - await api.disconnectYouTube(); - await loadYouTubeStatus(); - showToast('YouTube disconnected', 'info'); - } catch (e) { - showToast(e.message || 'Failed to disconnect', 'error'); - } finally { - setLoading(btn, false); - } - } - - async function loadYouTubeStatus() { - try { - const status = await api.getYouTubeStatus(); - const badge = document.getElementById('yt-status-badge'); - const connectRow = document.getElementById('yt-connect-row'); - const connectedRow = document.getElementById('yt-connected-row'); - const expiresEl = document.getElementById('yt-expires-at'); - - if (status.connected) { - badge.textContent = 'Connected'; - badge.className = 'badge badge-connected'; - connectRow.hidden = true; - connectedRow.hidden = false; - if (status.expires_at) { - expiresEl.textContent = `Token expires: ${new Date(status.expires_at).toLocaleString()}`; - } - } else { - badge.textContent = 'Disconnected'; - badge.className = 'badge badge-disconnected'; - connectRow.hidden = false; - connectedRow.hidden = true; - // Re-enable connect button in case it was loading - setLoading(document.getElementById('yt-connect-btn'), false); - } - } catch (e) { - // ignore — not critical - } - } - - // ---------------------------------------------------------------- - // Manual Questions - // ---------------------------------------------------------------- - async function submitManualQuestions() { - if (!activeSession) { - showToast('Start a session first', 'warning'); - return; - } - const textarea = document.getElementById('manual-textarea'); - const text = textarea.value.trim(); - if (!text) return; - - const btn = document.getElementById('manual-submit-btn'); - setLoading(btn, true, 'Submitting...'); - try { - const result = await api.submitManualQuestion(activeSession.id, text); - textarea.value = ''; - showToast(`${result.created} question(s) submitted`, 'success'); - } catch (e) { - if (e.message === 'rate_limit') { - showToast('Rate limit hit, try again in 60s', 'warning'); - } else { - showToast(e.message || 'Failed to submit questions', 'error'); - } - } finally { - setLoading(btn, false); - } - } - - // ---------------------------------------------------------------- - // Feed (Questions) - // ---------------------------------------------------------------- - async function loadComments() { - if (!activeSession) return; - try { - const comments = await api.getSessionComments(activeSession.id, 100, 0); - const feed = document.getElementById('questions-feed'); - feed.innerHTML = ''; - feedCount = 0; - comments.forEach(c => appendFeedItem(c)); - } catch (e) { - // ignore - } - } - - function appendFeedItem(comment) { - const feed = document.getElementById('questions-feed'); - - // Remove empty message if present - const empty = feed.querySelector('.empty-msg'); - if (empty) empty.remove(); - - const item = document.createElement('div'); - item.className = 'feed-item'; - item.dataset.commentId = comment.id; - - let badgeHtml = 'Classifying...'; - if (comment.is_question === true) { - badgeHtml = 'Question'; - } else if (comment.is_question === false) { - badgeHtml = 'Not a question'; - } - - item.innerHTML = ` - ${escHtml(comment.author_name || 'Unknown')} - ${escHtml(comment.text)} - ${badgeHtml} - `; - - feed.prepend(item); - feedCount++; - document.getElementById('feed-count').textContent = feedCount; - } - - function updateFeedItemBadge(commentId, isQuestion) { - const item = document.querySelector(`[data-comment-id="${commentId}"]`); - if (!item) return; - const badgeEl = item.querySelector('.feed-item-badge'); - if (!badgeEl) return; - if (isQuestion) { - badgeEl.innerHTML = 'Question'; - } else { - badgeEl.innerHTML = 'Not a question'; - } - } - - // ---------------------------------------------------------------- - // Clusters - // ---------------------------------------------------------------- - async function loadClusters() { - if (!activeSession) return; - try { - const clusters = await api.getSessionClusters(activeSession.id); - const list = document.getElementById('clusters-list'); - list.innerHTML = ''; - if (!clusters.length) { - list.innerHTML = '

No clusters yet.

'; - return; - } - clusters.forEach(cluster => upsertClusterCard(cluster)); - } catch (e) { - // ignore - } - } - - function upsertClusterCard(cluster) { - const list = document.getElementById('clusters-list'); - - // Remove empty message - const empty = list.querySelector('.empty-msg'); - if (empty) empty.remove(); - - let card = document.querySelector(`[data-cluster-id="${cluster.id}"]`); - if (!card) { - card = document.createElement('div'); - card.className = 'cluster-card'; - card.dataset.clusterId = cluster.id; - list.prepend(card); - } - - const answers = cluster.answers || []; - const latestAnswer = answers[answers.length - 1]; - - let answerHtml = '

Generating answer...

'; - let actionsHtml = ''; - - if (latestAnswer) { - const postedBadge = latestAnswer.is_posted - ? 'Posted' - : 'Pending'; - - answerHtml = ` -
${escHtml(latestAnswer.text)}
-
${postedBadge}
- `; - actionsHtml = ` - - `; - if (!latestAnswer.is_posted) { - actionsHtml += ` - - `; - } - } - - card.innerHTML = ` -
- ${escHtml(cluster.title || 'Untitled Cluster')} - ${cluster.comment_count || 0} questions -
- ${answerHtml} -
${actionsHtml}
- `; - } - - async function approveAnswer(answerId) { - const btn = document.getElementById(`approve-btn-${answerId}`); - setLoading(btn, true, 'Posting...'); - try { - await api.approveAnswer(answerId); - showToast('Answer approved for posting', 'success'); - loadClusters(); - } catch (e) { - if (e.message === 'rate_limit') { - showToast('Rate limit hit, try again in 60s', 'warning'); - } else { - showToast(e.message || 'Failed to approve answer', 'error'); - } - setLoading(btn, false); - } - } - - function copyAnswer(answerId) { - const el = document.getElementById(`answer-text-${answerId}`); - if (!el) return; - navigator.clipboard.writeText(el.textContent).then(() => { - showToast('Answer copied to clipboard', 'success'); - }).catch(() => { - showToast('Failed to copy', 'error'); - }); - } - - // ---------------------------------------------------------------- - // Stats - // ---------------------------------------------------------------- - async function refreshStats() { - if (!activeSession) return; - try { - const stats = await api.getSessionStats(activeSession.id); - document.getElementById('stat-total').textContent = stats.total_comments ?? '—'; - document.getElementById('stat-questions').textContent = stats.questions ?? '—'; - document.getElementById('stat-clusters').textContent = stats.clusters ?? '—'; - document.getElementById('stat-posted').textContent = stats.answers_posted ?? '—'; - } catch (e) { - // ignore - } - } - - // ---------------------------------------------------------------- - // WebSocket Event Handlers - // ---------------------------------------------------------------- - function registerWebSocketHandlers() { - dashboardWS.on('connected', () => { - console.log('WebSocket connected'); - }); - - dashboardWS.on('comment_created', (data) => { - appendFeedItem(data); - refreshStats(); - }); - - dashboardWS.on('comment_classified', (data) => { - updateFeedItemBadge(data.comment_id, data.is_question); - refreshStats(); - }); - - dashboardWS.on('cluster_created', (data) => { - upsertClusterCard(data); - refreshStats(); - }); - - dashboardWS.on('cluster_updated', (data) => { - upsertClusterCard(data); - refreshStats(); - }); - - dashboardWS.on('answer_ready', (data) => { - loadClusters(); - showToast('New answer generated — review in Clusters panel', 'info'); - }); - - dashboardWS.on('answer_posted', (data) => { - loadClusters(); - refreshStats(); - showToast('Answer posted to YouTube!', 'success'); - }); - - dashboardWS.on('error', (data) => { - showToast(data.msg || 'Connection error', 'error'); - }); - } - - // ---------------------------------------------------------------- - // Utility - // ---------------------------------------------------------------- - function escHtml(str) { - if (!str) return ''; - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - // ---------------------------------------------------------------- - // Bootstrap - // ---------------------------------------------------------------- - async function init() { - // Handle auth expiry globally - window.addEventListener('auth:expired', () => { - currentUser = null; - activeSession = null; - dashboardWS.disconnect(); - showAuth(); - showLogin(); - }); - - // Check for stored token - const token = localStorage.getItem('token'); - if (token) { - api.token = token; - try { - await initDashboard(); - } catch (e) { - // Token invalid — show login - localStorage.removeItem('token'); - api.token = null; - showAuth(); - showLogin(); - } - } else { - showAuth(); - showLogin(); - } - } - - // Start on DOMContentLoaded - document.addEventListener('DOMContentLoaded', init); - - // Public API - return { - handleLogin, - handleRegister, - logout, - showLogin, - showRegister, - handleCreateSession, - endSession, - connectYouTube, - disconnectYouTube, - submitManualQuestions, - approveAnswer, - copyAnswer, - }; -})(); diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js deleted file mode 100644 index 12e12dc..0000000 --- a/frontend/js/websocket.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * WebSocket client with exponential backoff reconnection. - */ - -class DashboardWebSocket { - constructor() { - this.ws = null; - this.handlers = {}; - this.retryCount = 0; - this.maxRetries = 10; - this._sessionId = null; - } - - connect(sessionId) { - this._sessionId = sessionId; - const token = localStorage.getItem('token'); - const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''; - const url = `${protocol}://${location.host}/ws/${sessionId}?connection_id=${Date.now()}${tokenParam}`; - - this.ws = new WebSocket(url); - - this.ws.onopen = () => { - this.retryCount = 0; - this._emit('connected', {}); - }; - - this.ws.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - this._emit(msg.type, msg.data || msg); - } catch (err) { - console.error('WS parse error', err); - } - }; - - this.ws.onclose = (e) => { - // Auth/forbidden errors — do not retry - if (e.code === 4001 || e.code === 4003) { - this._emit('error', { msg: 'WebSocket auth error' }); - return; - } - if (this.retryCount >= this.maxRetries) { - this._emit('error', { msg: 'Connection lost after maximum retries' }); - return; - } - const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000); - this.retryCount++; - setTimeout(() => { - if (this._sessionId) this.connect(this._sessionId); - }, delay); - }; - - this.ws.onerror = () => { - this._emit('error', { msg: 'WebSocket error' }); - }; - } - - on(type, cb) { - this.handlers[type] = cb; - } - - _emit(type, data) { - if (this.handlers[type]) this.handlers[type](data); - } - - send(obj) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(obj)); - } - } - - disconnect() { - this._sessionId = null; - if (this.ws) { - this.ws.onclose = null; // prevent reconnect - this.ws.close(); - this.ws = null; - } - } -} - -window.dashboardWS = new DashboardWebSocket(); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b853323..5184eb3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import { RegisterPage } from './pages/RegisterPage'; import { DashboardPage } from './pages/DashboardPage'; import LandingPage from './pages/LandingPage'; import { SettingsPage } from './pages/SettingsPage'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { ToastContainer } from './components/Toast/Toast'; import { GlobalShortcutsHandler } from './components/GlobalShortcutsHandler'; @@ -23,7 +24,9 @@ export default function App() { path="/dashboard" element={ - + + + } /> @@ -31,7 +34,9 @@ export default function App() { path="/settings" element={ - + + + } /> diff --git a/frontend/src/components/Dashboard/ActivityLog.jsx b/frontend/src/components/Dashboard/ActivityLog.jsx index 973d97a..6fdb06a 100644 --- a/frontend/src/components/Dashboard/ActivityLog.jsx +++ b/frontend/src/components/Dashboard/ActivityLog.jsx @@ -30,10 +30,10 @@ export function ActivityLog({ sessionEvents }) { return (
- {events.map((msg, i) => { + {events.map((msg) => { const meta = EVENT_META[msg.type]; return ( -
+
{meta.icon} {meta.label(msg.data)} {relativeTime(msg.timestamp)} diff --git a/frontend/src/components/Dashboard/AnalyticsPanel.jsx b/frontend/src/components/Dashboard/AnalyticsPanel.jsx index 18e40ad..4e31c9c 100644 --- a/frontend/src/components/Dashboard/AnalyticsPanel.jsx +++ b/frontend/src/components/Dashboard/AnalyticsPanel.jsx @@ -3,8 +3,9 @@ import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; -import { getSessionAnalytics, getSessionClusters, fetchAllComments } from '../../services/api'; +import { getSessionAnalytics, getSessionClusters, getClusterComments } from '../../services/api'; import { ActivityLog } from './ActivityLog'; +import { Skeleton } from '../Skeleton'; const ANALYTICS_EVENTS = new Set([ 'comment_created', 'comment_classified', @@ -70,15 +71,18 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { async function handleExportCSV() { setExporting(true); try { - const [clusters, comments] = await Promise.all([ - getSessionClusters(sessionId, token), - fetchAllComments(sessionId, token), - ]); + const clusters = await getSessionClusters(sessionId, token); const rows = [['Question', 'Answer', 'Cluster', 'Timestamp', 'Is Posted']]; - (clusters || []).forEach(cluster => { - const clusterComments = (comments || []).filter(c => c.cluster_id === cluster.id); + for (const cluster of (clusters || [])) { + let comments; + try { + comments = await getClusterComments(cluster.id, token); + } catch (e) { + console.warn(`Skipping cluster ${cluster.id} in export:`, e.message); + continue; + } const latestAnswer = cluster.answers?.[cluster.answers.length - 1]; - clusterComments.forEach(comment => { + for (const comment of (comments || [])) { rows.push([ comment.text, latestAnswer?.text || '', @@ -86,8 +90,8 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { new Date(comment.created_at).toLocaleString(), latestAnswer?.is_posted ? 'Yes' : 'No', ]); - }); - }); + } + } const csv = rows .map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) .join('\n'); @@ -102,20 +106,29 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { async function handleExportJSON() { setExporting(true); try { - const [clusters, comments] = await Promise.all([ - getSessionClusters(sessionId, token), - fetchAllComments(sessionId, token), - ]); - const output = (clusters || []).map(cluster => ({ - cluster_id: cluster.id, - title: cluster.title, - comment_count: cluster.comment_count, - answer: cluster.answers?.[cluster.answers.length - 1]?.text || null, - is_posted: cluster.answers?.[cluster.answers.length - 1]?.is_posted ?? false, - questions: (comments || []) - .filter(c => c.cluster_id === cluster.id) - .map(c => ({ text: c.text, author: c.author_name, timestamp: c.created_at })), - })); + const clusters = await getSessionClusters(sessionId, token); + const output = []; + for (const cluster of (clusters || [])) { + let comments; + try { + comments = await getClusterComments(cluster.id, token); + } catch (e) { + console.warn(`Skipping cluster ${cluster.id} in export:`, e.message); + continue; + } + output.push({ + cluster_id: cluster.id, + title: cluster.title, + comment_count: cluster.comment_count, + answer: cluster.answers?.[cluster.answers.length - 1]?.text || null, + is_posted: cluster.answers?.[cluster.answers.length - 1]?.is_posted ?? false, + questions: (comments || []).map(c => ({ + text: c.text, + author: c.author_name, + timestamp: c.created_at, + })), + }); + } downloadBlob( JSON.stringify(output, null, 2), 'application/json', @@ -128,7 +141,20 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { } } - if (loading) return

Loading analytics...

; + if (loading) return ( +
+

Session Analytics

+
+ {[1, 2, 3, 4].map(i => ( +
+ + +
+ ))} +
+ +
+ ); if (error) return

{error}

; // Derive cumulative line chart data @@ -203,7 +229,16 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) {
) : ( -

No time data yet.

+
+ + + + + + +

No data yet

+

Analytics will appear once questions start coming in

+
)} {data.top_clusters.length > 0 && ( diff --git a/frontend/src/components/Dashboard/ClusterDetailsModal.jsx b/frontend/src/components/Dashboard/ClusterDetailsModal.jsx index d11baa7..9103bcd 100644 --- a/frontend/src/components/Dashboard/ClusterDetailsModal.jsx +++ b/frontend/src/components/Dashboard/ClusterDetailsModal.jsx @@ -1,3 +1,5 @@ +import { Skeleton } from '../Skeleton'; + export function ClusterDetailsModal({ cluster, comments, onClose }) { const answers = cluster.answers ?? []; const latestAnswer = answers.length > 0 ? answers[answers.length - 1] : null; @@ -10,9 +12,24 @@ export function ClusterDetailsModal({ cluster, comments, onClose }) {
{comments === null ? ( -

Loading questions...

+
+ {[1, 2, 3, 4].map(i => ( +
+ + +
+ ))} +
) : comments.length === 0 ? ( -

No questions assigned yet.

+
+ + + + + +

No questions assigned

+

Questions will appear here once grouped into this cluster

+
) : ( comments.map(c => (
diff --git a/frontend/src/components/Dashboard/ClustersPanel.jsx b/frontend/src/components/Dashboard/ClustersPanel.jsx index 2d5c856..48ace26 100644 --- a/frontend/src/components/Dashboard/ClustersPanel.jsx +++ b/frontend/src/components/Dashboard/ClustersPanel.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef } from 'react'; -import { getSessionClusters, approveAnswer, editAnswer, getClusterComments } from '../../services/api'; +import { getSessionClusters, approveAnswer, editAnswer, getClusterComments, getRepresentativeQuestion } from '../../services/api'; import { showToast } from '../../hooks/useToast'; import { ClusterDetailsModal } from './ClusterDetailsModal'; +import { Skeleton } from '../Skeleton'; const REFETCH_EVENTS = new Set(['cluster_created', 'cluster_updated', 'answer_ready', 'answer_posted']); @@ -16,7 +17,11 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) const [clusterFilter, setClusterFilter] = useState('all'); const [selectedCluster, setSelectedCluster] = useState(null); const [modalComments, setModalComments] = useState(null); + const [expandedIds, setExpandedIds] = useState(new Set()); + const [repQuestions, setRepQuestions] = useState({}); const commentCache = useRef(new Map()); + const debounceRef = useRef(null); + const fetchedClusterIds = useRef(new Set()); async function fetchClusters() { const data = await getSessionClusters(sessionId, token); @@ -54,24 +59,54 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) handleApprove(latest.id); } }; - return () => { approveFirstRef.current = null; }; // cleanup on unmount — prevents stale calls + return () => { approveFirstRef.current = null; }; }, [clusters, approveFirstRef]); - // WS-triggered refetch — targeted cache invalidation + // WS-triggered refetch — debounced useEffect(() => { if (!wsMessages || wsMessages.length === 0) return; const last = wsMessages[wsMessages.length - 1]; if (last && REFETCH_EVENTS.has(last.type)) { const affectedId = last.data?.cluster_id ?? last.data?.id ?? null; - if (affectedId) { - commentCache.current.delete(affectedId); - } else { - commentCache.current.clear(); - } - fetchClusters().catch(() => {}); + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + if (affectedId) commentCache.current.delete(affectedId); + else commentCache.current.clear(); + fetchClusters().catch(() => {}); + }, 1000); } + return () => clearTimeout(debounceRef.current); }, [wsMessages]); + // Reset repQuestions on session change + useEffect(() => { + fetchedClusterIds.current = new Set(); + setRepQuestions({}); + }, [sessionId]); + + // Fetch representative question for each cluster (once per cluster id) + useEffect(() => { + clusters.forEach(cluster => { + if (cluster.comment_count <= 1) return; + if (fetchedClusterIds.current.has(cluster.id)) return; + fetchedClusterIds.current.add(cluster.id); + getRepresentativeQuestion(cluster.id, token) + .then(data => { + if (data?.text) setRepQuestions(prev => ({ ...prev, [cluster.id]: data.text })); + }) + .catch(() => {}); + }); + }, [clusters]); + + function toggleExpand(id) { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + async function openClusterModal(cluster) { setSelectedCluster(cluster); if (commentCache.current.has(cluster.id)) { @@ -145,8 +180,11 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) }); return ( -
-

Clusters & Answers

+
+

+ Clusters & Answers + {clusters.length} +

{['all', 'pending', 'approved'].map(tab => ( @@ -162,14 +200,12 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) {isLoadingInitial ? (
- {[1, 2].map(i => ( + {[1, 2, 3].map(i => (
-
-
-
+
+ +
-
-
))}
@@ -179,10 +215,20 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef })
{filteredClusters.length === 0 ? (
- 🤖 -

{clusters.length === 0 ? 'No clusters yet' : 'No clusters match this filter'}

+ + + + + + + +

+ {clusters.length === 0 ? 'No clusters yet' : 'No clusters match this filter'} +

{clusters.length === 0 && ( -

Questions cluster automatically after 5 similar ones arrive

+

+ Clusters form automatically once enough questions arrive +

)}
) : ( @@ -190,87 +236,104 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) const answers = cluster.answers || []; const latestAnswer = answers[answers.length - 1]; const isEditing = latestAnswer && editingAnswerId === latestAnswer.id; + const isExpanded = expandedIds.has(cluster.id); + const isApproved = latestAnswer?.is_posted === true; + return ( -
-
- openClusterModal(cluster)} - > +
+
toggleExpand(cluster.id)}> + {cluster.title || 'Untitled Cluster'} + {cluster.comment_count || 0}q + {isApproved && ( + ✓ POSTED + )} openClusterModal(cluster)} + className="cluster-expand-icon" + onClick={e => { e.stopPropagation(); openClusterModal(cluster); }} + title="View details" > - {cluster.comment_count || 0} questions + ▼
- {latestAnswer ? ( - <> - {isEditing ? ( -