diff --git a/backend/alembic/versions/f2a1c9d4e801_comments.py b/backend/alembic/versions/f2a1c9d4e801_comments.py
new file mode 100644
index 0000000..99e095d
--- /dev/null
+++ b/backend/alembic/versions/f2a1c9d4e801_comments.py
@@ -0,0 +1,31 @@
+"""Moderated discussion comments.
+
+Revision ID: f2a1c9d4e801
+Revises: e8d4f1a27c93
+"""
+from alembic import op
+
+revision = "f2a1c9d4e801"
+down_revision = "e8d4f1a27c93"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS comments (
+ id SERIAL PRIMARY KEY,
+ article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE,
+ question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ content TEXT NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )""")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_comments_id ON comments (id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_comments_article_id ON comments (article_id)")
+ op.execute("CREATE INDEX IF NOT EXISTS ix_comments_question_id ON comments (question_id)")
+
+
+def downgrade():
+ op.execute("DROP TABLE IF EXISTS comments")
diff --git a/backend/alembic/versions/g4b7e2f5a903_share_tokens.py b/backend/alembic/versions/g4b7e2f5a903_share_tokens.py
new file mode 100644
index 0000000..d71d0d5
--- /dev/null
+++ b/backend/alembic/versions/g4b7e2f5a903_share_tokens.py
@@ -0,0 +1,21 @@
+"""Public quiz share tokens.
+
+Revision ID: g4b7e2f5a903
+Revises: f2a1c9d4e801
+"""
+from alembic import op
+
+revision = "g4b7e2f5a903"
+down_revision = "f2a1c9d4e801"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS share_token VARCHAR(64)")
+ op.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_quizzes_share_token ON quizzes (share_token)")
+
+
+def downgrade():
+ op.execute("DROP INDEX IF EXISTS uq_quizzes_share_token")
+ op.execute("ALTER TABLE quizzes DROP COLUMN IF EXISTS share_token")
diff --git a/backend/app/main.py b/backend/app/main.py
index 98b521a..6d00537 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
-from app.routers import study_tools, uploads, articles
+from app.routers import study_tools, uploads, articles, comments, share
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -617,6 +617,8 @@ app.include_router(uploads.router)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
+app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
+app.include_router(share.router, prefix="/api/share", tags=["share"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index d318ef3..affc34b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -10,6 +10,7 @@ from app.models.favorite import Favorite
from app.models.user_note import UserNote
from app.models.lab_reference import LabReference
from app.models.article import Article, QuestionArticleLink
+from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
__all__ = [
@@ -27,6 +28,7 @@ __all__ = [
"LabReference",
"Article",
"QuestionArticleLink",
+ "Comment",
"FlashcardDeck",
"Flashcard",
"FlashcardDeckRating",
diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py
new file mode 100644
index 0000000..fc13b22
--- /dev/null
+++ b/backend/app/models/comment.py
@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey
+from sqlalchemy.orm import relationship
+
+from app.database import Base
+
+
+class Comment(Base):
+ """Moderated discussion comment on an article or a question."""
+
+ __tablename__ = "comments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=True)
+ question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ content = Column(Text, nullable=False)
+ status = Column(String, default="pending") # pending | approved | rejected
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+ user = relationship("User")
diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py
index ab4c2f2..6b9f2d8 100644
--- a/backend/app/models/quiz.py
+++ b/backend/app/models/quiz.py
@@ -28,6 +28,7 @@ class Quiz(Base):
max_attempts = Column(Integer, nullable=True) # null = unlimited
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review
+ share_token = Column(String(64), unique=True, nullable=True) # public /share/{token} link when set
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 6ede145..12bd9c1 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -63,6 +63,40 @@ class ArticleLinkIn(BaseModel):
section_id: str | None = None
+class ArticleAIDraft(BaseModel):
+ topic: str
+ instructions: str | None = None
+
+ @field_validator("topic")
+ @classmethod
+ def topic_shape(cls, value):
+ value = value.strip()
+ if not value or len(value) > 300:
+ raise ValueError("Topic is required (max 300 characters)")
+ return value
+
+
+class ArticleAIRefine(BaseModel):
+ instructions: str = ""
+
+ @field_validator("instructions")
+ @classmethod
+ def instructions_shape(cls, value):
+ if len(value) > 2000:
+ raise ValueError("Instructions are too long (max 2000 characters)")
+ return value
+
+
+def _queue_article_job(db, user, job_id, job_title):
+ import redis as redis_lib
+ from app.config import settings
+ r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
+ r.set(f"extraction:status:{job_id}", "pending", ex=3600)
+ r.lpush(f"extraction:user_jobs:{user.id}", job_id)
+ r.expire(f"extraction:user_jobs:{user.id}", 86400)
+ r.set(f"extraction:job_title:{job_id}", job_title, ex=3600)
+
+
def _validate_sections(sections: list[ArticleSection]):
ids, slugs = set(), set()
for section in sections:
@@ -341,3 +375,87 @@ def unlink_question(
question_id=question_id, article_id=article_id, section_id=section_id,
).delete(synchronize_session=False)
db.commit()
+
+
+@router.post("/ai-draft")
+def start_article_draft(
+ data: ArticleAIDraft,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ """Queue an AI draft; the saved article is a draft until an educator publishes it."""
+ import uuid
+ from app.tasks.quiz_tasks import generate_article_draft
+ job_id = str(uuid.uuid4())
+ _queue_article_job(db, current_user, job_id, f"Article draft: {data.topic[:60]}")
+ try:
+ generate_article_draft.delay(
+ job_id=job_id, user_id=current_user.id,
+ topic=data.topic, instructions=data.instructions or "", model_id=None,
+ )
+ except Exception:
+ raise HTTPException(503, "Task queue unavailable")
+ return {"job_id": job_id, "status": "pending"}
+
+
+@router.post("/{article_id}/ai-refine")
+def start_article_refine(
+ article_id: int,
+ data: ArticleAIRefine,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ article = db.get(Article, article_id)
+ if not article:
+ raise HTTPException(404, "Article not found")
+ import uuid
+ from app.tasks.quiz_tasks import generate_article_draft
+ job_id = str(uuid.uuid4())
+ _queue_article_job(db, current_user, job_id, f"Refine: {article.title[:60]}")
+ try:
+ generate_article_draft.delay(
+ job_id=job_id, user_id=current_user.id, topic=article.title,
+ instructions=data.instructions, article_id=article.id, model_id=None,
+ )
+ except Exception:
+ raise HTTPException(503, "Task queue unavailable")
+ return {"job_id": job_id, "status": "pending"}
+
+
+@router.post("/{article_id}/ai-cards")
+def start_article_cards(
+ article_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ """Generate cards into a private deck; sharing remains an explicit educator action."""
+ article = db.get(Article, article_id)
+ if not article:
+ raise HTTPException(404, "Article not found")
+ import uuid
+ from app.tasks.quiz_tasks import generate_article_cards
+ job_id = str(uuid.uuid4())
+ _queue_article_job(db, current_user, job_id, f"Cards: {article.title[:60]}")
+ try:
+ generate_article_cards.delay(job_id=job_id, user_id=current_user.id, article_id=article.id, model_id=None)
+ except Exception:
+ raise HTTPException(503, "Task queue unavailable")
+ return {"job_id": job_id, "status": "pending"}
+
+
+@router.get("/job/{job_id}")
+def get_article_job(job_id: str, current_user: User = Depends(get_current_user)):
+ """Poll an article AI job for the current user."""
+ import json as _json
+ import redis as redis_lib
+ from app.config import settings
+ r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
+ user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 49)
+ if job_id not in user_jobs and not current_user.is_moderator:
+ raise HTTPException(404, "Job not found")
+ status = r.get(f"extraction:status:{job_id}") or "unknown"
+ steps = [_json.loads(s) for s in r.lrange(f"extraction:steps:{job_id}", 0, -1)]
+ result = {"job_id": job_id, "status": status, "steps": steps}
+ if status == "failed":
+ result["error"] = r.get(f"extraction:error:{job_id}") or "Unknown error"
+ return result
diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py
new file mode 100644
index 0000000..49a5776
--- /dev/null
+++ b/backend/app/routers/comments.py
@@ -0,0 +1,158 @@
+"""Moderated comments on articles and questions."""
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, field_validator
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.article import Article
+from app.models.comment import Comment
+from app.models.question import Question
+from app.models.user import User
+from app.services.quiz_builder import bank_question_predicate
+from app.utils.auth import check_rate_limit, get_current_user, require_moderator
+
+router = APIRouter()
+
+
+class CommentCreate(BaseModel):
+ article_id: int | None = None
+ question_id: int | None = None
+ content: str
+
+ @field_validator("content")
+ @classmethod
+ def content_shape(cls, value):
+ value = value.strip()
+ if not value:
+ raise ValueError("Comment cannot be empty")
+ if len(value) > 2000:
+ raise ValueError("Comment is too long (max 2000 characters)")
+ return value
+
+
+class CommentModerate(BaseModel):
+ status: str
+
+ @field_validator("status")
+ @classmethod
+ def status_shape(cls, value):
+ if value not in ("pending", "approved", "rejected"):
+ raise ValueError("Invalid moderation status")
+ return value
+
+
+def _target(db: Session, article_id: int | None, question_id: int | None, user: User):
+ if (article_id is None) == (question_id is None):
+ raise HTTPException(400, "Provide exactly one of article_id or question_id")
+ if article_id is not None:
+ article = db.get(Article, article_id)
+ if not article:
+ raise HTTPException(404, "Article not found")
+ if article.status != "published" and not user.is_moderator:
+ raise HTTPException(404, "Article not found")
+ return "article_id", article
+ question = db.get(Question, question_id)
+ if not question or not db.query(Question.id).filter(
+ Question.id == question_id, bank_question_predicate(user)).first():
+ raise HTTPException(404, "Question not found")
+ return "question_id", question
+
+
+@router.post("/")
+def create_comment(
+ data: CommentCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ target_field, _ = _target(db, data.article_id, data.question_id, current_user)
+ check_rate_limit(
+ key=f"comments:daily:{current_user.id}",
+ max_calls=20,
+ window_seconds=86400,
+ detail="You've reached today's comment limit. Try again tomorrow.",
+ user=current_user,
+ )
+ comment = Comment(article_id=data.article_id, question_id=data.question_id,
+ user_id=current_user.id, content=data.content, status="pending")
+ db.add(comment)
+ db.commit()
+ db.refresh(comment)
+ return _json(db, comment, current_user)
+
+
+def _json(db: Session, comment: Comment, user: User):
+ author = db.get(User, comment.user_id) if comment.user_id else None
+ return {
+ "id": comment.id,
+ "article_id": comment.article_id,
+ "question_id": comment.question_id,
+ "user_id": comment.user_id,
+ "author_name": author.name if author else "Unknown",
+ "content": comment.content,
+ "status": comment.status,
+ "created_at": comment.created_at,
+ "own": comment.user_id == user.id,
+ "can_moderate": user.is_moderator,
+ }
+
+
+@router.get("/")
+def list_comments(
+ article_id: int | None = Query(None),
+ question_id: int | None = Query(None),
+ limit: int = Query(20, le=100),
+ offset: int = Query(0),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ target_field, target = _target(db, article_id, question_id, current_user)
+ target_id = getattr(target, "id")
+ query = db.query(Comment).filter(getattr(Comment, target_field) == target_id).filter(
+ (Comment.status == "approved") | (Comment.user_id == current_user.id),
+ )
+ total = query.count()
+ comments = query.order_by(Comment.created_at.desc()).offset(offset).limit(limit).all()
+ return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
+
+
+@router.get("/moderation")
+def list_pending_comments(
+ limit: int = Query(50, le=200),
+ offset: int = Query(0),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ query = db.query(Comment).filter(Comment.status == "pending")
+ total = query.count()
+ comments = query.order_by(Comment.created_at.asc()).offset(offset).limit(limit).all()
+ return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
+
+
+@router.patch("/{comment_id}")
+def moderate_comment(
+ comment_id: int,
+ data: CommentModerate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator),
+):
+ comment = db.get(Comment, comment_id)
+ if not comment:
+ raise HTTPException(404, "Comment not found")
+ comment.status = data.status
+ db.commit()
+ return _json(db, comment, current_user)
+
+
+@router.delete("/{comment_id}", status_code=204)
+def delete_comment(
+ comment_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ comment = db.get(Comment, comment_id)
+ if not comment:
+ raise HTTPException(404, "Comment not found")
+ if comment.user_id != current_user.id and not current_user.is_moderator:
+ raise HTTPException(403, "Not your comment")
+ db.delete(comment)
+ db.commit()
diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py
index c59b693..430c25e 100644
--- a/backend/app/routers/quizzes.py
+++ b/backend/app/routers/quizzes.py
@@ -15,7 +15,7 @@ from app.models.question_category import QuestionCategory
from app.services.quiz_builder import category_breadcrumbs
from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service
-from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared
+from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared, quiz_shareable_predicate
from app.utils.auth import get_current_user, require_moderator
from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz
@@ -607,3 +607,38 @@ def share_quiz(quiz_id: int, shared: bool = Query(...), db: Session = Depends(ge
if not quiz:
raise HTTPException(404, "Quiz not found")
return set_quiz_shared(db, quiz, current_user, shared)
+
+
+@router.post("/{quiz_id}/share-link")
+def create_share_link(quiz_id: int, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """Mark a general quiz shareable and return its public token."""
+ quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
+ if not quiz:
+ raise HTTPException(404, "Quiz not found")
+ if quiz.user_id != current_user.id and not current_user.is_moderator:
+ raise HTTPException(403, "Only the owner or a moderator can share this quiz")
+ if quiz.course_id is not None:
+ raise HTTPException(400, "Course quizzes cannot be shared publicly")
+ if not db.query(Quiz.id).filter(Quiz.id == quiz.id, quiz_shareable_predicate()).first():
+ raise HTTPException(400, "This test contains private or course-only questions")
+ if not quiz.share_token:
+ import uuid
+ quiz.share_token = uuid.uuid4().hex
+ quiz.is_shared = 1
+ db.commit()
+ return {"token": quiz.share_token, "enabled": True}
+
+
+@router.delete("/{quiz_id}/share-link", status_code=204)
+def revoke_share_link(quiz_id: int, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
+ if not quiz:
+ raise HTTPException(404, "Quiz not found")
+ if quiz.user_id != current_user.id and not current_user.is_moderator:
+ raise HTTPException(403, "Only the owner or a moderator can revoke sharing")
+ quiz.share_token = None
+ quiz.is_shared = 0
+ quiz.is_published = 0
+ db.commit()
diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py
new file mode 100644
index 0000000..33b93bc
--- /dev/null
+++ b/backend/app/routers/share.py
@@ -0,0 +1,42 @@
+"""Public share links — viewing quiz info does not require a login."""
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.quiz import Quiz
+from app.models.user import User
+from app.services.quiz_builder import category_breadcrumbs
+from app.models.question_category import QuestionCategory
+from app.utils.quiz_access import quiz_shareable_predicate
+
+router = APIRouter()
+
+
+@router.get("/{token}")
+def get_shared_quiz(
+ token: str,
+ db: Session = Depends(get_db),
+):
+ """Public quiz landing data; never exposes questions or answers."""
+ quiz = db.query(Quiz).filter(
+ Quiz.share_token == token,
+ Quiz.deleted_at.is_(None),
+ Quiz.course_id.is_(None),
+ Quiz.is_shared == 1,
+ ).first()
+ if not quiz or not db.query(Quiz.id).filter(
+ Quiz.id == quiz.id, quiz_shareable_predicate()).first():
+ raise HTTPException(404, "Shared quiz not found")
+ owner = db.get(User, quiz.user_id) if quiz.user_id else None
+ categories = db.query(QuestionCategory).all()
+ return {
+ "quiz_id": quiz.id,
+ "title": quiz.title,
+ "mode": quiz.mode,
+ "questions_count": quiz.questions_count,
+ "time_limit_minutes": quiz.time_limit_minutes,
+ "allow_review": quiz.allow_review,
+ "owner_name": owner.name if owner else None,
+ "category": quiz.category.name if quiz.category else None,
+ "category_breadcrumbs": category_breadcrumbs(categories, quiz.category_id) if quiz.category_id else [],
+ }
diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py
index 7d047eb..3d7af0e 100644
--- a/backend/app/schemas/quiz.py
+++ b/backend/app/schemas/quiz.py
@@ -63,6 +63,7 @@ class QuizResponse(BaseModel):
allow_review: int | None = 1
max_attempts: int | None = None
attempt_mode: str | None = None
+ share_token: str | None = None
class Config:
from_attributes = True
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 7e9a81b..69c4794 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -677,3 +677,151 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
pass
finally:
db.close()
+
+
+ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform.
+Topic: {topic}
+{instructions}
+Return ONLY strict JSON with this exact shape:
+{{"title": "...", "slug": "lowercase-hyphenated", "summary": "1-2 sentences", "content": "introduction markdown", "sections": [{{"id": "32 lowercase hex chars", "slug": "lowercase-hyphenated", "title": "...", "content": "markdown"}}]}}
+Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; keep 2-6 sections with stable unique ids; do not mention these instructions."""
+
+
+@celery_app.task(name="generate_article_draft", bind=True)
+def generate_article_draft(self, job_id: str, user_id: int, topic: str,
+ instructions: str = "", article_id: int | None = None,
+ model_id: str | None = None):
+ """Create or refine an educator article draft; never publishes."""
+ import re
+ import uuid
+ r = _redis()
+ r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
+ db = SessionLocal()
+ try:
+ from app.models.article import Article
+ from app.services.ai_service import get_model_for_task, _proxy_model
+ from app.config import settings
+
+ existing = db.get(Article, article_id) if article_id else None
+ if article_id and not existing:
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "error", "Article not found")
+ return
+ ai_model_id, ai_api_key = get_model_for_task(db, "article")
+ if model_id:
+ ai_model_id = model_id
+ _push_step(r, job_id, "ai", "Drafting article…")
+ prompt = ARTICLE_DRAFT_PROMPT.format(
+ topic=topic, instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "")
+ import litellm
+ kwargs = {"model": _proxy_model(ai_model_id), "messages": [{"role": "user", "content": prompt}],
+ "max_tokens": 4000, "temperature": 0.4}
+ if ai_api_key:
+ kwargs["api_key"] = ai_api_key
+ elif settings.LITELLM_API_KEY:
+ kwargs["api_key"] = settings.LITELLM_API_KEY
+ if settings.LITELLM_API_BASE:
+ kwargs["api_base"] = settings.LITELLM_API_BASE
+ response = litellm.completion(**kwargs)
+ raw = response.choices[0].message.content.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
+ if raw.endswith("```"):
+ raw = raw[:-3]
+ raw = raw.strip()
+ data = json.loads(raw)
+ title = str(data.get("title", topic)).strip()[:300]
+ slug = re.sub(r"[^a-z0-9]+", "-", str(data.get("slug", topic)).strip().lower()).strip("-")[:120] or "topic"
+ sections = []
+ for section in data.get("sections", []):
+ sections.append({
+ "id": str(section.get("id") or uuid.uuid4().hex)[:32].lower(),
+ "slug": re.sub(r"[^a-z0-9]+", "-", str(section.get("slug", "section")).strip().lower()).strip("-")[:120] or "section",
+ "title": str(section.get("title", "Section")).strip()[:300] or "Section",
+ "content": str(section.get("content", "")),
+ })
+ if existing:
+ existing.title, existing.slug, existing.summary = title, slug, str(data.get("summary", "") or "")[:2000]
+ existing.content, existing.sections = str(data.get("content", "") or ""), sections
+ else:
+ base_slug = slug
+ n = 2
+ while db.query(Article.id).filter(Article.slug == slug).first():
+ slug = f"{base_slug}-{n}"
+ n += 1
+ db.add(Article(slug=slug, title=title, summary=str(data.get("summary", "") or "")[:2000],
+ content=str(data.get("content", "") or ""), sections=sections,
+ user_id=user_id, status="draft"))
+ db.commit()
+ r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "done", f"Draft saved: {title}")
+ except Exception as exc:
+ logger.warning("Article draft job %s failed: %s", job_id, exc)
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "error", "Drafting failed; the model may need an 'article' configuration.")
+ finally:
+ db.close()
+
+
+@celery_app.task(name="generate_article_cards", bind=True)
+def generate_article_cards(self, job_id: str, user_id: int, article_id: int,
+ model_id: str | None = None):
+ """Generate cards from an article into an unshared educator deck; links stay private until shared."""
+ r = _redis()
+ r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
+ db = SessionLocal()
+ try:
+ from app.models.article import Article
+ from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
+ from app.services import extraction_modes
+ from app.services.ai_service import get_model_for_task
+
+ article = db.get(Article, article_id)
+ if not article:
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "error", "Article not found")
+ return
+ ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
+ if model_id:
+ ai_model_id = model_id
+ _push_step(r, job_id, "ai", f"Generating cards from {article.title}…")
+ content = "\n\n".join(filter(None, [
+ article.title, article.summary, article.content,
+ *[f"## {s['title']}\n{s['content']}" for s in (article.sections or [])],
+ ]))
+ cards = extraction_modes.generate_flashcards(content, "article", None, ai_model_id, ai_api_key)
+ if not cards:
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "error", "No cards could be generated")
+ return
+ deck = db.query(FlashcardDeck).filter(
+ FlashcardDeck.title == f"Cards: {article.title}",
+ FlashcardDeck.user_id == user_id,
+ FlashcardDeck.deleted_at.is_(None),
+ ).first()
+ if not deck:
+ deck = FlashcardDeck(title=f"Cards: {article.title}", user_id=user_id, card_count=0, is_shared=0)
+ db.add(deck)
+ db.flush()
+ new_cards = []
+ for card in cards:
+ item = Flashcard(deck_id=deck.id, front=card["front"], back=card["back"],
+ page_reference=card.get("page_reference"))
+ db.add(item)
+ new_cards.append(item)
+ db.flush()
+ for item in new_cards:
+ db.add(FlashcardArticleLink(flashcard_id=item.id, article_id=article.id))
+ deck.card_count = db.query(Flashcard).filter(Flashcard.deck_id == deck.id).count()
+ db.commit()
+ r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "done", f"{len(cards)} cards saved to private deck {deck.title}")
+ except Exception as exc:
+ logger.warning("Article cards job %s failed: %s", job_id, exc)
+ db.rollback()
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
+ _push_step(r, job_id, "error", "Card generation failed.")
+ finally:
+ db.close()
diff --git a/backend/tests/test_comments_ai.py b/backend/tests/test_comments_ai.py
new file mode 100644
index 0000000..df575d6
--- /dev/null
+++ b/backend/tests/test_comments_ai.py
@@ -0,0 +1,159 @@
+"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
+import json
+import sys
+import unittest
+from unittest.mock import Mock, patch
+
+import test_quiz_builder as fixtures
+from app.models.article import Article
+from app.models.comment import Comment
+from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
+from app.models.question import Question
+from app.routers import articles, comments
+from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards
+from sqlalchemy.orm import sessionmaker
+
+DRAFT_RESPONSE = {
+ "title": "AI draft topic", "slug": "ai-draft-topic", "summary": "Draft summary",
+ "content": "Draft intro", "sections": [
+ {"id": "a" * 32, "slug": "first", "title": "First", "content": "Body"},
+ {"id": "b" * 32, "slug": "second", "title": "Second", "content": "More"},
+ ],
+}
+
+
+class CommentsAiTests(unittest.TestCase):
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.client.app.include_router(articles.router, prefix='/articles')
+ self.client.app.include_router(comments.router, prefix='/comments')
+ self.redis = Mock()
+ self.redis.from_url.return_value = self.redis
+ self.redis.get.return_value = None
+ self.redis.lrange.return_value = []
+ self.redis.lpush.return_value = 1
+ self.redis.expire.return_value = True
+ self.redis.incr.side_effect = [1, 21]
+ self.delay = patch.dict(sys.modules, {'redis': self.redis})
+ self.delay.start()
+ self.queue = patch('app.tasks.quiz_tasks.generate_article_draft.delay').start()
+ self.card_queue = patch('app.tasks.quiz_tasks.generate_article_cards.delay').start()
+
+ def tearDown(self):
+ patch.stopall()
+ self.bank.tearDown()
+
+ def publish(self, article_id):
+ return self.client.post(f'/articles/{article_id}/publish', json={'published': True})
+
+ def test_comment_visibility_moderation_and_bounds(self):
+ self.bank.user = self.bank.mod
+ article = self.client.post('/articles/', json={
+ "title": "Discussed", "slug": "discussed", "content": "Intro", "sections": []}).json()
+ self.publish(article['id'])
+ self.bank.user = self.bank.owner
+ created = self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Owner pending note'})
+ self.assertEqual(created.status_code, 200, created.text)
+ self.assertEqual(created.json()['status'], 'pending')
+ self.assertEqual(created.json()['own'], True)
+ for payload in ({'article_id': article['id'], 'question_id': 1, 'content': 'x'},
+ {'article_id': article['id'], 'content': ' '},
+ {'article_id': article['id'], 'content': 'x' * 2001},
+ {'article_id': 999, 'content': 'x'},
+ {'question_id': 999, 'content': 'x'}):
+ self.assertIn(self.client.post('/comments/', json=payload).status_code, (400, 404, 422), payload)
+ self.assertEqual(self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Rate limited'}).status_code, 429)
+ listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
+ self.assertEqual(listing['total'], 1) # Own pending visible.
+ self.bank.user = self.bank.peer
+ listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
+ self.assertEqual(listing['total'], 0) # Others' pending hidden.
+ self.assertEqual(self.client.get('/comments/moderation').status_code, 403)
+ self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}).status_code, 403)
+ self.bank.user = self.bank.mod
+ pending = self.client.get('/comments/moderation').json()
+ self.assertEqual(pending['total'], 1)
+ approved = self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'})
+ self.assertEqual(approved.status_code, 200)
+ self.assertEqual(approved.json()['author_name'], 'Owner')
+ self.bank.user = self.bank.peer
+ listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
+ self.assertEqual(listing['total'], 1)
+ self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'rejected'}).status_code, 403)
+ # Question comments follow bank visibility.
+ self.bank.user = self.bank.peer
+ self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Private leak'}).status_code, 404)
+ self.redis.incr.side_effect = [1]
+ self.bank.user = self.bank.owner
+ self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Own question'}).status_code, 200)
+ self.bank.user = self.bank.mod
+ self.assertEqual(self.client.delete(f"/comments/{created.json()['id']}").status_code, 204)
+
+ def test_ai_endpoints_queue_and_poll(self):
+ self.bank.user = self.bank.mod
+ response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'})
+ self.assertEqual(response.status_code, 200, response.text)
+ job_id = response.json()['job_id']
+ self.queue.assert_called_once()
+ self.redis.get.return_value = 'completed'
+ poll = self.client.get(f'/articles/job/{job_id}').json()
+ self.assertEqual(poll['status'], 'completed')
+ self.bank.user = self.bank.owner
+ self.assertEqual(self.client.get(f'/articles/job/{job_id}').status_code, 404)
+ self.bank.user = self.bank.mod
+ article = self.client.post('/articles/', json={
+ "title": "Refine me", "slug": "refine-me", "content": "Intro", "sections": []}).json()
+ self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'Shorten'}).status_code, 200)
+ self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-cards").status_code, 200)
+ self.card_queue.assert_called_once()
+ self.bank.user = self.bank.owner
+ self.assertEqual(self.client.post('/articles/ai-draft', json={'topic': 'Learner'}).status_code, 403)
+ self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'x'}).status_code, 403)
+
+ def test_draft_task_creates_and_refines_without_publishing(self):
+ self.redis.set.return_value = True
+ ai = patch('litellm.completion').start()
+ ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(DRAFT_RESPONSE)))])
+ with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \
+ patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)), \
+ patch('app.services.ai_service._proxy_model', return_value='synthetic'):
+ generate_article_draft('job-1', 3, 'Neonatal jaundice', '')
+ article = self.bank.db.query(Article).filter_by(slug='ai-draft-topic').one()
+ self.assertEqual(article.status, 'draft')
+ self.assertEqual([s['id'] for s in article.sections], ['a' * 32, 'b' * 32])
+ self.redis.set.assert_any_call('extraction:status:job-1', 'completed', ex=3600)
+ generate_article_draft('job-2', 3, 'Neonatal jaundice', 'Refine', article.id)
+ refreshed = self.bank.db.query(Article).filter_by(id=article.id).one()
+ self.assertEqual(refreshed.status, 'draft')
+ self.assertEqual(refreshed.slug, 'ai-draft-topic')
+ patch.stopall()
+ self.redis.reset_mock()
+
+ def test_cards_task_builds_private_linked_deck(self):
+ self.redis.set.return_value = True
+ self.bank.db.add(Article(slug='cards-source', title='Cards source', content='Body',
+ sections=[{'id': 'c' * 32, 'slug': 's', 'title': 'S', 'content': 'More'}],
+ user_id=3, status='published'))
+ self.bank.db.commit()
+ article = self.bank.db.query(Article).filter_by(slug='cards-source').one()
+ with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \
+ patch('app.services.extraction_modes.generate_flashcards',
+ return_value=[{'front': 'F1', 'back': 'B1'}, {'front': 'F2', 'back': 'B2', 'page_reference': 3}]), \
+ patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)):
+ generate_article_cards('job-3', 3, article.id)
+ deck = self.bank.db.query(FlashcardDeck).filter_by(title='Cards: Cards source').one()
+ self.assertEqual(deck.is_shared, 0)
+ cards = self.bank.db.query(Flashcard).filter_by(deck_id=deck.id).all()
+ self.assertEqual(len(cards), 2)
+ self.assertEqual(deck.card_count, 2)
+ linked = self.bank.db.query(FlashcardArticleLink).filter_by(article_id=article.id).count()
+ self.assertEqual(linked, 2)
+ self.redis.set.assert_any_call('extraction:status:job-3', 'completed', ex=3600)
+ patch.stopall()
+ self.redis.reset_mock()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/backend/tests/test_share_public.py b/backend/tests/test_share_public.py
new file mode 100644
index 0000000..c259bc1
--- /dev/null
+++ b/backend/tests/test_share_public.py
@@ -0,0 +1,63 @@
+"""Public share-link endpoints: token lifecycle and public landing data."""
+import unittest
+
+import test_quiz_builder as fixtures
+from app.models.quiz import Quiz
+from app.models.quiz_question_link import QuizQuestionLink
+from app.routers import quizzes, share
+
+
+class ShareLinkTests(unittest.TestCase):
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.client.app.include_router(quizzes.router, prefix='/quizzes')
+ self.client.app.include_router(share.router, prefix='/share')
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def test_owner_only_token_lifecycle_and_public_landing(self):
+ self.bank.user = self.bank.owner
+ self.assertEqual(self.client.post('/quizzes/1/share-link').status_code, 403)
+ self.bank.user = self.bank.mod
+ created = self.client.post('/quizzes/1/share-link')
+ self.assertEqual(created.status_code, 200, created.text)
+ token = created.json()['token']
+ self.assertEqual(self.client.post('/quizzes/1/share-link').json()['token'], token) # Stable token.
+ public = self.client.get(f'/share/{token}')
+ self.assertEqual(public.status_code, 200, public.text)
+ data = public.json()
+ self.assertEqual(data['quiz_id'], 1)
+ self.assertEqual(data['title'], 'Origin')
+ self.assertNotIn('questions', data)
+ self.assertNotIn('correct_answer', str(data))
+ self.assertEqual(self.client.get('/share/unknown-token').status_code, 404)
+ self.bank.user = self.bank.peer
+ detail = self.client.get('/quizzes/1')
+ self.assertEqual(detail.json()['share_token'], token)
+ self.assertEqual(self.client.delete('/quizzes/1/share-link').status_code, 403)
+ self.bank.user = self.bank.mod
+ self.assertEqual(self.client.delete('/quizzes/1/share-link').status_code, 204)
+ self.assertEqual(self.client.get(f'/share/{token}').status_code, 404)
+ self.assertEqual(self.bank.db.get(Quiz, 1).is_shared, 0)
+ self.assertEqual(self.client.get('/quizzes/1').json()['share_token'], None)
+
+ def test_course_and_private_question_quizzes_cannot_share(self):
+ self.bank.user = self.bank.mod
+ self.assertEqual(self.client.post('/quizzes/2/share-link').status_code, 400) # Course quiz.
+ mixed = Quiz(title='Private mix', user_id=3, is_published=1, questions_count=1)
+ self.bank.db.add(mixed)
+ self.bank.db.flush()
+ self.bank.db.add(QuizQuestionLink(quiz_id=mixed.id, question_id=4, position=0))
+ self.bank.db.commit()
+ self.assertEqual(self.client.post(f'/quizzes/{mixed.id}/share-link').status_code, 400)
+ mixed.is_shared = 1 # Stale share flag alone must not make it public.
+ self.bank.db.commit()
+ self.assertEqual(self.client.post(f'/quizzes/{mixed.id}/share-link').status_code, 400)
+ self.assertEqual(self.bank.db.get(Quiz, mixed.id).share_token, None)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4de519e..a0c1bdb 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -28,6 +28,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
+const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
@@ -77,6 +78,7 @@ function AppRoutes() {
No comments yet.Comments {total > 0 && ({total})}
+
+ {comments.map(comment => (
+
+ )}
+ {comments.length < total && (
+
+ )}
+ Text', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false }] } })
+ const { container } = render(
Educator-reviewed articles linked to questions and cards.
Drafts stay private until you publish them. The model does not invent references.
+ + setAiTopic(e.target.value)} placeholder="e.g. Febrile seizures" /> + +{questions.length} linked questions · {cards.length} linked cards
Rewrites this draft; the result stays a draft.
++ {quiz.owner_name ? `Shared by ${quiz.owner_name} · ` : ''} + {quiz.questions_count} questions + {quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min` : ' · study mode'} + {quiz.allow_review === 1 ? '' : ' · no answer review'} +
+ {quiz.category_breadcrumbs?.length > 0 && ( ++ {quiz.category_breadcrumbs.map(c => c.name).join(' / ')} +
+ )} + {user ? ( + + ) : ( + + Sign in to take this quiz + + )} +{code}
-
+ {quiz.questions_per_attempt || quiz.questions_count} questions {quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''} @@ -324,7 +361,7 @@ function CourseQuizStart({ quiz, onStart }) { ) } -function ModeSelectScreen({ quiz, voices, onStart }) { +function ModeSelectScreen({ quiz, voices, onStart, onShareChanged }) { const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') const [startError, setStartError] = useState('') @@ -352,7 +389,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
{quiz.questions_count} questions {quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''} @@ -895,9 +932,9 @@ const timerStarted = timeLeft !== null