diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 077eaa1..1381e8d 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -47,11 +47,55 @@ class ArticleSection(BaseModel):
class ArticleReference(BaseModel):
- """Where a fact came from. Named sources, not markers in the prose."""
+ """Where a fact came from. Named sources, not markers in the prose.
+
+ Two kinds share the shape. A book has an author and the pages a fact is
+ on; a paper has a journal, a year and a PMID somebody can look up. The
+ fields a kind does not use stay empty rather than each kind getting its
+ own list, because the reader draws one reference list.
+ """
title: str = Field(min_length=1, max_length=300)
author: str | None = Field(default=None, max_length=300)
pages: list[int] = Field(default_factory=list, max_length=40)
+ journal: str | None = Field(default=None, max_length=150)
+ year: str | None = Field(default=None, max_length=10)
+ pmid: str | None = Field(default=None, max_length=20)
+ url: str | None = Field(default=None, max_length=300)
+
+
+#: "Barski L, et al. Management of diabetic ketoacidosis. Eur J Intern Med.
+#: 2023. PMID: 37419787." — the shape the PubMed path used to write as one
+#: flat line, before it wrote the same fields everything else writes.
+_PMID_TAIL = re.compile(r"\s*PMID:\s*(\d+)\.?\s*$")
+
+
+def _reference_json(entry) -> dict:
+ """One reference, in the shape the reader draws.
+
+ Rows written before references were structured are single strings, and the
+ reader reads `ref.title` — so those drew as blank list items under a
+ References heading, which is worse than no heading at all. Pulled apart
+ here rather than rewritten in the database: the read path is the one place
+ both old and new rows pass through.
+ """
+ if isinstance(entry, dict):
+ return entry
+ line = str(entry or "").strip()
+ if not line:
+ return {}
+ found = _PMID_TAIL.search(line)
+ pmid = found.group(1) if found else None
+ return {
+ "title": (_PMID_TAIL.sub("", line).strip() or line)[:300],
+ "author": None, "pages": [],
+ "pmid": pmid,
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else None,
+ }
+
+
+def _references_json(rows) -> list[dict]:
+ return [ref for ref in (_reference_json(row) for row in (rows or [])) if ref]
class ArticleWrite(BaseModel):
@@ -236,7 +280,7 @@ def _article_json(article: Article) -> dict:
"section_id": article.section_id,
"user_id": article.user_id,
"status": article.status,
- "references": article.references_json or [],
+ "references": _references_json(article.references_json),
"variants": article_service.available_variants(article),
"generated_by": article.generated_by,
"reviewed_at": article.reviewed_at,
@@ -271,7 +315,14 @@ def list_articles(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """Published articles for everyone; educators additionally see drafts."""
+ """The published library. Drafts are Editorial's business, not Reading's.
+
+ An educator used to see their drafts mixed into the shelf here, marked
+ with a tag — which made Reading and Editorial two views of the same list
+ and left an admin unsure which one he was looking at. Reading is what a
+ learner would see. Unfinished work is in Editorial, where it can be
+ worked on.
+ """
query = db.query(Article).filter(Article.deleted_at.is_(None))
if category_id:
query = query.filter(Article.category_id == category_id)
@@ -291,8 +342,7 @@ def list_articles(
articles = query.order_by(Article.updated_at.desc()).all()
if q and q.strip():
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
- if not current_user.is_moderator:
- articles = [a for a in articles if a.status == "published"]
+ articles = [a for a in articles if a.status == "published"]
return [_article_card_json(a) for a in articles]
@@ -1120,7 +1170,7 @@ def read_revision(article_id: int, revision_id: int, db: Session = Depends(get_d
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}
+ "references": _references_json(revision.references_json), "created_at": revision.created_at}
@router.post("/{article_id}/revisions/{revision_id}/restore")
diff --git a/backend/app/services/pubmed.py b/backend/app/services/pubmed.py
index ffaf548..9268842 100644
--- a/backend/app/services/pubmed.py
+++ b/backend/app/services/pubmed.py
@@ -209,10 +209,21 @@ def for_prompt(results: list[dict]) -> str:
return "\n\n---\n\n".join(blocks)
-def as_references(results: list[dict]) -> list[str]:
- """One line each, in the shape an article's reference list already uses."""
- lines = []
- for record in results:
- parts = [p for p in (record["authors"], record["title"], record["journal"], record["year"]) if p]
- lines.append(". ".join(parts).rstrip(".") + f". PMID: {record['pmid']}.")
- return lines
+def as_references(results: list[dict]) -> list[dict]:
+ """In the shape an article's reference list already uses.
+
+ Fields, not a sentence. Every other reference on an article is
+ {title, author, pages, ...} and the reader draws those fields — so a flat
+ line came out as a blank row under a References heading. A paper fills the
+ journal, year and PMID instead of the pages, and the PMID is what makes it
+ checkable.
+ """
+ return [{
+ "title": record["title"].rstrip("."),
+ "author": record["authors"] or None,
+ "pages": [],
+ "journal": record["journal"] or None,
+ "year": record["year"] or None,
+ "pmid": record["pmid"],
+ "url": record["url"],
+ } for record in results]
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 5e1be0b..b7b8d98 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -1151,9 +1151,14 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
# exists, with a PMID somebody can look up.
if references:
kept = list(article.references_json or [])
- for line in references:
- if line not in kept:
- kept.append(line)
+ # By PMID, not by equality: a refined draft searches PubMed again
+ # and the same paper comes back with the same id, so comparing
+ # whole records would stack duplicates every run.
+ have = {r.get("pmid") for r in kept if isinstance(r, dict) and r.get("pmid")}
+ for record in references:
+ if record["pmid"] not in have:
+ kept.append(record)
+ have.add(record["pmid"])
article.references_json = kept
db.commit()
# A draft that is not indexed is a draft nobody can find. Every writer of
diff --git a/backend/tests/test_article_ai.py b/backend/tests/test_article_ai.py
index 02b506b..c55de78 100644
--- a/backend/tests/test_article_ai.py
+++ b/backend/tests/test_article_ai.py
@@ -122,6 +122,53 @@ class ArticleAiTests(unittest.TestCase):
patch.stopall()
self.redis.reset_mock()
+ def test_pubmed_references_are_fields_the_reader_draws(self):
+ """A paper is stored like a book: fields, not one flat sentence.
+
+ Everything else on an article is {title, author, pages}, and the reader
+ reads those keys — so the line PubMed used to write appeared as a blank
+ row under a References heading. A draft with six real papers looked
+ like a draft with none.
+ """
+ from app.services import pubmed
+ records = [{
+ 'pmid': '37419787', 'title': 'Management of diabetic ketoacidosis.',
+ 'journal': 'Eur J Intern Med', 'year': '2023',
+ 'authors': 'Barski L, Golbets E, et al',
+ 'url': 'https://pubmed.ncbi.nlm.nih.gov/37419787/', 'abstract': '',
+ }]
+ reference, = pubmed.as_references(records)
+ self.assertEqual(reference['title'], 'Management of diabetic ketoacidosis')
+ self.assertEqual(reference['pmid'], '37419787')
+ self.assertEqual(reference['journal'], 'Eur J Intern Med')
+ self.assertEqual(reference['pages'], [])
+
+ # And a row written before this pulls itself apart on the way out,
+ # rather than being rewritten in the database.
+ healed = articles._reference_json(
+ 'Barski L, et al. Management of diabetic ketoacidosis.. '
+ 'Eur J Intern Med. 2023. PMID: 37419787.')
+ self.assertEqual(healed['pmid'], '37419787')
+ self.assertNotIn('PMID', healed['title'])
+ self.assertEqual(articles._reference_json({'title': 'A book'}), {'title': 'A book'})
+
+ def test_reading_shows_nobody_drafts(self):
+ """Reading is the published library, Editorial is the unfinished work.
+
+ An educator saw their own drafts filed among the published articles
+ with a tag on them, which made Reading and Editorial two views of one
+ list. Not even an admin sees a draft here.
+ """
+ self.bank.db.add(Article(slug='half-written', title='Half written',
+ content='Body', sections=[], user_id=3, status='draft'))
+ self.bank.db.add(Article(slug='finished', title='Finished',
+ content='Body', sections=[], user_id=3, status='published'))
+ self.bank.db.commit()
+ listed = self.client.get('/articles/').json()
+ slugs = [a['slug'] for a in listed]
+ self.assertIn('finished', slugs)
+ self.assertNotIn('half-written', slugs)
+
if __name__ == '__main__':
unittest.main()
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ae5108e..67079ee 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -235,6 +235,12 @@ function AppRoutes() {
{/* Cross-references in article prose address a topic by slug, which
outlives a numeric id and is what an educator actually writes. */}
Built from your own answers — where you stand, and what to study next. diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 3cfc1e5..06c4924 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -375,6 +375,13 @@ } .article-ref-title { color: var(--text); font-weight: 600; } .article-ref-pages { font-variant-numeric: tabular-nums; } +/* The checkable part of a paper. Set apart from the citation so the eye finds + it, and a link because it goes somewhere. */ +.article-ref-pmid { + margin-left: 8px; font-size: .78rem; font-weight: 600; + color: var(--primary); text-decoration: none; white-space: nowrap; +} +.article-ref-pmid:hover { text-decoration: underline; } @media (max-width: 900px) { /* Four things in a bar this narrow is three too many; the rail and the diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 873287b..bbdf5d1 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' import { SplitViewProvider } from '../context/SplitViewContext' @@ -90,7 +90,7 @@ export default function ArticlesPage() { try { const res = await api.post('/articles/', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] }) setShowCreate(false) - navigate(`/articles/${res.data.id}?edit=1`) + navigate(`/editorial/articles/${res.data.id}?edit=1`) } catch (err) { setError(err.response?.data?.detail || 'Could not create article') } @@ -115,7 +115,7 @@ export default function ArticlesPage() { // Straight into the draft. It finished into a list otherwise, and // the educator who asked for it was left looking at a panel that // had closed and a library that looked unchanged. - if (job.data.article_id) { navigate(`/articles/${job.data.article_id}?edit=1`); return } + if (job.data.article_id) { navigate(`/editorial/articles/${job.data.article_id}?edit=1`); return } load() } else if (job.data.status === 'failed') { setAiStatus(''); setError(`Drafting failed: ${job.data.error || 'unknown error'}`) } @@ -233,7 +233,7 @@ export default function ArticlesPage() {