pdf-quiz-generator/backend/app/routers/articles.py
Daniel cedab6e91e feat: thumbnails for uploaded images, at two widths and no others
A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.

Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
an endpoint that resizes to whatever the query string asks for is a CPU sink
anybody can point at. Never enlarged: a 180px image asked for at 640 is served
as it is, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.

Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.

EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.

Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.

Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:49:20 +02:00

861 lines
34 KiB
Python

"""Topic/article library with stable subsection links and card/question associations."""
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.search_service import hybrid_ids
from app.services import embedding_service
from app.models.article import (
Article, ArticleRevision, ArticleSectionIndex, ArticleSlug, ArticleView,
QuestionArticleLink,
)
from app.services import article_service
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
from app.models.section import Section
from app.models.user import User
from app.services.quiz_builder import bank_question_predicate
from app.services.quiz_builder import category_breadcrumbs
from app.models.question_category import QuestionCategory
from app.utils.auth import get_current_user, require_moderator
from app.utils.category_grants import can_edit_article
import logging
from datetime import datetime
router = APIRouter()
log = logging.getLogger(__name__)
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class ArticleSection(BaseModel):
id: str
slug: str
title: str
content: str = ""
# A section may sit under another one, which is how "ROS questionnaire"
# belongs to "Review of systems" rather than standing alongside it. Absent
# or null means top level, so every article written before this stays valid.
parent_id: str | None = None
# Which of the three readings this section belongs to: the full article, the
# key points, or the clinical view carrying management and doses.
variant: str = "long"
class ArticleReference(BaseModel):
"""Where a fact came from. Named sources, not markers in the prose."""
title: str = Field(min_length=1, max_length=300)
author: str | None = Field(default=None, max_length=300)
pages: list[int] = Field(default_factory=list, max_length=40)
class ArticleWrite(BaseModel):
title: str
slug: str
summary: str | None = None
content: str | None = None
sections: list[ArticleSection] = []
category_id: int | None = None
section_id: int | None = None
# Absent means "leave them alone": a caller that predates references must not
# silently strip the ones generation attached.
references: list[ArticleReference] | None = None
@field_validator("slug")
@classmethod
def slug_shape(cls, value):
value = value.strip()
if not SLUG_RE.match(value) or len(value) > 120:
raise ValueError("Slug must be lowercase letters, digits and single hyphens (max 120)")
return value
@field_validator("title")
@classmethod
def title_shape(cls, value):
value = value.strip()
if not value or len(value) > 300:
raise ValueError("Title is required (max 300 characters)")
return value
class ArticlePublish(BaseModel):
published: bool
class ArticleLinkIn(BaseModel):
question_id: int
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:
if not re.fullmatch(r"[0-9a-f]{32}", section.id):
raise HTTPException(400, "Section IDs must be stable 32-character hex identifiers")
if not SLUG_RE.match(section.slug.strip()) or len(section.slug) > 120:
raise HTTPException(400, "Invalid section slug")
if not section.title.strip() or len(section.title) > 300:
raise HTTPException(400, "Section titles are required (max 300 characters)")
if section.id in ids or section.slug.strip() in slugs:
raise HTTPException(400, "Section IDs and slugs must be unique within an article")
ids.add(section.id)
slugs.add(section.slug.strip())
for section in sections:
if section.variant not in article_service.VARIANTS:
raise HTTPException(400, f"Unknown section variant: {section.variant}")
# Nesting is one level deep and points backwards: a parent has to be a
# section of this article that was already listed, which rules out a cycle
# and a sub-section that renders before the heading it belongs to.
parents = set()
seen: set[str] = set()
for section in sections:
if section.parent_id is not None:
if section.parent_id == section.id or section.parent_id not in seen:
raise HTTPException(400, "A sub-section must sit under an earlier section of this article")
parent = next(s for s in sections if s.id == section.parent_id)
# A sub-section belongs to the same reading as its parent, or the
# contents rail would list a child that its view never renders.
if parent.variant != section.variant:
raise HTTPException(400, "A sub-section must be in the same view as the section it sits under")
parents.add(section.parent_id)
seen.add(section.id)
nested = {s.id for s in sections if s.parent_id is not None}
if parents & nested:
raise HTTPException(400, "Sub-sections cannot themselves hold sub-sections")
def _section_ids(article):
return {section["id"] for section in (article.sections or [])}
def _validate_source_section(db, section_id):
if section_id is not None and not db.get(Section, section_id):
raise HTTPException(400, "Source section not found")
def _views_for(db, user) -> list[str]:
"""The readings this learner's objective offers."""
from app.models.exam import Exam
from app.routers.exams import views_for
exam_id = getattr(user, "active_exam_id", None)
return views_for(db.get(Exam, exam_id) if exam_id else None)
def _article_card_json(article: Article) -> dict:
"""An article as it appears in a list: enough to find it, not to read it.
The listing used to send `content` and `sections` for every article — 214KB
of prose across the library, none of which a list renders. A browser that
waits for the whole corpus before it can draw a column of titles is slow
for no reason.
"""
sections = article.sections or []
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"summary": article.summary,
"category_id": article.category_id,
"section_id": article.section_id,
"user_id": article.user_id,
"status": article.status,
"section_count": len(sections),
"variants": article_service.available_variants(article),
"generated_by": article.generated_by,
"reviewed_at": article.reviewed_at,
"submitted_at": article.submitted_at,
"created_at": article.created_at,
"updated_at": article.updated_at,
}
def _article_json(article: Article) -> dict:
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"summary": article.summary,
"content": article.content,
"sections": article.sections,
"category_id": article.category_id,
"section_id": article.section_id,
"user_id": article.user_id,
"status": article.status,
"references": article.references_json or [],
"variants": article_service.available_variants(article),
"generated_by": article.generated_by,
"reviewed_at": article.reviewed_at,
"submitted_at": article.submitted_at,
"created_at": article.created_at,
"updated_at": article.updated_at,
}
def _record_view(db, user, article) -> None:
"""Remember that this learner opened the article; one row per user/article.
Best effort: a reading page must not fail because a bookkeeping write did.
"""
try:
view = db.query(ArticleView).filter_by(user_id=user.id, article_id=article.id).first()
if view:
view.viewed_at = datetime.utcnow()
else:
db.add(ArticleView(user_id=user.id, article_id=article.id))
db.commit()
except Exception:
db.rollback()
log.warning("Could not record article view", exc_info=True)
def _reembed(db, article) -> None:
"""Embed the article and reproject its sections. Failures wait for the retry task."""
try:
embedding_service.embed_record(article, "article")
_rebuild_section_index(db, article)
db.commit()
except Exception:
db.rollback()
log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True)
def _rebuild_section_index(db, article) -> None:
"""Mirror the article's JSON sections into their own searchable rows.
Sections live in a JSON column, so they cannot carry a vector or a full-text
index themselves. Projecting them lets a citation point at the right section
rather than the whole article. Rows are keyed by section id, so editing a
section updates it and removing one deletes it.
"""
sections = article.sections or []
keep = set()
for section in sections:
section_id = section.get("id")
if not section_id:
continue
keep.add(section_id)
row = db.query(ArticleSectionIndex).filter_by(
article_id=article.id, section_id=section_id).first()
text_changed = True
if row is None:
row = ArticleSectionIndex(article_id=article.id, section_id=section_id)
db.add(row)
else:
text_changed = (row.title != section.get("title")) or (row.content != section.get("content"))
row.title = section.get("title")
row.content = section.get("content")
# Only pay for an embedding when the text actually changed.
if text_changed or row.embedding is None:
embedding_service.embed_record(row, "article_section")
stale = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.article_id == article.id)
if keep:
stale = stale.filter(~ArticleSectionIndex.section_id.in_(keep))
stale.delete(synchronize_session=False)
@router.get("/")
def list_articles(
category_id: int | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Published articles for everyone; educators additionally see drafts."""
query = db.query(Article)
if category_id:
query = query.filter(Article.category_id == category_id)
if q and q.strip():
# Hybrid retrieval, same as the question bank: a title substring match
# could not find an article that says the same thing in other words.
ranked, _ = hybrid_ids(db, q.strip(), "article", limit=200)
if not ranked:
return []
query = query.filter(Article.id.in_(ranked))
rank_of = {article_id: position for position, article_id in enumerate(ranked)}
articles = query.order_by(Article.updated_at.desc()).all()
if q and q.strip():
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
if not current_user.is_moderator:
articles = [a for a in articles if a.status == "published"]
return [_article_card_json(a) for a in articles]
@router.post("/")
def create_article(
data: ArticleWrite,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
_validate_sections(data.sections)
if data.category_id and not db.get(QuestionCategory, data.category_id):
raise HTTPException(400, "Category not found")
_validate_source_section(db, data.section_id)
if db.query(Article.id).filter(Article.slug == data.slug).first():
raise HTTPException(400, "Slug is already in use")
article = Article(
slug=data.slug, title=data.title, summary=data.summary, content=data.content,
sections=[section.model_dump() for section in data.sections],
category_id=data.category_id, section_id=data.section_id,
user_id=current_user.id, status="draft",
)
db.add(article)
db.flush()
article_service.record_slug(db, article)
db.commit()
db.refresh(article)
_reembed(db, article)
return _article_json(article)
@router.get("/linked")
def articles_for_question(
question_id: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Articles linked to a bank-visible question (published, or drafts for educators)."""
if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(current_user)).first():
raise HTTPException(404, "Question not found")
rows = db.query(QuestionArticleLink, Article).join(Article, Article.id == QuestionArticleLink.article_id).filter(
QuestionArticleLink.question_id == question_id,
).all()
result = []
for link, article in rows:
if article.status != "published" and not current_user.is_moderator:
continue
result.append({**_article_json(article), "section_id": link.section_id})
return result
@router.get("/recent")
def recently_viewed(
limit: int = Query(5, ge=1, le=20),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Articles this learner opened most recently, newest first."""
rows = (
db.query(Article, ArticleView.viewed_at)
.join(ArticleView, ArticleView.article_id == Article.id)
.filter(ArticleView.user_id == current_user.id, Article.status == "published")
.order_by(ArticleView.viewed_at.desc())
.limit(limit)
.all()
)
return [{"id": article.id, "title": article.title, "slug": article.slug,
"viewed_at": viewed_at.isoformat() if viewed_at else None}
for article, viewed_at in rows]
def _plain_excerpt(article: Article, limit: int = 260) -> str:
"""First readable prose in an article, with the markup taken out.
A preview shows what the link leads to, so it wants the opening sentences —
not a heading, a table pipe, or an image tag rendered as `![](…)`.
"""
source = article.summary or article.content or ""
if not source.strip() and article.sections:
source = (article.sections[0] or {}).get("content") or ""
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", source) # images
# Our own cross-reference syntax, before the plain-link rule below gets at
# it: `[[288|eczema]]` and `[[Eczema|eczema]]` both mean a word, and the
# generic rule left "[[288 eczema]]" on the card. Which half is the word
# depends on which half is a number.
text = re.sub(r"\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|[a-z0-9-]+)?)\]\]",
lambda m: (m.group(2) or m.group(3) or "").strip(), text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # links keep their words
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) # headings
text = re.sub(r"[*_`>|]|^\s*[-+]\s", " ", text, flags=re.M)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit].rstrip() + "" if len(text) > limit else text
@router.get("/by-slug/{slug}")
def article_by_slug(slug: str, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Resolve a slug — current or historical — to the article it names."""
article = article_service.resolve_slug(db, slug)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
# `moved` tells the caller to correct its URL rather than keep using the old one.
return {"id": article.id, "slug": article.slug, "title": article.title,
"moved": article.slug != slug.strip().lower()}
@router.get("/preview/{slug}")
def preview_article(
slug: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""What a cross-reference points at, for a hover card.
Deliberately small: a link preview that fetched whole articles would pull
down the library a paragraph at a time as somebody reads.
"""
article = article_service.resolve_slug(db, slug)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"excerpt": _plain_excerpt(article),
"section_count": len(article.sections or []),
"status": article.status,
}
@router.get("/{article_id}")
def get_article(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
_record_view(db, current_user, article)
data = _article_json(article)
allowed = _views_for(db, current_user)
data["variants"] = [v for v in data["variants"] if v in allowed]
data["sections"] = [s for s in article_service.normalise_sections(article.sections or [])
if s.get("variant") in allowed]
# An editor has to see the whole article to edit it; a learner does not.
if current_user.is_moderator or article.user_id == current_user.id:
data["all_variants"] = article_service.available_variants(article)
categories = db.query(QuestionCategory).all()
data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else []
return data
@router.get("/{article_id}/questions")
def article_questions(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator:
raise HTTPException(404, "Article not found")
rows = db.query(QuestionArticleLink, Question).join(Question, Question.id == QuestionArticleLink.question_id).filter(
QuestionArticleLink.article_id == article_id,
).all()
questions = []
for link, question in rows:
if not current_user.is_moderator and not db.query(Question.id).filter(
Question.id == question.id, bank_question_predicate(current_user)).first():
continue
questions.append({
"question_id": question.id, "question_text": question.question_text,
"correct_answer": question.correct_answer, "explanation": question.explanation,
"section_id": link.section_id,
})
return questions
@router.get("/{article_id}/cards")
def article_cards(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator:
raise HTTPException(404, "Article not found")
rows = db.query(FlashcardArticleLink, Flashcard, FlashcardDeck).join(
Flashcard, Flashcard.id == FlashcardArticleLink.flashcard_id,
).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
FlashcardArticleLink.article_id == article_id, FlashcardDeck.deleted_at.is_(None),
).all()
cards = []
for link, card, deck in rows:
if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared:
continue
cards.append({"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
"front": card.front, "back": card.back, "article_section_id": link.article_section_id})
return cards
@router.patch("/{article_id}")
def update_article(
article_id: int,
data: ArticleWrite,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not can_edit_article(db, current_user, article):
raise HTTPException(403, "Not your article")
_validate_sections(data.sections)
if data.category_id and not db.get(QuestionCategory, data.category_id):
raise HTTPException(400, "Category not found")
_validate_source_section(db, data.section_id)
if db.query(Article.id).filter(Article.slug == data.slug, Article.id != article.id).first():
raise HTTPException(400, "Slug is already in use")
# Keep what it was before it becomes something else.
article_service.snapshot(db, article, current_user.id)
article.slug, article.title, article.summary = data.slug, data.title, data.summary
article.content = article_service.upgrade_markers(db, data.content)
article.sections = article_service.normalise_sections([
{**section.model_dump(),
"content": article_service.upgrade_markers(db, section.content)}
for section in data.sections
])
article.category_id, article.section_id = data.category_id, data.section_id
if data.references is not None:
article.references_json = [ref.model_dump() for ref in data.references]
article_service.record_slug(db, article)
# Remediate links whose section was removed; whole-article links survive renames.
kept = _section_ids(article)
db.query(QuestionArticleLink).filter(
QuestionArticleLink.article_id == article.id,
QuestionArticleLink.section_id.isnot(None),
QuestionArticleLink.section_id.notin_(kept) if kept else True,
).delete(synchronize_session=False)
db.query(FlashcardArticleLink).filter(
FlashcardArticleLink.article_id == article.id,
FlashcardArticleLink.article_section_id.isnot(None),
FlashcardArticleLink.article_section_id.notin_(kept) if kept else True,
).delete(synchronize_session=False)
db.commit()
db.refresh(article)
_reembed(db, article)
# Told at the moment of saving, which is the last point the person who wrote
# the link is still looking at it.
return {**_article_json(article), "broken_links": article_service.broken_markers(db, article)}
@router.delete("/{article_id}", status_code=204)
def delete_article(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not can_edit_article(db, current_user, article):
raise HTTPException(403, "Not your article")
db.delete(article)
db.commit()
@router.post("/{article_id}/publish")
def publish_article(
article_id: int,
data: ArticlePublish,
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")
article.status = "published" if data.published else "draft"
db.commit()
return _article_json(article)
@router.put("/{article_id}/links")
def link_question(
article_id: int,
data: ArticleLinkIn,
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")
if data.section_id is not None and data.section_id not in _section_ids(article):
raise HTTPException(400, "Section not found in this article")
if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first():
raise HTTPException(404, "Question not found")
existing = db.query(QuestionArticleLink).filter_by(
question_id=data.question_id, article_id=article_id, section_id=data.section_id,
).first()
if existing:
return {"linked": False}
db.add(QuestionArticleLink(question_id=data.question_id, article_id=article_id,
section_id=data.section_id, user_id=current_user.id))
db.commit()
return {"linked": True}
@router.delete("/{article_id}/links/{question_id}", status_code=204)
def unlink_question(
article_id: int,
question_id: int,
section_id: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
db.query(QuestionArticleLink).filter_by(
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, 199)
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
# ── Editorial: review workflow, revisions, and the queue of work ──────────────
class StatusIn(BaseModel):
status: str
@router.post("/{article_id}/status")
def set_article_status(article_id: int, data: StatusIn, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Move an article through draft → in review → published.
An author may send their own work for review; only a moderator may publish,
because publishing is the point at which nobody checks it again.
"""
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
is_author = article.user_id == current_user.id
if not (current_user.is_moderator or is_author):
raise HTTPException(403, "Not your article")
if data.status == "published" and not current_user.is_moderator:
raise HTTPException(403, "Only a moderator can publish an article")
try:
article_service.set_status(db, article, data.status, current_user.id)
except ValueError as error:
raise HTTPException(400, str(error))
db.commit()
return {"id": article.id, "status": article.status}
@router.get("/{article_id}/revisions")
def list_revisions(article_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not can_edit_article(db, current_user, article):
raise HTTPException(403, "Not your article")
rows = db.query(ArticleRevision).filter(
ArticleRevision.article_id == article_id).order_by(ArticleRevision.id.desc()).limit(30).all()
return [{
"id": r.id, "title": r.title, "status": r.status, "note": r.note,
"created_at": r.created_at, "created_by": r.created_by,
"section_count": len(r.sections or []),
} for r in rows]
@router.get("/{article_id}/revisions/{revision_id}")
def read_revision(article_id: int, revision_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
revision = db.get(ArticleRevision, revision_id)
if not revision or revision.article_id != article_id:
raise HTTPException(404, "Revision not found")
article = db.get(Article, article_id)
if not (current_user.is_moderator or (article and article.user_id == current_user.id)):
raise HTTPException(403, "Not your article")
return {"id": revision.id, "title": revision.title, "summary": revision.summary,
"content": revision.content, "sections": revision.sections,
"references": revision.references_json or [], "created_at": revision.created_at}
@router.post("/{article_id}/revisions/{revision_id}/restore")
def restore_revision(article_id: int, revision_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Put an old version back, keeping the current one as a revision of its own.
Restoring is itself an edit, so it is snapshotted too — otherwise the way
back from a mistaken restore is gone.
"""
revision = db.get(ArticleRevision, revision_id)
article = db.get(Article, article_id)
if not article or not revision or revision.article_id != article_id:
raise HTTPException(404, "Revision not found")
article_service.snapshot(db, article, current_user.id, note=f"before restoring #{revision.id}")
article.title, article.summary, article.content = revision.title, revision.summary, revision.content
article.sections = article_service.normalise_sections(revision.sections or [])
article.references_json = revision.references_json
db.commit()
db.refresh(article)
_reembed(db, article)
return _article_json(article)
@router.get("/editorial/queue")
def editorial_queue(db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""What still needs a person: the work, not a list of everything.
Each bucket is something an editor can act on today. Counting articles by
status alone would say how many exist, which is not a queue.
"""
articles = db.query(Article).all()
linked = {row[0] for row in db.query(QuestionArticleLink.article_id).distinct().all()}
def row(article):
return {"id": article.id, "slug": article.slug, "title": article.title,
"status": article.status, "generated_by": article.generated_by,
"updated_at": article.updated_at,
"variants": article_service.available_variants(article)}
awaiting = [row(a) for a in articles if a.status == "in_review"]
machine_drafts = [row(a) for a in articles
if a.status == "draft" and a.generated_by]
no_references = [row(a) for a in articles
if a.status != "draft" and not (a.references_json or [])]
no_questions = [row(a) for a in articles
if a.status == "published" and a.id not in linked]
thin = [row(a) for a in articles if len(a.sections or []) < 2]
return {
"counts": {
"total": len(articles),
"draft": sum(1 for a in articles if a.status == "draft"),
"in_review": len(awaiting),
"published": sum(1 for a in articles if a.status == "published"),
},
# Ordered by what blocks a learner soonest.
"awaiting_review": awaiting[:100],
"machine_drafts": machine_drafts[:100],
"published_without_references": no_references[:100],
"published_without_questions": no_questions[:100],
"thin": thin[:100],
}