diff --git a/backend/alembic/versions/b6c7d8e9f0a1_article_cms.py b/backend/alembic/versions/b6c7d8e9f0a1_article_cms.py
new file mode 100644
index 0000000..e256aca
--- /dev/null
+++ b/backend/alembic/versions/b6c7d8e9f0a1_article_cms.py
@@ -0,0 +1,90 @@
+"""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)
diff --git a/backend/app/config.py b/backend/app/config.py
index 3b090e5..4b9fd7d 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -71,4 +71,12 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
+ # Clinical library index (Milvus on the ped-ai stack), read-only. Articles
+ # are grounded in what it retrieves; nothing here writes to it.
+ CLINICAL_MILVUS_URI: str = ""
+ CLINICAL_MILVUS_TOKEN: str = ""
+ CLINICAL_MILVUS_COLLECTION: str = "mcp_bge_m3_1024"
+
+
settings = Settings()
+
diff --git a/backend/app/models/article.py b/backend/app/models/article.py
index 954a4b9..8011f2b 100644
--- a/backend/app/models/article.py
+++ b/backend/app/models/article.py
@@ -22,7 +22,16 @@ class Article(Base, Embeddable):
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True) # Optional PDF source range.
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
- status = Column(String, default="draft") # draft | published
+ status = Column(String, default="draft") # draft | in_review | published
+ submitted_at = Column(DateTime, nullable=True)
+ reviewed_at = Column(DateTime, nullable=True)
+ reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ # Sources the article was written from. A list, not in-text markers.
+ references_json = Column(JSON, nullable=True)
+ # What wrote it, so a machine-written draft stays visibly one until an
+ # educator has been through it.
+ generated_by = Column(String(80), nullable=True)
+ generated_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -46,6 +55,45 @@ class QuestionArticleLink(Base):
article = relationship("Article", back_populates="links")
+class ArticleRevision(Base):
+ """A snapshot of an article as it was before a save.
+
+ Kept for the same reason question versions are: an editor who breaks
+ something at 2am needs to get back to what was there, and a diff nobody can
+ reach is not a safety net.
+ """
+
+ __tablename__ = "article_revisions"
+
+ id = Column(Integer, primary_key=True, index=True)
+ article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
+ title = Column(String(300), nullable=False)
+ summary = Column(Text, nullable=True)
+ content = Column(Text, nullable=True)
+ sections = Column(JSON, nullable=False, default=list)
+ references_json = Column(JSON, nullable=True)
+ status = Column(String(20), nullable=True)
+ note = Column(String(200), nullable=True)
+ created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class ArticleSlug(Base):
+ """Every slug an article has ever had.
+
+ A cross-reference written today must survive a rename tomorrow. Old slugs
+ redirect rather than 404, so the author who renamed something does not
+ silently break every article that pointed at it.
+ """
+
+ __tablename__ = "article_slugs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ slug = Column(String(120), unique=True, nullable=False, index=True)
+ article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
class ArticleView(Base):
"""Last time a learner opened an article — one row per user and article."""
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 796def9..6637ade 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -8,7 +8,11 @@ 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, ArticleSectionIndex, ArticleView, QuestionArticleLink
+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
@@ -36,6 +40,9 @@ class ArticleSection(BaseModel):
# 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 ArticleWrite(BaseModel):
@@ -121,6 +128,10 @@ def _validate_sections(sections: list[ArticleSection]):
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.
@@ -130,6 +141,11 @@ def _validate_sections(sections: list[ArticleSection]):
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}
@@ -158,6 +174,11 @@ def _article_json(article: Article) -> dict:
"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,
}
@@ -273,6 +294,8 @@ def create_article(
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)
@@ -336,6 +359,20 @@ def _plain_excerpt(article: Article, limit: int = 260) -> str:
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,
@@ -347,7 +384,7 @@ def preview_article(
Deliberately small: a link preview that fetched whole articles would pull
down the library a paragraph at a time as somebody reads.
"""
- article = db.query(Article).filter(Article.slug == slug.lower()).first()
+ 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:
@@ -450,9 +487,18 @@ def update_article(
_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")
- article.slug, article.title, article.summary, article.content = data.slug, data.title, data.summary, data.content
- article.sections = [section.model_dump() for section in data.sections]
+ # 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
+ 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(
@@ -468,7 +514,9 @@ def update_article(
db.commit()
db.refresh(article)
_reembed(db, article)
- return _article_json(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)
@@ -622,3 +670,128 @@ def get_article_job(job_id: str, current_user: User = Depends(get_current_user))
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 (current_user.is_moderator or article.user_id == current_user.id):
+ 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],
+ }
diff --git a/backend/app/services/article_service.py b/backend/app/services/article_service.py
new file mode 100644
index 0000000..764d1f2
--- /dev/null
+++ b/backend/app/services/article_service.py
@@ -0,0 +1,162 @@
+"""Article editing rules: variants, revisions, slugs and cross-reference markers.
+
+Three things live here because they are decisions rather than plumbing, and each
+one has a failure mode worth naming.
+
+**Variants.** One article is read three ways — the full text, the key points, and
+the clinical view that carries management and doses. They are one article rather
+than three because they describe one condition: split into three rows they drift
+apart, and a question linked to "Bronchiolitis" would have to pick which of the
+three it meant. Each section carries the variant it belongs to.
+
+**Slugs.** A cross-reference written today has to survive a rename tomorrow, so
+every slug an article has ever had is kept and old ones redirect. Without this a
+rename silently breaks every article pointing at the old name, and the person who
+renamed it never finds out.
+
+**Markers.** `[[7|Febrile seizures]]` resolves by id and displays the text. The
+id is what makes it rename-proof; the text is what makes the prose readable while
+you are writing it.
+"""
+import logging
+import re
+from datetime import datetime
+
+from sqlalchemy.orm import Session
+
+from app.models.article import Article, ArticleRevision, ArticleSlug
+
+logger = logging.getLogger(__name__)
+
+VARIANTS = ("long", "key_points", "clinical")
+DEFAULT_VARIANT = "long"
+
+# [[7|Febrile seizures]] — id first, because the id is the part that must not
+# change. [[febrile-seizures]] is the older slug form and still resolves.
+MARKER_RE = re.compile(r"\[\[(?:(\d+)\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
+
+STATUSES = ("draft", "in_review", "published")
+
+
+def normalise_sections(sections: list[dict]) -> list[dict]:
+ """Stamp every section with a variant, defaulting to the full article.
+
+ Articles written before variants existed have no such field; treating them
+ as the long version is what keeps them rendering unchanged.
+ """
+ out = []
+ for section in sections:
+ row = dict(section)
+ if row.get("variant") not in VARIANTS:
+ row["variant"] = DEFAULT_VARIANT
+ out.append(row)
+ return out
+
+
+def sections_for(article: Article, variant: str) -> list[dict]:
+ """The sections of one view, in order."""
+ variant = variant if variant in VARIANTS else DEFAULT_VARIANT
+ return [s for s in normalise_sections(article.sections or [])
+ if s.get("variant") == variant]
+
+
+def available_variants(article: Article) -> list[str]:
+ """Which views this article actually has, so the toggle offers no empty one."""
+ present = {s.get("variant") for s in normalise_sections(article.sections or [])}
+ return [v for v in VARIANTS if v in present]
+
+
+def record_slug(db: Session, article: Article) -> None:
+ """Remember the article's current slug, so it keeps resolving after a rename."""
+ existing = db.query(ArticleSlug).filter(ArticleSlug.slug == article.slug).first()
+ if existing is None:
+ db.add(ArticleSlug(slug=article.slug, article_id=article.id))
+ elif existing.article_id != article.id:
+ # Another article now owns this name. The newest claim wins, because the
+ # alternative is a redirect that sends readers to the wrong condition.
+ existing.article_id = article.id
+
+
+def resolve_slug(db: Session, slug: str) -> Article | None:
+ """Find an article by its current slug, or by one it used to have."""
+ slug = (slug or "").strip().lower()
+ if not slug:
+ return None
+ article = db.query(Article).filter(Article.slug == slug).first()
+ if article:
+ return article
+ historical = db.query(ArticleSlug).filter(ArticleSlug.slug == slug).first()
+ return db.get(Article, historical.article_id) if historical else None
+
+
+def snapshot(db: Session, article: Article, user_id: int | None, note: str | None = None) -> None:
+ """Keep what the article was, before it becomes something else."""
+ db.add(ArticleRevision(
+ article_id=article.id, title=article.title, summary=article.summary,
+ content=article.content, sections=article.sections or [],
+ references_json=article.references_json, status=article.status,
+ note=(note or "")[:200] or None, created_by=user_id,
+ ))
+
+
+def marker_targets(text: str) -> tuple[set[int], set[str]]:
+ """The article ids and legacy slugs a piece of prose points at."""
+ ids, slugs = set(), set()
+ for match in MARKER_RE.finditer(text or ""):
+ if match.group(1):
+ ids.add(int(match.group(1)))
+ elif match.group(3):
+ slugs.add(match.group(3))
+ return ids, slugs
+
+
+def broken_markers(db: Session, article: Article) -> list[str]:
+ """Markers in this article that point at nothing.
+
+ Checked when an article is saved, because that is the last moment the person
+ who wrote the link is still looking at it. A dead cross-reference found a
+ week later belongs to nobody.
+ """
+ body = " ".join(filter(None, [
+ article.content or "",
+ *[(s.get("content") or "") for s in (article.sections or [])],
+ ]))
+ ids, slugs = marker_targets(body)
+ broken = []
+ if ids:
+ known = {row[0] for row in db.query(Article.id).filter(Article.id.in_(ids)).all()}
+ broken.extend(f"[[{missing}|…]]" for missing in sorted(ids - known))
+ for slug in sorted(slugs):
+ if resolve_slug(db, slug) is None:
+ broken.append(f"[[{slug}]]")
+ return broken
+
+
+def upgrade_markers(db: Session, text: str) -> str:
+ """Rewrite legacy slug markers to the id form, keeping the title as the label.
+
+ Done on save rather than in a migration: an article nobody has touched is not
+ broken, and rewriting prose that no one asked to change is how you lose an
+ author's trust in the editor.
+ """
+ def replace(match: re.Match) -> str:
+ if match.group(1):
+ return match.group(0)
+ slug = match.group(3)
+ article = resolve_slug(db, slug)
+ return f"[[{article.id}|{article.title}]]" if article else match.group(0)
+
+ return MARKER_RE.sub(replace, text or "")
+
+
+def set_status(db: Session, article: Article, status: str, user_id: int | None) -> None:
+ """Move an article through draft → in_review → published."""
+ if status not in STATUSES:
+ raise ValueError(f"Unknown status: {status}")
+ now = datetime.utcnow()
+ if status == "in_review" and article.status != "in_review":
+ article.submitted_at = now
+ if status == "published" and article.status != "published":
+ article.reviewed_at = now
+ article.reviewed_by = user_id
+ article.status = status
diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py
new file mode 100644
index 0000000..0dbe014
--- /dev/null
+++ b/backend/app/services/article_writer.py
@@ -0,0 +1,190 @@
+"""Write a topic article from what the clinical library returns.
+
+The division of labour is deliberate and matches AI Mode: **retrieval supplies
+the facts and the provenance; the model supplies the prose.** References are
+built from the metadata of the passages that were actually retrieved, never from
+the model, so a reference cannot be invented — the same property that makes an
+AI Mode citation trustworthy.
+
+What is generated is an original piece of writing grounded in the library, not
+an extract from it. A textbook's sentences belong to its publisher; its facts do
+not, and an article that reproduced the sentences would be redistribution
+whatever produced the copy. The prompt says this plainly, and the length limits
+make a long verbatim passage impossible to hide.
+
+Three views come out of one call rather than three, because they are three
+readings of one topic and generating them separately lets them contradict each
+other on the numbers.
+"""
+import json
+import logging
+import re
+import uuid
+
+from sqlalchemy.orm import Session
+
+from app.models.article import Article
+from app.services import article_service, clinical_library
+from app.services.ai_service import _call_model, get_model_for_task
+
+logger = logging.getLogger(__name__)
+
+PASSAGES = 10
+# Long enough to be worth reading, short enough that nobody skims past the point.
+LONG_WORDS = 550
+SHELF = "Pediatrics"
+
+
+def slugify(name: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
+ return slug[:120] or "topic"
+
+
+def _prompt(topic: str, passages: list[dict]) -> str:
+ sources = "\n\n".join(
+ f"[{i + 1}] {p['source'].get('title', 'source')}"
+ f"{', p. ' + str(p['source']['page']) if p['source'].get('page') else ''}\n{p['text']}"
+ for i, p in enumerate(passages)
+ )
+ return f"""You are writing a study article about **{topic}** for a paediatric exam-revision platform.
+
+Write it from the reference passages below. They are extracts from standard
+textbooks and guidelines. Use them for the facts, the numbers and the structure —
+then write the article in your own words. Do not reproduce sentences from the
+passages: this is an article about the topic, not an extract from a book.
+
+If the passages do not cover something, leave it out rather than filling the gap
+from memory. A gap a reader can see is safer than a confident sentence nobody
+can check.
+
+Produce three views of the same topic:
+
+1. "long" — the full article, about {LONG_WORDS} words, in 4 to 7 sections.
+ Typical headings: Definition, Epidemiology, Etiology, Pathophysiology,
+ Clinical features, Diagnostics, Differential diagnosis, Treatment,
+ Complications, Prevention. Use only the ones the passages support.
+2. "key_points" — one section titled "Key points", 6 to 10 short bullets a
+ learner could revise from the night before an exam.
+3. "clinical" — one or two sections covering management at the bedside:
+ what to do, in what order, with drug doses and routes where the passages give
+ them. Include units and per-kilogram dosing exactly as stated. If a dose is
+ not in the passages, do not state one.
+
+Return ONLY valid JSON, no markdown fence:
+
+{{
+ "summary": "one or two sentences, no heading",
+ "long": [{{"title": "Definition", "content": "markdown"}}],
+ "key_points": [{{"title": "Key points", "content": "- bullet\\n- bullet"}}],
+ "clinical": [{{"title": "Management", "content": "markdown"}}]
+}}
+
+Markdown may use lists, bold and tables. Do not write citation markers, footnote
+numbers or URLs anywhere: the reference list is attached separately.
+
+REFERENCE PASSAGES
+
+{sources}"""
+
+
+def _sections(blocks: list[dict], variant: str) -> list[dict]:
+ """Turn the model's blocks into stable sections of one view."""
+ out = []
+ for block in blocks or []:
+ # The model occasionally returns a bare string where a block was asked
+ # for. Skipping it costs one section; letting it through ends the run.
+ if not isinstance(block, dict):
+ continue
+ title = str(block.get("title") or "").strip()
+ content = str(block.get("content") or "").strip()
+ if not title or not content:
+ continue
+ out.append({
+ # A fresh id per section, because links are made against these and a
+ # regenerated article must not silently inherit another's links.
+ "id": uuid.uuid4().hex,
+ "slug": slugify(title)[:60] or f"section-{len(out) + 1}",
+ "title": title[:300],
+ "content": content,
+ "parent_id": None,
+ "variant": variant,
+ })
+ return out
+
+
+def _unique_slugs(sections: list[dict]) -> list[dict]:
+ """Slugs are unique within an article, and three views repeat titles."""
+ seen: set[str] = set()
+ for section in sections:
+ base = section["slug"]
+ candidate, n = base, 2
+ while candidate in seen:
+ candidate = f"{base}-{n}"
+ n += 1
+ section["slug"] = candidate
+ seen.add(candidate)
+ return sections
+
+
+def write_article(db: Session, topic: str, category_id: int | None = None,
+ user_id: int | None = None) -> dict:
+ """Retrieve, write, and store one article as a draft.
+
+ Returns a small report rather than the article, because the caller is a
+ background run over hundreds of topics and wants to know what happened.
+ """
+ passages = clinical_library.search(topic, limit=PASSAGES, folder_contains=SHELF)
+ if len(passages) < 3:
+ # Too little to ground an article. Writing one anyway would produce
+ # exactly the confident unverifiable prose this is designed to avoid.
+ return {"topic": topic, "status": "skipped", "reason": "not enough source material",
+ "passages": len(passages)}
+
+ model_id, api_key = get_model_for_task(db, "extraction")
+ raw = _call_model(_prompt(topic, passages), model_id, api_key)
+ text = raw.strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```[a-z]*\n?|```$", "", text).strip()
+ try:
+ data = json.loads(text)
+ except ValueError:
+ logger.warning("Article writer returned unparseable JSON for %s", topic)
+ return {"topic": topic, "status": "failed", "reason": "model did not return JSON"}
+ if not isinstance(data, dict):
+ return {"topic": topic, "status": "failed", "reason": "model returned the wrong shape"}
+
+ sections = _unique_slugs([
+ *_sections(data.get("long"), "long"),
+ *_sections(data.get("key_points"), "key_points"),
+ *_sections(data.get("clinical"), "clinical"),
+ ])
+ if not sections:
+ return {"topic": topic, "status": "failed", "reason": "no usable sections"}
+
+ slug = slugify(topic)
+ existing = db.query(Article).filter(Article.slug == slug).first()
+ if existing:
+ return {"topic": topic, "status": "exists", "article_id": existing.id}
+
+ article = Article(
+ slug=slug, title=topic[:300],
+ summary=(data.get("summary") or "").strip()[:2000] or None,
+ content=None, sections=sections, category_id=category_id, user_id=user_id,
+ # Never published by generation. A person decides that.
+ status="draft",
+ # References come from the retrieved metadata, not from the model, so a
+ # source cannot be invented.
+ references_json=clinical_library.references_from(passages),
+ generated_by=f"clinical-library:{model_id}",
+ )
+ from datetime import datetime
+
+ article.generated_at = datetime.utcnow()
+ db.add(article)
+ db.flush()
+ article_service.record_slug(db, article)
+ db.commit()
+ db.refresh(article)
+ return {"topic": topic, "status": "written", "article_id": article.id,
+ "sections": len(sections), "references": len(article.references_json or []),
+ "passages": len(passages)}
diff --git a/backend/app/services/clinical_library.py b/backend/app/services/clinical_library.py
new file mode 100644
index 0000000..79beab6
--- /dev/null
+++ b/backend/app/services/clinical_library.py
@@ -0,0 +1,173 @@
+"""Read-only retrieval from the clinical library index.
+
+The index is a Milvus collection of ~1.8M chunks from the reference library —
+Nelson, Harrison's, Mandell's and the rest — embedded with bge-m3 at 1024
+dimensions. PedsHub already embeds with the same model through the same LiteLLM
+proxy, so a query vector generated here is directly comparable to what is
+stored there; no second embedder and no re-indexing.
+
+Two things this module will not do, deliberately:
+
+* **It never writes.** The credentials are a query user and the collection
+ belongs to another system. Articles are grounded in what comes back; nothing
+ flows the other way.
+* **It never hands back long verbatim passages for publication.** What it
+ returns is source material for someone to write *from*, plus the citation
+ needed to say where a fact came from. Copying a textbook into an article and
+ publishing it is redistribution, whatever produced the copy.
+"""
+import json
+import logging
+import re
+import urllib.parse
+
+import requests
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+# Enough context to write a section from, without turning a prompt into a book.
+SNIPPET_CHARS = 1200
+DEFAULT_LIMIT = 12
+
+
+TIMEOUT = 20
+
+
+def _call(path: str, body: dict) -> dict | None:
+ """Milvus over its HTTP API rather than gRPC.
+
+ The gRPC client drags in a protobuf and grpcio version surface that fights
+ with what this image already pins for other services. HTTP has neither
+ problem and this module only ever reads.
+ """
+ if not settings.CLINICAL_MILVUS_URI:
+ return None
+ try:
+ response = requests.post(
+ f"{settings.CLINICAL_MILVUS_URI.rstrip('/')}{path}",
+ headers={"Authorization": f"Bearer {settings.CLINICAL_MILVUS_TOKEN}",
+ "Content-Type": "application/json"},
+ json=body, timeout=TIMEOUT,
+ )
+ except requests.RequestException:
+ logger.warning("Clinical library unreachable", exc_info=True)
+ return None
+ if response.status_code != 200:
+ logger.warning("Clinical library refused %s: %s %s", path,
+ response.status_code, response.text[:200])
+ return None
+ payload = response.json()
+ if payload.get("code") not in (0, None):
+ logger.warning("Clinical library error on %s: %s", path, str(payload)[:200])
+ return None
+ return payload
+
+
+def available() -> bool:
+ """Whether the index can be reached at all, for an honest error upstream."""
+ payload = _call("/v2/vectordb/collections/list", {})
+ return bool(payload) and settings.CLINICAL_MILVUS_COLLECTION in (payload.get("data") or [])
+
+
+def _tidy(value: str | None) -> str:
+ """File paths in this index are URL-encoded; a citation should not be."""
+ if not value:
+ return ""
+ return re.sub(r"\s+", " ", urllib.parse.unquote(value)).strip()
+
+
+def _source_of(payload: dict) -> dict:
+ """The bibliographic details, separated from the prose."""
+ title = _tidy(payload.get("title")) or _tidy(payload.get("file_path")).rsplit("/", 1)[-1]
+ # Library filenames carry the shop they came from; a reference should not.
+ title = re.sub(r"\s*\((?:z-library|1lib|z-lib)[^)]*\)", "", title, flags=re.I).strip()
+ title = re.sub(r"\.pdf$", "", title, flags=re.I).strip()
+ return {
+ "title": title,
+ "author": _tidy(payload.get("author")),
+ "page": payload.get("page_number"),
+ "specialty": _tidy(payload.get("subcategory")),
+ }
+
+
+def search(query: str, limit: int = DEFAULT_LIMIT,
+ folder_contains: str | None = None) -> list[dict]:
+ """Passages relevant to a topic, each with where it came from.
+
+ `folder_contains` narrows to one shelf of the library — passing "Pediatrics"
+ is how an article about a childhood condition is grounded in the paediatric
+ texts rather than in the adult ones that outnumber them.
+ """
+ query = (query or "").strip()
+ if not query:
+ return []
+
+ from app.services.embedding_service import generate_embedding
+
+ vector = generate_embedding(query)
+ if not vector:
+ logger.warning("No query embedding; cannot search the clinical library")
+ return []
+
+ body = {
+ "collectionName": settings.CLINICAL_MILVUS_COLLECTION,
+ "data": [vector],
+ "annsField": "dense",
+ "limit": limit,
+ "outputFields": ["payload", "folder_path", "file_path", "chunk_index"],
+ }
+ # Filtering in the query is what keeps the shelf constraint honest: taking
+ # the top hits and discarding the wrong folder afterwards would silently
+ # return fewer results the more specific the request was.
+ if folder_contains:
+ safe = folder_contains.replace('"', "")
+ body["filter"] = f'folder_path like "%{safe}%"'
+
+ payload = _call("/v2/vectordb/entities/search", body)
+ if not payload:
+ return []
+
+ results = []
+ for hit in (payload.get("data") or []):
+ raw = hit.get("payload") or {}
+ if isinstance(raw, str):
+ try:
+ raw = json.loads(raw)
+ except ValueError:
+ raw = {}
+ text = _tidy(raw.get("excerpt"))
+ if not text:
+ continue
+ results.append({
+ "text": text[:SNIPPET_CHARS],
+ "score": float(hit.get("distance", 0.0)),
+ "source": _source_of(raw),
+ "folder": _tidy(hit.get("folder_path")),
+ })
+ return results
+
+
+def references_from(passages: list[dict], limit: int = 8) -> list[dict]:
+ """One entry per book, with the pages that were actually used.
+
+ Deduplicated by title because forty chunks of Nelson is one reference, not
+ forty — a reference list that repeats a book is noise standing where
+ provenance should be.
+ """
+ by_title: dict[str, dict] = {}
+ for passage in passages:
+ source = passage.get("source") or {}
+ title = source.get("title")
+ if not title:
+ continue
+ entry = by_title.setdefault(title, {
+ "title": title, "author": source.get("author") or None, "pages": [],
+ })
+ page = source.get("page")
+ if isinstance(page, int) and page not in entry["pages"]:
+ entry["pages"].append(page)
+ for entry in by_title.values():
+ entry["pages"] = sorted(entry["pages"])[:6]
+ return list(by_title.values())[:limit]
diff --git a/backend/scripts/fix_lab_formatting.py b/backend/scripts/fix_lab_formatting.py
new file mode 100644
index 0000000..eb4a9b8
--- /dev/null
+++ b/backend/scripts/fix_lab_formatting.py
@@ -0,0 +1,429 @@
+"""Repair OCR-damaged units and turn inline lab panels into markdown tables.
+
+The PREP PDFs were scanned, so the extracted stems carry two separate injuries.
+
+ 1. Unit corruption. The scanner confuses letter pairs that share a shape —
+ "m" reads as "rn" or "in", "µ" as "p" or "4" — and superscripts are lost
+ entirely, so "3.5 × 10⁹/L" arrives as "3.5 x 109/L". The replacements below
+ were not guessed: every one was found by listing the unit-shaped tokens
+ that actually occur in the bank and reading the ones that occur once or
+ twice, which is where the damage hides. Anything whose correction needed a
+ judgement call — "3,000/mL" that should probably be "/µL", "14.6 x 10A" —
+ is deliberately absent: a wrong unit is worse than an ugly one.
+
+ 2. A lab panel is a wall of prose. "Sodium, 139 mEq/L (139 mmol/L); Potassium,
+ …" runs for a paragraph, and the reader has to parse it themselves. A
+ markdown table separates analyte from value from SI value.
+
+Every change snapshots the question first through the same helper the edit UI
+uses, so anything here can be rolled back from the question's version history.
+
+ docker compose exec backend python -m scripts.fix_lab_formatting
+ docker compose exec backend python -m scripts.fix_lab_formatting --apply
+
+ --explanations repair units in explanation text as well as stems
+ --tables also convert lab panels to markdown tables (see below)
+ --limit N only look at the first N questions, for a quick look
+
+NOTE ON --tables: the quiz player renders a stem as plain text (QuizPage's
+ManualHighlightText slices the raw string so highlight offsets stay valid), so a
+markdown table currently shows up as literal pipes there. Table conversion is
+therefore opt-in until the stem is rendered as markdown. The unit repair is
+safe in every view and runs by default.
+"""
+import argparse
+import re
+import sys
+
+from app.database import SessionLocal
+from app.models.question import Question
+from app.routers.questions import _snapshot_question
+
+# The user id recorded against the snapshots this script writes. NULL is
+# allowed by the column and is honest: no person made these edits.
+SCRIPT_EDITOR_ID = None
+
+SUPERSCRIPT = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
+ "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹"}
+
+# Powers of ten that name a real haematology unit: 10³/µL, 10⁶/µL, 10⁹/L,
+# 10¹²/L. A stem holding "x 101/µL" or "x 10A" lost a digit rather than a
+# superscript, and rewriting it would invent a number, so it is left alone.
+REAL_POWERS = {"3", "6", "9", "12"}
+
+
+def _repair_power(match: re.Match) -> str:
+ """"5.6 x 109/L" -> "5.6 × 10⁹/L", but only for a power that exists."""
+ value, exponent = match.group("value"), match.group("exp")
+ if exponent not in REAL_POWERS:
+ return match.group(0)
+ digits = "".join(SUPERSCRIPT[d] for d in exponent)
+ return f"{value} × 10{digits}{match.group('tail')}"
+
+
+# (pattern, replacement, why) — applied in order, every one idempotent.
+#
+# Each was verified against the questions it fires on. The narrow ones name a
+# single mangled token; the broad ones are anchored so they cannot fire on
+# prose (a digit before the unit, a slash after it, or an explicit multiplier).
+OCR_REPAIRS = [
+ # "m" scanned as the ligature "rn" or as "in". Both appear in chloride and
+ # sodium lines whose SI twin in the same sentence spells the unit correctly.
+ (re.compile(r"\bmrnol/L\b"), "mmol/L", "rn read for m"),
+ (re.compile(r"\binEq/L\b"), "mEq/L", "in read for m"),
+ (re.compile(r"\bbeats/ruin\b"), "beats/min", "min read as ruin"),
+ # Units that do not exist. Electrolytes and their SI twins are per litre;
+ # a per-decilitre spelling is always the scan losing the "m" of "mmol".
+ (re.compile(r"\bmmol/dL\b"), "mmol/L", "mmol/dL is not a unit"),
+ (re.compile(r"\bmEq/dL\b"), "mEq/L", "mEq/dL is not a unit"),
+ (re.compile(r"\bmmo/L\b"), "mmol/L", "dropped l"),
+ # "q" scanned as "g" — both lines carry an SI twin in mmol/L.
+ (re.compile(r"\bmEg/L\b"), "mEq/L", "g read for q"),
+ (re.compile(r"\bmOsm/Kg\b"), "mOsm/kg", "casing"),
+ # "≤" flattened to "<_" — the underscore is the lower half of the glyph.
+ (re.compile(r"<_\s*(?=\d)"), "≤", "<_ read for ≤"),
+ (re.compile(r">_\s*(?=\d)"), "≥", ">_ read for ≥"),
+ (re.compile(r"\bmMol/L\b"), "mmol/L", "casing"),
+ (re.compile(r"\bmg/dl\b"), "mg/dL", "casing"),
+ (re.compile(r"\bg/dl\b"), "g/dL", "casing"),
+ # µ scanned as p, 4 or lowercase l. Picolitres and "4mol" are never real;
+ # the counts they carry are ordinary per-microlitre cell counts.
+ (re.compile(r"(?<=\d)/pL\b"), "/µL", "p read for µ"),
+ (re.compile(r"\b4mol/L\b"), "µmol/L", "4 read for µ"),
+ (re.compile(r"(?<=\d)/µl\b"), "/µL", "casing"),
+ # mm Hg is two tokens; a slash between them is a scanning artefact.
+ (re.compile(r"\bmm\s*/\s*Hg\b"), "mm Hg", "stray slash"),
+ (re.compile(r"\bmmHg\b"), "mm Hg", "missing space"),
+ # Superscripts the scan flattened. Cubic units and BMI first, then powers
+ # of ten, which need the exponent checked before they can be rewritten.
+ (re.compile(r"\bkg/m2\b"), "kg/m²", "lost superscript"),
+ (re.compile(r"\b([µμu])m3\b"), r"µm³", "lost superscript"),
+ (re.compile(r"\bmm3\b"), "mm³", "lost superscript"),
+ # MCV is a volume, not a rate: "90/µm³" means "90 µm³".
+ (re.compile(r"(?<=\d)/µm³"), " µm³", "stray slash"),
+ (re.compile(
+ r"(?P\d[\d,]*(?:\.\d+)?)\s*[x×X]\s*10\s*(?P\d{1,2})(?P\s*/)"),
+ _repair_power, "lost power-of-ten superscript"),
+]
+
+
+def repair_units(text: str) -> tuple[str, list[str]]:
+ """Apply the replacement table, returning the new text and what fired."""
+ if not text:
+ return text or "", []
+ fired = []
+ for pattern, replacement, reason in OCR_REPAIRS:
+ new_text, count = pattern.subn(replacement, text)
+ if new_text != text:
+ fired.append(f"{reason} ×{count}")
+ text = new_text
+ return text, fired
+
+
+# ---------------------------------------------------------------------------
+# Lab panel -> markdown table
+# ---------------------------------------------------------------------------
+
+# A value is a number (or a stock qualitative phrase) followed by up to four
+# unit-ish tokens and an optional parenthetical. "Unit-ish" is the load-bearing
+# part: without it the scanner walks straight out of the panel and swallows the
+# sentence that follows the last result.
+QUALITATIVE = (r"within normal limits|within the normal range|normal|negative|positive|"
+ r"pending|trace|not detected|nondetectable|nonreactive|reactive|absent|present")
+
+# Bare unit words that carry no digit and no slash, so nothing else identifies them.
+BARE_UNITS = (r"Hg|fL|fl|mm|cm|kg|mg|g|dL|mL|L|U|IU|sec|s|units?|cells?|mEq|mmol|nmol|pmol|"
+ r"[µμu]mol|mOsm|ng|pg|mIU|[µμu]IU|kcal|mcg|[µμu]g|[µμu]L|mL|MoM|mg|dl|per|"
+ r"[µμu]m³|mm³|hpf|HPF|LPF|seconds?|minutes?|hours?|days?|weeks?|months?|years?|to")
+_UNIT_TOKEN = (rf"(?:[x×X]|[<>≤≥]|\d[\w,./%°³²⁰-⁹\-]*|"
+ rf"[A-Za-zµμ³²⁰-⁹]+(?:/[A-Za-zµμ0-9³²⁰-⁹\-]+)+|%|°[CF]|"
+ rf"(?:{BARE_UNITS}))\.?(?![\w/])")
+_CONTINUATION = re.compile(rf"^{_UNIT_TOKEN}$")
+
+_QUALITATIVE_ONLY = re.compile(rf"^(?:{QUALITATIVE})$", re.I)
+_LEAD = rf"(?:[<>≤≥]\s*)?(?:\d[\w,./%°³²⁰-⁹\-]*|{QUALITATIVE})"
+# Continuations are separated by a space, never a newline: a line break ends the
+# result. Letting \s match it lets the value run into the sentence below the list.
+_ITEM = re.compile(
+ rf"(?[A-Za-z][A-Za-z0-9 '’\-()/%]{{1,60}}?)\s*[,:]\s*"
+ rf"(?P{_LEAD}(?:[ \t]+{_UNIT_TOKEN}){{0,6}})"
+ rf"(?P(?:[ \t]*\([^()]{{1,90}}\)){{0,2}})",
+ re.I)
+
+# Words that mean the "name" is really a clause, not an analyte.
+_NOT_A_NAME = re.compile(
+ r"\b(?:is|are|was|were|has|have|had|shows?|showed|reveals?|revealed|"
+ r"includes?|included|and|with|of|the|his|her|he|she|but|which|that|"
+ r"who|when|following|about|approximately|over|after|before|during|for)\b", re.I)
+
+# A differential is written the other way round — "29% segmented neutrophils,
+# 28% bands" — so reading it as name-then-value pairs each result with the
+# NEXT analyte's percentage. Anything sitting directly after a bare percentage
+# is part of such a list and is left as prose.
+_PERCENT_LABELS_NEXT = re.compile(r"\d\s*%\s*$")
+
+# A test name never carries a measurement; if it does, the split was wrong.
+_VALUE_INSIDE_NAME = re.compile(r"\d\s*(?:%|[A-Za-zµμ]+/[A-Za-zµμ])")
+
+# What a panel may start after: a lead-in's colon, a separator, a bullet, or a
+# line of its own. Anything else means the "panel" is a sentence — a string of
+# vital signs, say — and rewriting a sentence as a table breaks its grammar.
+_PANEL_OPENERS = (":", ";", "•", "·", "-", "–", "—", "\n")
+
+def _IS_SI_VALUE(inner: str) -> bool:
+ """A parenthetical is the SI twin only if it is one number and its unit.
+
+ "(4% segmented, 83% lymphocytes)" also opens with a digit but is a
+ differential, and filing it under SI units would be a lie about the data.
+ """
+ return (bool(re.match(r"^[<>≤≥]?\s*\d", inner))
+ and "," not in inner and len(inner.split()) <= 3)
+
+
+# A reference range is an annotation on the result above it, not a test.
+_RANGE_LABEL = re.compile(r"^(?:reference|normal)\s+(?:range|value)s?$", re.I)
+
+# Enough consecutive results that the block is a panel rather than a passing
+# mention of one or two values inside a sentence.
+MIN_ITEMS = 4
+
+# How much punctuation may sit between two results and still count as the same
+# panel: a separator and a space or two, never a whole clause.
+MAX_GAP = 4
+
+
+def _clean_value(value: str) -> str:
+ """Trim the sentence-ending punctuation that belongs to the prose, not the value."""
+ return value.strip().rstrip(",;.").strip()
+
+
+def _accept(match: re.Match) -> tuple[str, str, str] | None:
+ """(analyte, result, si) if this match is a real lab result, else None."""
+ # A "result" that starts inside a bracket is part of the previous result's
+ # reference range ("(normal, 150 to 350)"), not a new analyte.
+ if match.start() and match.string[match.start() - 1] in "([":
+ return None
+ if _PERCENT_LABELS_NEXT.search(match.string[: match.start()]):
+ return None
+
+ # The last entry of a written-out list carries the conjunction that joined
+ # it: "…; and oxygen saturation, 97%".
+ name = re.sub(r"^and\s+", "", match.group("name").strip(" ,:;-"), flags=re.I)
+ # A column header the extraction glued on ("Patient Result - Hemoglobin").
+ name = name.rpartition(" - ")[2].strip() or name
+ if len(name) < 2 or _NOT_A_NAME.search(name):
+ return None
+ # A name carrying its own value ("Factor II 0.20 U/mL (reference range")
+ # means the comma we split on belongs to a parenthetical, not to this test.
+ if _VALUE_INSIDE_NAME.search(name):
+ return None
+
+ value = match.group("value").strip()
+ if not _QUALITATIVE_ONLY.match(value):
+ # The first token opens the value; every later one must look like a unit.
+ for token in value.split()[1:]:
+ if not _CONTINUATION.match(token):
+ return None
+
+ si = ""
+ for paren in re.findall(r"\([^()]*\)", match.group("paren") or ""):
+ inner = paren[1:-1].strip()
+ # A parenthetical that opens with a number is the SI twin; anything
+ # else ("normal, 150 to 350") is a reference range and stays beside
+ # the result, where a reader expects to find it.
+ if not si and _IS_SI_VALUE(inner):
+ si = inner
+ else:
+ value = f"{value} {paren}"
+
+ # A bare percentage that labels the words after it ("Differential count, 29%
+ # segmented neutrophils") is the same trap from the other side.
+ if not si and value.rstrip().endswith("%") and \
+ re.match(r"^[ \t]+[a-z]", match.string[match.end():]):
+ return None
+ return name, _clean_value(value), si
+
+
+def find_panels(text: str) -> list[tuple[int, int, list[tuple[str, str, str]]]]:
+ """Locate runs of consecutive lab results: (start, end, items)."""
+ if not text:
+ return []
+ accepted = []
+ for match in _ITEM.finditer(text):
+ item = _accept(match)
+ if item:
+ accepted.append((match.start(), match.end(), item))
+
+ panels, run = [], []
+ for entry in accepted:
+ if run and entry[0] - run[-1][1] > MAX_GAP:
+ panels.append(run)
+ run = []
+ # Two results for the same analyte mean the run has drifted into prose
+ # that repeats a word; end the panel rather than emit a confused table.
+ if run and not _RANGE_LABEL.match(entry[2][0]) and \
+ entry[2][0].lower() in {r[2][0].lower() for r in run}:
+ panels.append(run)
+ run = []
+ run.append(entry)
+ if run:
+ panels.append(run)
+
+ out = []
+ for panel in panels:
+ end = panel[-1][1]
+ # A panel whose next word is lowercase was not parsed to its end — the
+ # last result trails off into "on arterial blood gas" or a differential.
+ # Leave the whole block as prose rather than publish a truncated table.
+ if re.match(r"^[ \t\n]*[.;,•·]*[ \t\n]*[a-z]", text[end:]):
+ continue
+ before = text[: panel[0][0]].rstrip(" \t")
+ if before and not before.endswith(_PANEL_OPENERS):
+ continue
+ items = _fold_reference_ranges([e[2] for e in panel])
+ if len(items) >= MIN_ITEMS:
+ out.append((panel[0][0], end, items))
+ return out
+
+
+def _fold_reference_ranges(items: list[tuple[str, str, str]]) -> list[tuple[str, str, str]]:
+ """"…, 125 U/L; reference range, ≤40 U/L" is one result, not two."""
+ folded: list[tuple[str, str, str]] = []
+ for name, value, si in items:
+ if folded and _RANGE_LABEL.match(name):
+ prev_name, prev_value, prev_si = folded[-1]
+ folded[-1] = (prev_name, f"{prev_value} ({name.lower()}, {value})", prev_si)
+ continue
+ folded.append((name, value, si))
+ return folded
+
+
+def render_table(items: list[tuple[str, str, str]]) -> str:
+ """A GFM table; the SI column only exists when something fills it."""
+ with_si = any(si for _n, _v, si in items)
+ header = ["Test", "Result"] + (["SI units"] if with_si else [])
+ lines = ["| " + " | ".join(header) + " |",
+ "| " + " | ".join(["---"] * len(header)) + " |"]
+ for name, value, si in items:
+ cells = [name, value] + ([si] if with_si else [])
+ lines.append("| " + " | ".join(c.replace("|", "\\|") for c in cells) + " |")
+ return "\n".join(lines)
+
+
+def tabulate_panels(text: str) -> tuple[str, int]:
+ """Replace every confidently-parsed lab panel with a markdown table."""
+ if not text or "\n|" in text or text.lstrip().startswith("|"):
+ return text or "", 0 # already tabulated — keep this idempotent
+ panels = find_panels(text)
+ if not panels:
+ return text, 0
+ out, cursor = [], 0
+ for start, end, items in panels:
+ # The lead-in keeps its colon but loses the bullet that was about to
+ # introduce the first result, and the prose after a table loses the
+ # separator that used to join it to the last result.
+ before = text[cursor:start].rstrip().rstrip("•·-–—").rstrip()
+ out.append(_leading_prose(before) if cursor else before)
+ out.append("\n\n" + render_table(items) + "\n\n")
+ cursor = end
+ out.append(_leading_prose(text[cursor:]))
+ return "".join(out).strip(), len(panels)
+
+
+def _leading_prose(text: str) -> str:
+ """Prose that followed a result: drop the punctuation that joined it on."""
+ return text.lstrip(" \t\n.;,•·").lstrip()
+
+
+# ---------------------------------------------------------------------------
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--apply", action="store_true", help="write the changes")
+ parser.add_argument("--explanations", action="store_true",
+ help="repair units in explanations too")
+ parser.add_argument("--tables", action="store_true",
+ help="also convert lab panels to markdown tables")
+ parser.add_argument("--limit", type=int, default=0, help="only scan N questions")
+ parser.add_argument("--show", type=int, default=6, help="how many examples to print")
+ args = parser.parse_args()
+
+ db = SessionLocal()
+ try:
+ query = db.query(Question).order_by(Question.id)
+ if args.limit:
+ query = query.limit(args.limit)
+ questions = query.all()
+
+ stem_fixes, expl_fixes, tabled = [], [], []
+ for question in questions:
+ new_stem, stem_reasons = repair_units(question.question_text or "")
+ panels = 0
+ if args.tables:
+ new_stem, panels = tabulate_panels(new_stem)
+ new_expl, expl_reasons = (question.explanation or ""), []
+ if args.explanations:
+ new_expl, expl_reasons = repair_units(question.explanation or "")
+
+ stem_changed = new_stem != (question.question_text or "")
+ expl_changed = args.explanations and new_expl != (question.explanation or "")
+ if not stem_changed and not expl_changed:
+ continue
+
+ if stem_reasons:
+ stem_fixes.append((question.id, stem_reasons))
+ if expl_reasons:
+ expl_fixes.append((question.id, expl_reasons))
+ if panels:
+ tabled.append((question.id, panels))
+
+ if args.apply:
+ # Snapshot the question as it stands, through the same helper
+ # the edit endpoint uses, so the version history and the
+ # restore button work on these edits exactly as on a human one.
+ _snapshot_question(db, question, SCRIPT_EDITOR_ID)
+ if stem_changed:
+ question.question_text = new_stem
+ if expl_changed:
+ question.explanation = new_expl
+
+ if args.apply:
+ db.commit()
+
+ print("APPLIED" if args.apply else "DRY RUN")
+ print(f" questions scanned : {len(questions)}")
+ print(f" stems with unit repairs : {len(stem_fixes)}")
+ if args.explanations:
+ print(f" explanations repaired : {len(expl_fixes)}")
+ else:
+ print(" explanations : skipped (pass --explanations)")
+ if args.tables:
+ print(f" stems tabulated : {len(tabled)} "
+ f"({sum(n for _i, n in tabled)} panels)")
+ else:
+ print(" lab tables : skipped (pass --tables)")
+
+ reasons: dict[str, int] = {}
+ for _qid, fired in stem_fixes + expl_fixes:
+ for entry in fired:
+ reason, _, count = entry.rpartition(" ×")
+ reasons[reason] = reasons.get(reason, 0) + int(count)
+ print("\n repairs by cause:")
+ for reason, count in sorted(reasons.items(), key=lambda kv: -kv[1]):
+ print(f" {count:6d} {reason}")
+
+ for qid, fired in stem_fixes[: args.show]:
+ print(f" question {qid}: {', '.join(fired)}")
+ for qid, panels in tabled[: args.show]:
+ print(f" question {qid}: {panels} lab table(s)")
+
+ if not args.apply:
+ print("\n Re-run with --apply to write these changes.")
+ return 0
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/scripts/generate_articles.py b/backend/scripts/generate_articles.py
new file mode 100644
index 0000000..d8839f4
--- /dev/null
+++ b/backend/scripts/generate_articles.py
@@ -0,0 +1,100 @@
+"""Write a topic article for every condition that has questions.
+
+Long-running and resumable: an article that already exists is skipped, so the
+run can be stopped and restarted without duplicating work or spending a model
+call twice on the same topic.
+
+Everything it writes is a draft. Generated medical writing that nobody has read
+must not reach a learner, so publishing stays a decision a person makes on the
+editorial queue.
+
+ docker compose exec backend python -m scripts.generate_articles
+ docker compose exec backend python -m scripts.generate_articles --apply
+ docker compose exec backend python -m scripts.generate_articles --apply --limit 25
+"""
+import argparse
+import sys
+import time
+
+from sqlalchemy import text as sa_text
+
+from app.database import SessionLocal
+from app.models.article import Article
+from app.services import clinical_library
+from app.services.article_writer import slugify, write_article
+
+# A pause between topics, so a run of hundreds does not monopolise the model
+# proxy that the rest of the platform shares.
+PAUSE_SECONDS = 1.5
+
+
+def candidates(db, limit: int | None):
+ """Leaf categories that actually hold questions, biggest first.
+
+ Leaves because a condition is what an article is about; a discipline is a
+ shelf. Biggest first so that stopping the run early still leaves the topics
+ carrying the most questions covered.
+ """
+ rows = db.execute(sa_text("""
+ SELECT c.id, c.name, COUNT(q.id) AS uses
+ FROM question_categories c
+ JOIN questions q ON q.question_category_id = c.id
+ WHERE NOT EXISTS (SELECT 1 FROM question_categories k WHERE k.parent_id = c.id)
+ GROUP BY c.id, c.name
+ ORDER BY uses DESC, c.name
+ """)).fetchall()
+ return rows[:limit] if limit else rows
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--apply", action="store_true", help="actually write articles")
+ parser.add_argument("--limit", type=int, default=None, help="stop after this many topics")
+ parser.add_argument("--user-id", type=int, default=None, help="author to record")
+ args = parser.parse_args()
+
+ if args.apply and not clinical_library.available():
+ print(" The clinical library index is unreachable; refusing to write "
+ "articles with nothing to ground them in.")
+ return 1
+
+ db = SessionLocal()
+ try:
+ topics = candidates(db, args.limit)
+ existing = {row[0] for row in db.query(Article.slug).all()}
+ todo = [(cid, name, uses) for cid, name, uses in topics if slugify(name) not in existing]
+
+ print(f" conditions with questions : {len(topics)}")
+ print(f" already written : {len(topics) - len(todo)}")
+ print(f" to write : {len(todo)}")
+ if not args.apply:
+ print("\n First 15:")
+ for _cid, name, uses in todo[:15]:
+ print(f" {uses:4d} {name}")
+ print("\n Re-run with --apply to write them. Everything lands as a draft.")
+ return 0
+
+ written = skipped = failed = 0
+ for index, (category_id, name, uses) in enumerate(todo, start=1):
+ try:
+ result = write_article(db, name, category_id=category_id, user_id=args.user_id)
+ except Exception as error: # one bad topic must not end a run of hundreds
+ db.rollback()
+ result = {"status": "failed", "reason": str(error)[:120]}
+ status = result.get("status")
+ written += status == "written"
+ skipped += status in ("skipped", "exists")
+ failed += status == "failed"
+ note = result.get("reason", "")
+ print(f" [{index}/{len(todo)}] {name[:52]:<52} {status:<8} {note[:40]}", flush=True)
+ time.sleep(PAUSE_SECONDS)
+
+ print(f"\n written : {written}\n skipped : {skipped}\n failed : {failed}")
+ print(" All drafts. Review them in the editorial queue before publishing.")
+ finally:
+ db.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docker-compose.yml b/docker-compose.yml
index b7a462f..0099b88 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,7 @@ services:
networks:
- default
- danvics_speech
+ - danvics_milvus
depends_on:
postgres:
condition: service_healthy
@@ -57,6 +58,9 @@ services:
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
+ networks:
+ - default
+ - danvics_milvus
depends_on:
postgres:
condition: service_healthy
@@ -172,3 +176,8 @@ volumes:
networks:
danvics_speech:
external: true
+ # The clinical library index. Note there are two Milvus servers on this host:
+ # this is the one holding mcp_bge_m3_1024, reached as `milvus`. The other, on
+ # ped-ai-storage_basic, is a different instance with different credentials.
+ danvics_milvus:
+ external: true
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 803b56b..63e94c8 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -34,6 +34,7 @@ const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
+const EditorialPage = lazy(() => import('./pages/EditorialPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
@@ -130,6 +131,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css
index 574f375..32c9c9d 100644
--- a/frontend/src/components/ArticleLink.css
+++ b/frontend/src/components/ArticleLink.css
@@ -25,8 +25,9 @@
font-size: 0.84rem;
line-height: 1.5;
text-align: left;
- /* The card is a hint, not a target: it must never sit between the pointer and
- the link it describes. */
+ /* The card is a hint before it is a target: its body stays transparent to the
+ pointer so it never sits between the reader and the link it describes, and
+ only the controls below take clicks. */
pointer-events: none;
}
.al-card.is-above { top: auto; bottom: calc(100% + 8px); }
@@ -34,6 +35,26 @@
.al-card-excerpt { color: var(--text-muted); }
.al-card-meta { font-size: 0.75rem; color: var(--text-subtle); }
+.al-card-actions { display: flex; gap: 6px; margin-top: 2px; pointer-events: auto; }
+.al-card-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 9px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font: inherit;
+ font-size: 0.72rem;
+ line-height: 1.4;
+ color: var(--text-muted);
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.al-card-action:hover { border-color: var(--primary); color: var(--primary); }
+.al-card-action:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
+
/* No hover on touch, so the card never appears there and the link is just a link. */
@media (hover: none) {
.al-card { display: none; }
diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx
index d8ee7fd..e40df7f 100644
--- a/frontend/src/components/ArticleLink.jsx
+++ b/frontend/src/components/ArticleLink.jsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
+import { useSplitView } from '../context/SplitViewContext'
import './ArticleLink.css'
// One fetch per article for the life of the page. A reader hovers the same
@@ -9,6 +10,9 @@ import './ArticleLink.css'
const cache = new Map()
const HOVER_DELAY = 350 // Long enough that crossing a link does not summon a card.
+// Long enough to cross the gap between the link and the card's controls, and to
+// cover the blur-then-focus gap when Tab moves between them.
+const HIDE_DELAY = 260
export function fetchPreview(slug) {
if (!cache.has(slug)) {
@@ -24,7 +28,8 @@ export function fetchPreview(slug) {
*
* Following a link to find out whether it was worth following is the thing that
* breaks a train of thought, so the preview answers that in place: title, a
- * couple of sentences, and how much is there.
+ * couple of sentences, how much is there, and — where the page can hold one —
+ * the offer to open it beside what you are reading rather than in place of it.
*
* Touch has no hover, so on a phone this is just a link — a card that appears on
* tap would sit between the finger and the thing it was about to open.
@@ -38,31 +43,61 @@ export default function ArticleLink({ slug, children, className = '' }) {
const [above, setAbove] = useState(false)
const timer = useRef(null)
const anchor = useRef(null)
+ const split = useSplitView()
+ const href = `/articles/s/${slug}`
useEffect(() => () => clearTimeout(timer.current), [])
- const show = useCallback(() => {
- clearTimeout(timer.current)
- timer.current = setTimeout(async () => {
- const data = await fetchPreview(slug)
- if (!data) return
- // Flip the card above the link when there is no room beneath it.
- const box = anchor.current?.getBoundingClientRect?.()
- if (box) setAbove(window.innerHeight - box.bottom < 220)
- setPreview(data)
- setOpen(true)
- }, HOVER_DELAY)
+ const reveal = useCallback(async () => {
+ const data = await fetchPreview(slug)
+ if (!data) return
+ // Flip the card above the link when there is no room beneath it.
+ const box = anchor.current?.getBoundingClientRect?.()
+ if (box) setAbove(window.innerHeight - box.bottom < 220)
+ setPreview(data)
+ setOpen(true)
}, [slug])
- const hide = useCallback(() => {
+ const showSoon = useCallback(() => {
+ clearTimeout(timer.current)
+ timer.current = setTimeout(reveal, HOVER_DELAY)
+ }, [reveal])
+
+ // Focus is a deliberate act, not a pointer passing through, and a keyboard
+ // reader who tabs on before the delay elapses would never meet the controls.
+ const showNow = useCallback(() => {
+ clearTimeout(timer.current)
+ reveal()
+ }, [reveal])
+
+ // Leaving is given a grace period so the pointer can travel the gap from the
+ // link down onto the card's controls without the card vanishing under it.
+ const hideSoon = useCallback(() => {
+ clearTimeout(timer.current)
+ timer.current = setTimeout(() => setOpen(false), HIDE_DELAY)
+ }, [])
+
+ const hideNow = useCallback(() => {
clearTimeout(timer.current)
setOpen(false)
}, [])
+ const followLink = (event) => {
+ hideNow()
+ // Inside the second pane a cross-reference stays in that pane: the article
+ // you started from is on the left and should still be there afterwards.
+ // Modified and middle clicks are left alone so they still open a tab.
+ if (!split?.inPane || event.button !== 0) return
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+ event.preventDefault()
+ split.open(slug)
+ }
+
return (
-
-
+ { if (event.key === 'Escape') hideNow() }}>
+
{children}
{open && preview && (
@@ -73,6 +108,21 @@ export default function ArticleLink({ slug, children, className = '' }) {
{preview.section_count} section{preview.section_count === 1 ? '' : 's'}
{preview.status !== 'published' && ' · draft'}
+
+ {split && (
+
+ )}
+ {/* A real link on the real URL, so middle-click, ctrl-click and
+ "copy link address" work without this component's help. */}
+
+ ↗ New tab
+
+
)}
diff --git a/frontend/src/components/ArticleLink.test.jsx b/frontend/src/components/ArticleLink.test.jsx
index e38bbdb..11212e7 100644
--- a/frontend/src/components/ArticleLink.test.jsx
+++ b/frontend/src/components/ArticleLink.test.jsx
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ArticleLink from './ArticleLink'
+import { SplitViewProvider } from '../context/SplitViewContext'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
@@ -16,6 +17,20 @@ const preview = {
const mount = (slug = 'febrile-seizures') => render(
febrile seizures)
+// The link under a page that can hold a second pane, with somewhere to navigate
+// to so an ordinary click can be told apart from one the component handled.
+const mountBeside = (view, slug) => render(
+
+
+
+ febrile seizures} />
+ Whole article
} />
+
+
+ )
+
+const linkNamed = () => screen.getByRole('link', { name: 'febrile seizures' })
+
describe('cross-reference previews', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -33,14 +48,14 @@ describe('cross-reference previews', () => {
mount()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
- await userEvent.hover(screen.getByRole('link'))
+ await userEvent.hover(linkNamed())
const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
expect(card).toHaveTextContent('Febrile seizures')
expect(card).toHaveTextContent('A seizure with fever')
expect(card).toHaveTextContent('4 sections')
- await userEvent.unhover(screen.getByRole('link'))
- expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ await userEvent.unhover(linkNamed())
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument())
})
it('fetches a given article once however often it is hovered', async () => {
@@ -73,3 +88,82 @@ describe('cross-reference previews', () => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
+
+describe('reading a cross-reference without leaving the article', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useRealTimers()
+ api.get.mockResolvedValue({ data: preview })
+ })
+
+ it('offers the card two ways out: beside this article, or in a tab', async () => {
+ const open = vi.fn()
+ mountBeside({ open, inPane: false }, 'card-controls')
+ await userEvent.hover(linkNamed())
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+
+ expect(within(card).getByRole('button', { name: /split view/i })).toBeInTheDocument()
+ const newTab = within(card).getByRole('link', { name: /new tab/i })
+ expect(newTab).toHaveAttribute('href', '/articles/s/card-controls')
+ expect(newTab).toHaveAttribute('target', '_blank')
+ expect(newTab).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('offers no split where the page has nowhere to put one', async () => {
+ mount('no-pane-here')
+ await userEvent.hover(screen.getByRole('link'))
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ expect(within(card).queryByRole('button', { name: /split view/i })).not.toBeInTheDocument()
+ expect(within(card).getByRole('link', { name: /new tab/i })).toBeInTheDocument()
+ })
+
+ it('stays open while the pointer travels from the link to its controls', async () => {
+ const open = vi.fn()
+ mountBeside({ open, inPane: false }, 'reachable-card')
+ await userEvent.hover(linkNamed())
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ const split = within(card).getByRole('button', { name: /split view/i })
+
+ // Leaving the link is not leaving the card: the gap between them is the
+ // route to the controls, not a reason to take them away.
+ await userEvent.unhover(linkNamed())
+ await userEvent.hover(split)
+ await new Promise(resolve => setTimeout(resolve, 400))
+ expect(screen.getByRole('tooltip')).toBeInTheDocument()
+
+ await userEvent.click(split)
+ expect(open).toHaveBeenCalledWith('reachable-card')
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+
+ it('lets a keyboard reader reach both controls', async () => {
+ const open = vi.fn()
+ mountBeside({ open, inPane: false }, 'keyboard-reachable')
+ await userEvent.tab()
+ expect(linkNamed()).toHaveFocus()
+
+ // Focus is deliberate, so the card does not make a keyboard reader wait out
+ // the hover delay before its controls exist to be tabbed onto.
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ await userEvent.tab()
+ expect(within(card).getByRole('button', { name: /split view/i })).toHaveFocus()
+ await userEvent.tab()
+ expect(within(card).getByRole('link', { name: /new tab/i })).toHaveFocus()
+ })
+
+ it('leaves an ordinary click alone: the link is still a link', async () => {
+ const open = vi.fn()
+ mountBeside({ open, inPane: false }, 'plain-click')
+ await userEvent.click(linkNamed())
+ expect(await screen.findByText('Whole article')).toBeInTheDocument()
+ expect(open).not.toHaveBeenCalled()
+ })
+
+ it('keeps a link followed inside the pane inside the pane', async () => {
+ const open = vi.fn()
+ mountBeside({ open, inPane: true }, 'deeper-topic')
+ await userEvent.click(linkNamed())
+ expect(open).toHaveBeenCalledWith('deeper-topic')
+ expect(screen.queryByText('Whole article')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx
new file mode 100644
index 0000000..ef2301b
--- /dev/null
+++ b/frontend/src/components/ArticleReader.jsx
@@ -0,0 +1,242 @@
+import { useEffect, useRef, useState } from 'react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import ArticleLink from './ArticleLink'
+import { markdownImageUrl } from '../utils/uploads'
+
+// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
+// educator writes a cross-reference without having to know an article's numeric
+// id, which changes nothing for them and everything for a link that has to last.
+const WIKI_LINK = /\[\[([^\]|]+?)(?:\|([a-z0-9-]+))?\]\]/g
+const expandWikiLinks = (text) => (text || '').replace(WIKI_LINK, (_m, label, slug) =>
+ `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`)
+
+const internalSlug = (href) => {
+ const match = /^\/articles\/(?:s\/)?([a-z0-9-]+)\/?$/.exec(href || '')
+ return match ? match[1] : null
+}
+
+export function Markdown({ children, attemptId }) {
+ // Educator content renders as Markdown only; raw HTML is escaped, not executed.
+ return (
+ ,
+ a: ({ node, href, children: kids, ...props }) => {
+ const slug = internalSlug(href)
+ // A link into the library stays in the app and shows what it leads to;
+ // only links off the site get a new tab.
+ if (slug) return {kids}
+ return {kids}
+ },
+ }}>
+ {expandWikiLinks(children)}
+
+ )
+}
+
+/**
+ * An article as it reads: contents rail, summary, and sections that expand
+ * where they sit.
+ *
+ * One renderer serves both the page and the second pane beside it, so a
+ * cross-reference opened in split view behaves like the article it came from
+ * rather than like a cut-down copy of it.
+ *
+ * `activeSection` is the caller's, because the page keeps it in the URL for deep
+ * links and the pane keeps it to itself; everything else — what is open, the
+ * rail, the mobile drawer — belongs to this reader alone.
+ */
+// One topic, three readings. The full article is what you study from; the key
+// points are what you revise from; the clinical view is what you act from at the
+// bedside. 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.
+const VIEWS = [
+ { key: 'long', label: 'Article' },
+ { key: 'key_points', label: 'Key points' },
+ { key: 'clinical', label: 'Clinical' },
+]
+
+export default function ArticleReader({ article, activeSection = '', onOpenSection, idPrefix = '', landmark = true, children }) {
+ // Which sections are open. Everything starts closed: an article is a reference
+ // you consult, and a wall of prose hides the one heading you came for.
+ const [openIds, setOpenIds] = useState({})
+ const [drawerOpen, setDrawerOpen] = useState(false)
+ // Collapsing the contents rail hands its width to the prose.
+ const [railOpen, setRailOpen] = useState(true)
+ const root = useRef(null)
+ // A page has one main landmark, so the second reader on it is not one.
+ const Content = landmark ? 'main' : 'div'
+
+ // Older articles have no variant on their sections; they are the full article.
+ const everySection = (article.sections || []).map(
+ sec => ({ ...sec, variant: sec.variant || 'long' }))
+ const present = VIEWS.filter(v => everySection.some(sec => sec.variant === v.key))
+ const [view, setView] = useState(present[0]?.key || 'long')
+ const allSections = everySection.filter(sec => sec.variant === view)
+ // Element ids are prefixed because two readers can share a page, and a
+ // duplicate id would point the pane's contents at the article behind it.
+ const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`)
+
+ // Deep link: open the section (and the one it sits under) and scroll to it.
+ // Landing on a collapsed heading would look like the link had gone nowhere.
+ useEffect(() => {
+ if (!activeSection) return
+ const parentId = allSections.find(sec => sec.id === activeSection)?.parent_id
+ setOpenIds(prev => ({ ...prev, [activeSection]: true, ...(parentId ? { [parentId]: true } : {}) }))
+ sectionElement(activeSection)?.scrollIntoView?.({ block: 'start' })
+ }, [activeSection, article])
+
+ /** Jump to a section in the page and hand it back for the URL. */
+ const openSection = (secId) => {
+ setDrawerOpen(false)
+ if (secId) {
+ const parentId = allSections.find(sec => sec.id === secId)?.parent_id
+ setOpenIds(prev => ({ ...prev, [secId]: true, ...(parentId ? { [parentId]: true } : {}) }))
+ sectionElement(secId)?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
+ }
+ onOpenSection?.(secId)
+ }
+
+ /** Open or close one section without moving the page. */
+ const toggleSection = (secId) => setOpenIds(prev => ({ ...prev, [secId]: !prev[secId] }))
+
+ const childSections = allSections.filter(sec => sec.parent_id)
+ const kidsOf = (secId) => childSections.filter(sec => sec.parent_id === secId)
+ // References belong at the end whatever order they were written in — a reader
+ // scrolling for content should not hit the bibliography halfway down.
+ const isReferences = (sec) => /^references$/i.test(sec.title || '') || sec.slug === 'references'
+ const topSections = [
+ ...allSections.filter(sec => !sec.parent_id && !isReferences(sec)),
+ ...allSections.filter(sec => !sec.parent_id && isReferences(sec)),
+ ]
+ const setAll = (open) => setOpenIds(open
+ ? Object.fromEntries(allSections.map(sec => [sec.id, true]))
+ : {})
+ const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
+
+ const renderSection = (sec, depth) => {
+ const isOpen = !!openIds[sec.id]
+ const kids = kidsOf(sec.id)
+ const Heading = depth === 0 ? 'h2' : 'h3'
+ return (
+
+
+
+
+ {isOpen && (
+
+ )}
+
+ {/* Headings first, prose on request: the article opens as a contents
+ page you can scan, and each section expands where it sits rather
+ than in a modal that loses the thread. */}
+
+ {/* Only the views this article actually has: an empty tab is a
+ promise the article cannot keep. */}
+ {present.length > 1 && (
+
+ {/* Sources for the whole article, not markers in the prose: a
+ learner checking a claim wants the book and the page, and a
+ sentence peppered with superscripts is harder to read. */}
+
+ {article.references.map((ref, index) => (
+
+ )
+}
diff --git a/frontend/src/components/ArticleSplitPane.css b/frontend/src/components/ArticleSplitPane.css
new file mode 100644
index 0000000..78500d3
--- /dev/null
+++ b/frontend/src/components/ArticleSplitPane.css
@@ -0,0 +1,89 @@
+/* Split view: a cross-reference read beside the article that pointed at it. */
+
+.article-split { min-width: 0; }
+.article-split.is-open {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+/* Two columns of prose need more room than one. At the reading page's usual
+ 1080px each pane would be narrower than a table. */
+.article-page.is-split { max-width: min(1560px, 100%); }
+
+/* Each pane scrolls alone, which is the whole point: following a
+ cross-reference must not move the article you were reading. */
+.article-split.is-open > .article-split-main,
+.article-split.is-open > .article-split-pane {
+ max-height: calc(100vh - 120px);
+ overflow-y: auto;
+}
+/* The rail is sticky inside its own pane now, not inside the window. */
+.article-page.is-split .article-sections { top: 6px; max-height: calc(100vh - 200px); }
+.article-page.is-split .article-layout { grid-template-columns: 190px 1fr; gap: 14px; }
+.article-page.is-split .article-content { padding: 16px 18px; }
+
+.article-split-pane {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--card-radius);
+}
+.article-split-pane:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
+
+.asplit-head {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--border);
+ border-radius: var(--card-radius) var(--card-radius) 0 0;
+}
+.asplit-title {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text);
+ text-decoration: none;
+ overflow-wrap: anywhere;
+}
+.asplit-title:hover { color: var(--primary); }
+.asplit-close {
+ flex-shrink: 0;
+ width: 30px;
+ height: 30px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font: inherit;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+.asplit-close:hover { border-color: var(--primary); color: var(--primary); }
+.asplit-body { padding: 4px 12px 16px; }
+/* The pane is already a card, so the reader inside it does not draw a second one. */
+.asplit-body .article-content { background: none; border: 0; padding: 8px 0 0; }
+
+@media (max-width: 820px) {
+ /* Two panes on a phone are two unreadable columns, and shrinking one to a
+ strip only invites pinch-zoom. The cross-reference takes the screen
+ instead, as a sheet over the article — which is still exactly where it was,
+ at the same scroll position, when the sheet closes. */
+ .article-split.is-open { display: block; }
+ .article-split.is-open > .article-split-main { max-height: none; overflow: visible; }
+ .article-page.is-split { max-width: 1080px; }
+ .article-page.is-split .article-layout { grid-template-columns: 1fr; }
+ .article-split.is-open > .article-split-pane {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ max-height: none;
+ border: 0;
+ border-radius: 0;
+ }
+ .asplit-head { border-radius: 0; }
+}
diff --git a/frontend/src/components/ArticleSplitPane.jsx b/frontend/src/components/ArticleSplitPane.jsx
new file mode 100644
index 0000000..5c241ef
--- /dev/null
+++ b/frontend/src/components/ArticleSplitPane.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import ArticleReader from './ArticleReader'
+import { resolveArticleId } from './ArticleLink'
+import './ArticleSplitPane.css'
+
+/**
+ * A second article beside the one being read.
+ *
+ * The point of split view is that the article you came from stays put, so this
+ * pane carries only the reading — no comments, no practice, no editing — and
+ * the full article is one click away in the heading for anything more.
+ */
+export default function ArticleSplitPane({ slug, onClose }) {
+ const [article, setArticle] = useState(null)
+ const [error, setError] = useState('')
+ const [activeSection, setActiveSection] = useState('')
+ const pane = useRef(null)
+
+ useEffect(() => {
+ let live = true
+ setArticle(null)
+ setError('')
+ setActiveSection('')
+ // The slug's id has already been fetched by the preview card that offered
+ // the split, so opening the pane costs one request, not two.
+ resolveArticleId(slug)
+ .then(id => (id ? api.get(`/articles/${id}`) : Promise.reject(new Error('unresolved'))))
+ .then(res => { if (live) setArticle(res.data) })
+ .catch(() => { if (live) setError('That article could not be opened here.') })
+ return () => { live = false }
+ }, [slug])
+
+ // Focus follows the reader into the pane: on a narrow screen it covers the
+ // article, so leaving focus behind would strand a keyboard or screen-reader
+ // user on prose they can no longer see.
+ useEffect(() => { pane.current?.focus() }, [slug])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 9df3b43..33ab937 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -111,7 +111,8 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
- { to: '/media', label: 'Images' }] : []),
+ { to: '/media', label: 'Images' },
+ { to: '/editorial', label: 'Editorial' }] : []),
{ to: '/study-plans', label: 'Study plans' },
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' },
diff --git a/frontend/src/context/SplitViewContext.jsx b/frontend/src/context/SplitViewContext.jsx
new file mode 100644
index 0000000..73b4f2e
--- /dev/null
+++ b/frontend/src/context/SplitViewContext.jsx
@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react'
+
+/**
+ * Where a cross-reference can be opened beside the article you are reading.
+ *
+ * A cross-reference sits deep inside rendered Markdown, several components away
+ * from the page that owns the second pane, so the offer travels by context
+ * rather than through every renderer in between. No provider means no second
+ * pane exists — a library listing, a question review — and the link is then
+ * only a link.
+ */
+const SplitViewContext = createContext(null)
+
+export const SplitViewProvider = SplitViewContext.Provider
+
+/** `{ open(slug), inPane }`, or null where there is nowhere to put a pane. */
+export function useSplitView() {
+ return useContext(SplitViewContext)
+}
diff --git a/frontend/src/pages/ArticleSplitView.test.jsx b/frontend/src/pages/ArticleSplitView.test.jsx
new file mode 100644
index 0000000..fe20f86
--- /dev/null
+++ b/frontend/src/pages/ArticleSplitView.test.jsx
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { ArticlePage } from './ArticlesPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
+vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
+vi.mock('../components/RichEditor', () => ({ default: ({ value }) =>
{value}
}))
+vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => src }))
+
+const article = {
+ id: 1, slug: 'febrile-seizures', title: 'Febrile seizures', summary: '', status: 'published',
+ content: 'Fever alone rarely explains it — see [[meningitis]].', category_breadcrumbs: [],
+ sections: [{ id: 'a'.repeat(32), slug: 'workup', title: 'Initial workup', content: 'Workup body' }],
+ user_id: 3,
+}
+const meningitis = {
+ id: 7, slug: 'meningitis', title: 'Meningitis', status: 'published', content: 'Meningitis intro, and [[sepsis]].',
+ sections: [{ id: 'b'.repeat(32), slug: 'signs', title: 'Signs', content: 'Neck stiffness' }],
+}
+const sepsis = { id: 9, slug: 'sepsis', title: 'Sepsis', status: 'published', content: 'Sepsis intro', sections: [] }
+
+const previews = {
+ meningitis: { id: 7, slug: 'meningitis', title: 'Meningitis', excerpt: 'Inflammation of the meninges…', section_count: 1, status: 'published' },
+ sepsis: { id: 9, slug: 'sepsis', title: 'Sepsis', excerpt: 'Life-threatening organ dysfunction…', section_count: 0, status: 'published' },
+}
+
+const mount = () => render(
+
+ } />
+ )
+
+describe('reading a cross-reference beside the article', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ api.get.mockImplementation(url => {
+ if (url.startsWith('/articles/preview/')) return Promise.resolve({ data: previews[url.split('/').pop()] })
+ if (url === '/articles/1') return Promise.resolve({ data: article })
+ if (url === '/articles/7') return Promise.resolve({ data: meningitis })
+ if (url === '/articles/9') return Promise.resolve({ data: sepsis })
+ return Promise.resolve({ data: [] })
+ })
+ })
+
+ const openSplit = async (name) => {
+ await userEvent.hover(screen.getByRole('link', { name }))
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
+ }
+
+ it('opens the linked article beside the one that pointed at it', async () => {
+ mount()
+ expect(await screen.findByText(/Fever alone rarely explains it/)).toBeInTheDocument()
+ expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument()
+
+ await openSplit('meningitis')
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/7'))
+ const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
+ expect(within(pane).getByText(/Meningitis intro/)).toBeInTheDocument()
+
+ // The article that sent the reader there is still on the page, unmoved.
+ expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
+ expect(document.querySelector('.article-split')).toHaveClass('is-open')
+ expect(document.querySelector('.article-page')).toHaveClass('is-split')
+ })
+
+ it('brings the whole reader with it: contents, and sections that expand', async () => {
+ mount()
+ await screen.findByText(/Fever alone rarely explains it/)
+ await openSplit('meningitis')
+ const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
+
+ expect(within(pane).queryByText('Neck stiffness')).not.toBeInTheDocument()
+ // Contents rail and collapsible heading both, the same as the page behind it.
+ expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument()
+ await userEvent.click(within(pane.querySelector('.asec-list')).getByRole('button', { name: /Signs/ }))
+ expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
+ })
+
+ it('closing it gives the page back its single column', async () => {
+ mount()
+ await screen.findByText(/Fever alone rarely explains it/)
+ await openSplit('meningitis')
+ const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
+
+ await userEvent.click(within(pane).getByRole('button', { name: 'Close split view' }))
+ expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument()
+ expect(document.querySelector('.article-split')).not.toHaveClass('is-open')
+ expect(document.querySelector('.article-page')).not.toHaveClass('is-split')
+ expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
+ })
+
+ it('follows a link inside the pane in the pane, not into a third column', async () => {
+ mount()
+ await screen.findByText(/Fever alone rarely explains it/)
+ await openSplit('meningitis')
+ const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
+
+ await userEvent.click(within(pane).getByRole('link', { name: 'sepsis' }))
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/9'))
+ expect(await screen.findByRole('region', { name: 'Split view: Sepsis' })).toBeInTheDocument()
+ expect(document.querySelectorAll('.article-split-pane')).toHaveLength(1)
+ // The article on the left is where it was; only the pane changed.
+ expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
+ })
+
+ it('closes on Escape, the way anything laid over the page should', async () => {
+ mount()
+ await screen.findByText(/Fever alone rarely explains it/)
+ await openSplit('meningitis')
+ await screen.findByRole('region', { name: 'Split view: Meningitis' })
+
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument())
+ })
+})
diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css
index 398ab09..32922fe 100644
--- a/frontend/src/pages/ArticlesPage.css
+++ b/frontend/src/pages/ArticlesPage.css
@@ -199,3 +199,26 @@
.asec-head { font-size: 0.95rem; }
.asec-body { font-size: 0.92rem; }
}
+
+/* One topic, three readings. */
+.asec-controls { display: flex; align-items: center; gap: 10px; justify-content: space-between; flex-wrap: wrap; }
+.aview-switch { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
+.aview {
+ min-height: 34px; padding: 6px 13px; border: 0; border-radius: 7px; cursor: pointer;
+ background: none; font: inherit; font-size: 0.83rem; font-weight: 600; color: var(--text-muted);
+}
+.aview:hover { color: var(--text); }
+.aview.is-active { background: var(--card-bg); color: var(--primary); box-shadow: 0 1px 2px rgba(15,23,42,0.08); }
+
+/* Sources for the whole article, not markers scattered through the prose. */
+.article-references { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--border); }
+.article-references h2 { margin: 0 0 10px; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
+.article-references ol { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; }
+.article-references li { font-size: 0.83rem; line-height: 1.5; color: var(--text-muted); }
+.article-ref-title { color: var(--text); font-weight: 600; }
+.article-ref-pages { font-variant-numeric: tabular-nums; }
+
+@media (max-width: 640px) {
+ .asec-controls { gap: 8px; }
+ .aview { padding: 6px 10px; font-size: 0.79rem; }
+}
diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx
index f427fbd..0bf8749 100644
--- a/frontend/src/pages/ArticlesPage.jsx
+++ b/frontend/src/pages/ArticlesPage.jsx
@@ -1,49 +1,19 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
+import { SplitViewProvider } from '../context/SplitViewContext'
import RichEditor from '../components/RichEditor'
import CategoryColumns from '../components/CategoryColumns'
-import ArticleLink, { resolveArticleId } from '../components/ArticleLink'
+import { resolveArticleId } from '../components/ArticleLink'
+import ArticleReader from '../components/ArticleReader'
+import ArticleSplitPane from '../components/ArticleSplitPane'
import CommentSection from '../components/CommentSection'
import PractiseTopic from '../components/PractiseTopic'
-import { markdownImageUrl } from '../utils/uploads'
import './ArticlesPage.css'
const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
-// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
-// educator writes a cross-reference without having to know an article's numeric
-// id, which changes nothing for them and everything for a link that has to last.
-const WIKI_LINK = /\[\[([^\]|]+?)(?:\|([a-z0-9-]+))?\]\]/g
-const expandWikiLinks = (text) => (text || '').replace(WIKI_LINK, (_m, label, slug) =>
- `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`)
-
-const internalSlug = (href) => {
- const match = /^\/articles\/(?:s\/)?([a-z0-9-]+)\/?$/.exec(href || '')
- return match ? match[1] : null
-}
-
-function Markdown({ children, attemptId }) {
- // Educator content renders as Markdown only; raw HTML is escaped, not executed.
- return (
- ,
- a: ({ node, href, children: kids, ...props }) => {
- const slug = internalSlug(href)
- // A link into the library stays in the app and shows what it leads to;
- // only links off the site get a new tab.
- if (slug) return {kids}
- return {kids}
- },
- }}>
- {expandWikiLinks(children)}
-
- )
-}
-
function DraftBadge({ status }) {
if (status === 'published') return null
return Draft
@@ -204,12 +174,8 @@ export function ArticlePage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeSection, setActiveSection] = useState('')
- // Which sections are open. Everything starts closed: an article is a reference
- // you consult, and a wall of prose hides the one heading you came for.
- const [openIds, setOpenIds] = useState({})
- const [drawerOpen, setDrawerOpen] = useState(false)
- // Collapsing the contents rail hands its width to the prose.
- const [railOpen, setRailOpen] = useState(true)
+ // The cross-reference being read beside this article, by slug.
+ const [splitSlug, setSplitSlug] = useState(null)
const [questions, setQuestions] = useState([])
const [cards, setCards] = useState([])
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
@@ -253,31 +219,16 @@ export function ArticlePage() {
if (id === null && slug) { setError('Article not found'); setLoading(false) }
}, [id, slug])
- // Deep link: open the section (and the one it sits under) and scroll to it.
- // Landing on a collapsed heading would look like the link had gone nowhere.
- useEffect(() => {
- if (!activeSection || !article) return
- const parentId = (article.sections || []).find(s => s.id === activeSection)?.parent_id
- setOpenIds(prev => ({ ...prev, [activeSection]: true, ...(parentId ? { [parentId]: true } : {}) }))
- const target = document.getElementById(`section-${activeSection}`)
- target?.scrollIntoView?.({ block: 'start' })
- }, [activeSection, article])
-
- /** Jump to a section in the page and record it in the URL for deep links. */
+ /** Record the section being read in the URL, so a reader can link to it. */
const openSection = (secId) => {
setActiveSection(secId)
setSearchParams(secId ? { section: secId } : {})
- setDrawerOpen(false)
- if (secId) {
- const parentId = (article?.sections || []).find(s => s.id === secId)?.parent_id
- setOpenIds(prev => ({ ...prev, [secId]: true, ...(parentId ? { [parentId]: true } : {}) }))
- const target = document.getElementById(`section-${secId}`)
- target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
- }
}
- /** Open or close one section without moving the page. */
- const toggleSection = (secId) => setOpenIds(prev => ({ ...prev, [secId]: !prev[secId] }))
+ // Both readers open a cross-reference into the same slot, so one followed
+ // from inside the pane replaces what is there instead of splitting again.
+ const splitView = useMemo(() => ({ open: setSplitSlug, inPane: false }), [])
+ const paneView = useMemo(() => ({ open: setSplitSlug, inPane: true }), [])
const save = async (publish = null) => {
setSaving(true)
@@ -338,47 +289,8 @@ export function ArticlePage() {
const canEdit = user?.is_moderator || article.user_id === user?.id
- const allSections = article.sections || []
- const childSections = allSections.filter(sec => sec.parent_id)
- const kidsOf = (secId) => childSections.filter(sec => sec.parent_id === secId)
- // References belong at the end whatever order they were written in — a reader
- // scrolling for content should not hit the bibliography halfway down.
- const isReferences = (sec) => /^references$/i.test(sec.title || '') || sec.slug === 'references'
- const topSections = [
- ...allSections.filter(sec => !sec.parent_id && !isReferences(sec)),
- ...allSections.filter(sec => !sec.parent_id && isReferences(sec)),
- ]
- const setAll = (open) => setOpenIds(open
- ? Object.fromEntries(allSections.map(sec => [sec.id, true]))
- : {})
- const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
-
- const renderSection = (sec, depth) => {
- const isOpen = !!openIds[sec.id]
- const kids = kidsOf(sec.id)
- const Heading = depth === 0 ? 'h2' : 'h3'
- return (
-
-
-
-
- {isOpen && (
-
- )}
-
- {/* Headings first, prose on request: the article opens as a contents
- page you can scan, and each section expands where it sits rather
- than in a modal that loses the thread. */}
- {allSections.length > 0 && (
-