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
90 lines
3.9 KiB
Python
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)
|