diff --git a/backend/alembic/versions/c5d6e7f8091a_collectible_articles.py b/backend/alembic/versions/c5d6e7f8091a_collectible_articles.py
new file mode 100644
index 0000000..a9622f3
--- /dev/null
+++ b/backend/alembic/versions/c5d6e7f8091a_collectible_articles.py
@@ -0,0 +1,44 @@
+"""A library can hold articles as well as questions.
+
+Collections were questions only, so the reading side of the site had no way to
+put anything aside — the bookmark on an article had nowhere to write. Its own
+table rather than a nullable column beside `question_id`, so a row cannot claim
+to be both kinds or neither.
+
+Revision ID: c5d6e7f8091a
+Revises: b4c5d6e7f809
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "c5d6e7f8091a"
+down_revision = "b4c5d6e7f809"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # `Base.metadata.create_all()` still runs at startup and creates missing
+ # tables, so on a box that has already booted this code the table is here
+ # before the migration is. Checked rather than assumed, or the upgrade
+ # fails on exactly the machines that are up to date.
+ if "user_collection_articles" in sa.inspect(op.get_bind()).get_table_names():
+ return
+ op.create_table(
+ "user_collection_articles",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column("collection_id", sa.Integer(),
+ sa.ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("article_id", sa.Integer(),
+ sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False),
+ sa.UniqueConstraint("collection_id", "article_id", name="uq_collection_article"),
+ )
+ op.create_index("ix_user_collection_articles_collection",
+ "user_collection_articles", ["collection_id"])
+
+
+def downgrade():
+ if "user_collection_articles" not in sa.inspect(op.get_bind()).get_table_names():
+ return
+ op.drop_index("ix_user_collection_articles_collection", table_name="user_collection_articles")
+ op.drop_table("user_collection_articles")
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
index 9400a3c..54423b5 100644
--- a/backend/app/models/collection.py
+++ b/backend/app/models/collection.py
@@ -23,3 +23,20 @@ class UserCollectionQuestion(Base):
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(Integer, ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
+
+
+class UserCollectionArticle(Base):
+ """An article put aside into a library.
+
+ Its own table rather than a nullable `article_id` beside `question_id` on
+ the row above: that shape allows a row with both, or with neither, and
+ every read then has to say which kind it is looking at. Two tables, one
+ unique constraint each, and a library is the union of them.
+ """
+
+ __tablename__ = "user_collection_articles"
+ __table_args__ = (UniqueConstraint("collection_id", "article_id", name="uq_collection_article"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ collection_id = Column(Integer, ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False)
+ article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index 41ca78b..0a9ffe1 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -212,13 +212,22 @@ def search_litellm_models(
log.warning(f"LiteLLM model search failed: {e}")
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
+ # No proxy configured, so all that is left is whatever the SDK can name by
+ # itself. It only lists providers whose own API keys are in the
+ # environment, and this deployment has none — everything goes through the
+ # proxy above. An empty list is therefore the normal answer here, and
+ # returning it plainly would read as "this site has no models".
try:
import litellm
models = sorted(litellm.utils.get_valid_models())
- return {"models": models, "source": "litellm-builtin"}
except Exception as e:
log.warning(f"LiteLLM builtin model list failed: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve LiteLLM built-in model list.")
+ if not models:
+ raise HTTPException(
+ status_code=400,
+ detail="No model endpoint is configured. Set the API base to your LLM proxy and try again.")
+ return {"models": models, "source": "litellm-builtin"}
@router.get("/models", response_model=list[AIModelConfigResponse])
diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py
index e62ba02..0979acc 100644
--- a/backend/app/routers/collections.py
+++ b/backend/app/routers/collections.py
@@ -1,4 +1,10 @@
-"""Personal question libraries (saved questions)."""
+"""Personal libraries: questions and articles somebody has put aside.
+
+A library holds both. It used to hold only questions, so the reading side of
+the site had a bookmark with nowhere to write — and the two kinds are the same
+act to whoever is doing it, which is why they share a library rather than each
+getting their own list.
+"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
@@ -6,9 +12,12 @@ from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
-from app.models.collection import UserCollection, UserCollectionQuestion
+from app.models.article import Article
+from app.models.collection import (UserCollection, UserCollectionArticle,
+ UserCollectionQuestion)
from app.models.question import Question
from app.models.user import User
+from app.services.article_service import readable_articles
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user
@@ -42,6 +51,8 @@ def _as_json(db, collection) -> dict:
"title": collection.title,
"question_count": db.query(UserCollectionQuestion).filter(
UserCollectionQuestion.collection_id == collection.id).count(),
+ "article_count": db.query(UserCollectionArticle).filter(
+ UserCollectionArticle.collection_id == collection.id).count(),
"created_at": collection.created_at,
"last_used_at": collection.last_used_at,
# Every library is one person's. Said plainly rather than assumed,
@@ -130,3 +141,60 @@ def remove_collection_question(collection_id: int, question_id: int, db: Session
synchronize_session=False)
_touch(db, collection)
db.commit()
+
+
+@router.get("/{collection_id}/articles")
+def collection_articles(collection_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ rows = (db.query(Article)
+ .join(UserCollectionArticle, UserCollectionArticle.article_id == Article.id)
+ .filter(UserCollectionArticle.collection_id == collection.id)
+ .order_by(Article.title)
+ .all())
+ _touch(db, collection)
+ db.commit()
+ # An article saved and later unpublished stays in the library and says so,
+ # rather than vanishing from a list the learner built themselves.
+ return [{"id": a.id, "title": a.title, "slug": a.slug, "status": a.status} for a in rows]
+
+
+@router.put("/{collection_id}/articles/{article_id}")
+def add_collection_article(collection_id: int, article_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ if not readable_articles(db, user).filter(Article.id == article_id).first():
+ raise HTTPException(404, "Article not found")
+ if db.query(UserCollectionArticle.id).filter_by(
+ collection_id=collection.id, article_id=article_id).first():
+ return {"added": False}
+ db.add(UserCollectionArticle(collection_id=collection.id, article_id=article_id))
+ _touch(db, collection)
+ db.commit()
+ return {"added": True}
+
+
+@router.delete("/{collection_id}/articles/{article_id}", status_code=204)
+def remove_collection_article(collection_id: int, article_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ db.query(UserCollectionArticle).filter_by(
+ collection_id=collection_id, article_id=article_id).delete(synchronize_session=False)
+ _touch(db, collection)
+ db.commit()
+
+
+@router.get("/for-article/{article_id}")
+def libraries_holding_article(article_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ """Which of this learner's libraries already hold this article.
+
+ The reader needs it to draw the bookmark filled or hollow. Asked as one
+ question rather than by fetching every library's contents, which would also
+ stamp each of them as used and reorder the learner's own list.
+ """
+ held = {row.collection_id for row in db.query(UserCollectionArticle.collection_id)
+ .join(UserCollection, UserCollection.id == UserCollectionArticle.collection_id)
+ .filter(UserCollection.user_id == user.id,
+ UserCollectionArticle.article_id == article_id).all()}
+ return {"collection_ids": sorted(held)}
diff --git a/backend/app/services/article_service.py b/backend/app/services/article_service.py
index 8936ce2..6b139c6 100644
--- a/backend/app/services/article_service.py
+++ b/backend/app/services/article_service.py
@@ -171,3 +171,18 @@ def set_status(db: Session, article: Article, status: str, user_id: int | None)
article.reviewed_at = now
article.reviewed_by = user_id
article.status = status
+
+
+def readable_articles(db, user):
+ """A query over the articles this person may read.
+
+ Published, plus their own drafts, plus everything if they moderate. The
+ rule was written out inline at every place that needed it, which is how a
+ draft ends up reachable from one route and not another.
+ """
+ from app.models.article import Article
+
+ query = db.query(Article)
+ if getattr(user, "is_moderator", False):
+ return query
+ return query.filter((Article.status == "published") | (Article.user_id == user.id))
diff --git a/backend/requirements.txt b/backend/requirements.txt
index a4ac010..3fa5366 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,7 +9,19 @@ python-multipart==0.0.9
pydantic[email]==2.6.1
pydantic-settings==2.1.0
PyMuPDF==1.23.22
-litellm==1.28.13
+# Thumbnails. It arrives transitively today, which is not a reason to let a
+# feature depend on somebody else's dependency tree.
+Pillow==12.3.0
+# 1.28.13 was withdrawn from PyPI, so this file could not be edited at all
+# without the whole pip layer failing to rebuild. 1.53.1 is the nearest version
+# still published; the three things we use — completion, acompletion and
+# utils.get_valid_models — are unchanged in it.
+#
+# We use the SDK only as an OpenAI-compatible HTTP client pointed at the proxy
+# in LITELLM_API_BASE: every call sets api_base and prefixes the model with
+# `openai/`. Embeddings already go over plain httpx. There is a smaller
+# dependency in here waiting to be taken.
+litellm==1.53.1
chromadb==0.4.24
celery[redis]==5.3.6
redis==5.0.1
diff --git a/frontend/src/components/ArticleEditor.jsx b/frontend/src/components/ArticleEditor.jsx
index f75262a..957c54b 100644
--- a/frontend/src/components/ArticleEditor.jsx
+++ b/frontend/src/components/ArticleEditor.jsx
@@ -3,7 +3,7 @@ import RichEditor from './RichEditor'
import './ArticleEditor.css'
const VIEWS = [
- { key: 'short', label: 'Short', hint: 'Bullets a learner could revise from the night before an exam.' },
+ { key: 'short', label: 'Summary', hint: 'Bullets a learner could revise from the night before an exam.' },
{ key: 'long', label: 'Long', hint: 'The full article: definition through management.' },
{ key: 'clinical', label: 'Clinical', hint: 'What to do at the bedside, with doses and routes.' },
]
diff --git a/frontend/src/components/ArticleEditor.test.jsx b/frontend/src/components/ArticleEditor.test.jsx
index 5ea12e1..69b809c 100644
--- a/frontend/src/components/ArticleEditor.test.jsx
+++ b/frontend/src/components/ArticleEditor.test.jsx
@@ -41,14 +41,14 @@ describe('editing an article', () => {
expect(screen.queryByDisplayValue('In short')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('Management')).not.toBeInTheDocument()
- await openView('Short')
+ await openView('Summary')
expect(screen.getByDisplayValue('In short')).toBeInTheDocument()
expect(screen.queryByDisplayValue('Definition')).not.toBeInTheDocument()
})
it('counts what each view holds, so an empty one is visible before you open it', async () => {
mount()
- expect(within(screen.getByRole('tab', { name: /^Short/ })).getByText('1')).toBeInTheDocument()
+ expect(within(screen.getByRole('tab', { name: /^Summary/ })).getByText('1')).toBeInTheDocument()
expect(within(screen.getByRole('tab', { name: /^Long/ })).getByText('2')).toBeInTheDocument()
})
diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx
index 0aef7d6..272d225 100644
--- a/frontend/src/components/ArticleReader.jsx
+++ b/frontend/src/components/ArticleReader.jsx
@@ -31,15 +31,17 @@ export function Markdown({ children, attemptId }) {
* 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. Short is what you revise from, long is what you
-// study from, clinical 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.
+// One topic, three readings. The summary is what you revise from, long is what
+// you study from, clinical 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.
//
-// Short leads because it is the quickest way to tell whether this is the article
-// you wanted; the full text is one click away.
+// `short` is the stored variant and stays that way — renaming it would be a
+// data migration to change a word on a button. What a reader sees is "Summary",
+// because that is what the section itself is called.
+const SUMMARY = 'short'
const VIEWS = [
- { key: 'short', label: 'Short' },
+ { key: SUMMARY, label: 'Summary' },
{ key: 'long', label: 'Long' },
{ key: 'clinical', label: 'Clinical' },
]
@@ -244,7 +246,7 @@ export default function ArticleReader({
{/* A view with one section does not need a heading over it, or a
control to collapse the only thing there is. The tab already
- names it — "Short" then a heading reading "In short" says the
+ names it — "Summary" then a heading reading "In short" says the
same word twice and hides the content behind a chevron. */}
{soleSection ? (
@@ -292,16 +294,39 @@ export default function ArticleReader({
* it — and the rail is filtered by the same choice, so a reader who cannot
* see the switch cannot explain why half the contents just went away.
*/
+ const choose = (key) => { setView(key); setOpenIds({}); setReading('') }
+ const hasSummary = present.some(option => option.key === SUMMARY)
+ const full = present.filter(option => option.key !== SUMMARY)
+ // Where the reader goes when the summary is switched off: the fullest reading
+ // there is, which is Long unless the article only has the clinical view.
+ const fallback = full[0]?.key
+
const viewSwitch = present.length > 1 && (
-