pdf-quiz-generator/backend/alembic/versions/b6c7d8e9f0a1_article_cms.py
Daniel 025e5bb4ac feat: article CMS, three reading views, and articles written from the library
Standardises cross-references the way we agreed, and puts a CMS around articles
so hundreds of generated drafts are reviewable rather than merely present.

Links, made rename-proof
`[[7|Febrile seizures]]` resolves by id and displays the text — the id is the
part that must not change, the text is what keeps prose readable while you write
it. `[[old-slug]]` still resolves and is rewritten to the id form on save, not in
a migration: an article nobody has touched is not broken, and rewriting prose no
one asked to change is how an editor stops trusting the editor. Every slug an
article has ever had is kept, so a rename redirects instead of 404ing, and a save
reports markers pointing at nothing — at the moment the person who wrote the link
is still looking at it.

Three views of one topic
The full article to study from, the key points to revise from, the clinical view
to act from, with doses. They are views of one article rather than three
articles, so the numbers cannot drift apart and a question linked to the topic
still means one thing. Each section carries its variant; articles written before
this are the long view, unchanged.

CMS
draft → in review → published, with an author able to submit and only a
moderator able to publish. Every save snapshots what was there, restorable, and
restoring is itself snapshotted or the way back from a mistaken restore is gone.
The editorial queue is work rather than inventory: waiting for review, generated
and unread, published without sources, published with nothing to practise,
barely written. An empty bucket is drawn as good news, not as an alert.

Articles from the clinical library
The library index is 1.8M chunks of reference texts embedded with bge-m3 — the
same model PedsHub already uses, so our query vectors are directly comparable and
nothing had to be re-indexed. Retrieval supplies the facts and the provenance;
the model supplies the prose. References are built from the metadata of the
passages actually retrieved, never from the model, so a reference cannot be
invented — the same property that makes an AI Mode citation trustworthy. A topic
with fewer than three grounding passages is skipped rather than written from
memory. Everything lands as a draft.

Two things worth naming. The generated text is original writing grounded in those
books, not extracts from them: their facts are usable, their sentences are their
publishers'. And there are two Milvus servers on this host — the collection with
the data is the one reached as `milvus`, not the similarly named one on the other
stack, which I wired up first and which silently refused.

Also fixed along the way: `litellm==1.28.13` has been withdrawn from PyPI, so
requirements.txt could no longer be resolved from scratch and the image only
built because of a cached layer. Later additions go in their own layer until the
pins are refreshed.

182 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:13:07 +02:00

90 lines
3.9 KiB
Python

"""Article CMS: review workflow, revisions, slug history, references.
Revision ID: b6c7d8e9f0a1
Revises: a5b6c7d8e9f0
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "b6c7d8e9f0a1"
down_revision = "a5b6c7d8e9f0"
branch_labels = None
depends_on = None
def _has_table(name: str) -> bool:
return name in inspect(op.get_bind()).get_table_names()
def _has_column(table: str, column: str) -> bool:
return column in {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
def upgrade():
# `Base.metadata.create_all()` runs at startup as a fallback for fresh
# deploys, so on a running box it will already have created whatever the
# models describe. This migration is the record of the change and must apply
# cleanly either way, so every step checks first.
# Review sits between draft and published: generated medical writing that
# nobody has read is exactly what must not reach a learner.
for column in (
sa.Column("submitted_at", sa.DateTime, nullable=True),
sa.Column("reviewed_at", sa.DateTime, nullable=True),
sa.Column("reviewed_by", sa.Integer,
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
# Where the facts came from. A list of sources, not in-text markers.
sa.Column("references_json", sa.JSON, nullable=True),
# What produced it, so machine-written drafts stay visibly distinct from
# an educator's own writing for as long as they need to be.
sa.Column("generated_by", sa.String(80), nullable=True),
sa.Column("generated_at", sa.DateTime, nullable=True),
):
if not _has_column("articles", column.name):
op.add_column("articles", column)
if not _has_table("article_revisions"):
op.create_table(
"article_revisions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("article_id", sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("summary", sa.Text, nullable=True),
sa.Column("content", sa.Text, nullable=True),
sa.Column("sections", sa.JSON, nullable=False),
sa.Column("references_json", sa.JSON, nullable=True),
sa.Column("status", sa.String(20), nullable=True),
sa.Column("note", sa.String(200), nullable=True),
sa.Column("created_by", sa.Integer,
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
# Every slug an article has ever had, so a rename cannot orphan the
# cross-references pointing at the old one.
if not _has_table("article_slugs"):
op.create_table(
"article_slugs",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("slug", sa.String(120), nullable=False, unique=True, index=True),
sa.Column("article_id", sa.Integer,
sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
# Seed it with what every article is called today, or the history starts
# empty and the first rename still breaks the links it should have caught.
op.execute("""
INSERT INTO article_slugs (slug, article_id)
SELECT slug, id FROM articles
ON CONFLICT (slug) DO NOTHING
""")
def downgrade():
op.drop_table("article_slugs")
op.drop_table("article_revisions")
for column in ("generated_at", "generated_by", "references_json",
"reviewed_by", "reviewed_at", "submitted_at"):
if _has_column("articles", column):
op.drop_column("articles", column)