diff --git a/backend/.env.example b/backend/.env.example
index a2febe0..54780a0 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -108,3 +108,8 @@ BBB_SECRET=
CLINICAL_MILVUS_URI=
CLINICAL_MILVUS_TOKEN=
CLINICAL_MILVUS_COLLECTION=mcp_bge_m3_1024
+
+# The indexed clinical library an AI draft can be grounded in, if this
+# deployment has one. Reached over MCP on an internal network; the search never
+# leaves it. Blank means the feature is simply not offered.
+CLINICAL_MCP_URL=
diff --git a/backend/app/config.py b/backend/app/config.py
index 52b6e3f..dd693e9 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -12,6 +12,9 @@ class Settings(BaseSettings):
LOGIN_WINDOW_MINUTES: int = 15
# Refresh is flood-protected rather than rate-limited; see auth.refresh.
REFRESH_MAX_PER_HOUR: int = 600
+ # Where the indexed clinical library answers, when there is one. The admin
+ # page can override it; this is the default a deployment ships with.
+ CLINICAL_MCP_URL: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index 861e483..f76625f 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -621,6 +621,7 @@ def get_settings(admin: User = Depends(require_admin)):
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.all_flags(),
+ **site_settings.all_values(),
}
except Exception:
return {
@@ -631,6 +632,7 @@ def get_settings(admin: User = Depends(require_admin)):
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.FLAGS,
+ **site_settings.VALUES,
}
@@ -652,6 +654,10 @@ def update_settings(
if flag in settings_data:
site_settings.set_flag(flag, bool(settings_data[flag]))
+ for name in site_settings.VALUES:
+ if name in settings_data:
+ site_settings.set_value(name, str(settings_data[name] or ""))
+
# Not settable here, and refused rather than ignored. Every vector in
# the database was produced by this model; changing it makes all of them
# incomparable and search returns noise until 3,000 questions, every
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 21e4966..077eaa1 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -104,6 +104,10 @@ class ArticleCategoryLinkIn(BaseModel):
class ArticleAIDraft(BaseModel):
topic: str
instructions: str | None = None
+ #: What to draw on besides the model's own knowledge. Both off by default,
+ #: so a draft asked for the way it always was is the draft it always was.
+ use_library: bool = False
+ use_pubmed: bool = False
@field_validator("topic")
@classmethod
@@ -292,6 +296,29 @@ def list_articles(
return [_article_card_json(a) for a in articles]
+@router.get("/ai-sources")
+def ai_sources(current_user: User = Depends(require_moderator)):
+ """Which grounding a draft can be offered here.
+
+ Asked by the panel before it draws its checkboxes: a switch for a library
+ nobody has configured is a switch that does nothing, and finding that out
+ by ticking it and waiting is the worst way to learn it.
+ """
+ from app.services import clinical_corpus, site_settings
+ return {
+ "library": {
+ "available": clinical_corpus.is_enabled(),
+ "label": "The clinical library",
+ "note": "Indexed reference works this institution has put in.",
+ },
+ "pubmed": {
+ "available": site_settings.get_flag("pubmed_enabled", False),
+ "label": "PubMed — cite published literature",
+ "note": "The query goes to NCBI.",
+ },
+ }
+
+
@router.get("/link-targets")
def link_targets(
q: str | None = Query(None, description="What the writer typed"),
@@ -957,6 +984,7 @@ def start_article_draft(
generate_article_draft.delay(
job_id=job_id, user_id=current_user.id,
topic=data.topic, instructions=data.instructions or "", model_id=None,
+ use_library=data.use_library, use_pubmed=data.use_pubmed,
)
except Exception:
raise HTTPException(503, "Task queue unavailable")
@@ -1136,6 +1164,11 @@ def editorial_queue(db: Session = Depends(get_db),
"variants": article_service.available_variants(article)}
awaiting = [row(a) for a in articles if a.status == "in_review"]
+ # Every draft, whoever or whatever wrote it. Without this a draft that is
+ # not machine-written and not yet in review belonged to no queue at all:
+ # the tile counted it and there was nowhere to click. A draft is by
+ # definition unfinished work, so it is always somebody's to pick up.
+ drafts = [row(a) for a in articles if a.status == "draft"]
machine_drafts = [row(a) for a in articles
if a.status == "draft" and a.generated_by]
no_references = [row(a) for a in articles
@@ -1156,6 +1189,7 @@ def editorial_queue(db: Session = Depends(get_db),
},
# Ordered by what blocks a learner soonest.
"awaiting_review": awaiting[:100],
+ "drafts": sorted(drafts, key=lambda r: r["updated_at"] or "", reverse=True)[:100],
"machine_drafts": machine_drafts[:100],
"published_without_references": no_references[:100],
"published_without_questions": no_questions[:100],
diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py
index 210735d..01d4b61 100644
--- a/backend/app/routers/flashcards.py
+++ b/backend/app/routers/flashcards.py
@@ -88,6 +88,9 @@ class FlashcardDeckDetail(BaseModel):
class CardEdit(BaseModel):
front: str | None = None
back: str | None = None
+ #: A picture on the card. Empty string clears it, which is why this is
+ #: `| None` for "not mentioned" rather than for "remove".
+ image_path: str | None = None
def _own_deck_or_404(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
@@ -99,6 +102,19 @@ def _own_deck_or_404(deck_id: int, current_user: User, db: Session) -> Flashcard
return deck
+def _own_deck_to_edit(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
+ """Yours to change, and you are somebody who writes decks.
+
+ Cards are written by educators. Ownership alone is not enough: if a learner
+ ever comes to own a deck — inherited, imported, granted — that must not by
+ itself make them an author of study material other people may end up
+ seeing.
+ """
+ if not current_user.is_moderator:
+ raise HTTPException(status_code=403, detail="Writing cards needs educator access")
+ return _own_deck_or_404(deck_id, current_user, db)
+
+
# ── Deck endpoints ───────────────────────────────────────────────────
@router.post("/")
@@ -383,7 +399,7 @@ def update_deck(
current_user: User = Depends(get_current_user),
):
"""Update deck metadata (title, category). Owner or admin only."""
- deck = _own_deck_or_404(deck_id, current_user, db)
+ deck = _own_deck_to_edit(deck_id, current_user, db)
if data.title is not None:
title = data.title.strip()
if not title:
@@ -604,9 +620,12 @@ def update_flashcard(
card.front = data.front
if data.back is not None:
card.back = data.back
+ if data.image_path is not None:
+ card.image_path = data.image_path.strip() or None
db.commit()
db.refresh(card)
- return {"id": card.id, "deck_id": card.deck_id, "front": card.front, "back": card.back}
+ return {"id": card.id, "deck_id": card.deck_id, "front": card.front,
+ "back": card.back, "image_path": card.image_path}
@router.delete("/cards/{card_id}", status_code=204)
@@ -653,7 +672,7 @@ def create_flashcard_manually(
current_user: User = Depends(get_current_user),
):
"""Add a single card to an existing deck. Owner or admin."""
- deck = _own_deck_or_404(deck_id, current_user, db)
+ deck = _own_deck_to_edit(deck_id, current_user, db)
if not data.front.strip() or not data.back.strip():
raise HTTPException(status_code=400, detail="Both sides are required")
card = Flashcard(deck_id=deck.id, front=data.front.strip(), back=data.back.strip(),
diff --git a/backend/app/services/clinical_corpus.py b/backend/app/services/clinical_corpus.py
new file mode 100644
index 0000000..a040c40
--- /dev/null
+++ b/backend/app/services/clinical_corpus.py
@@ -0,0 +1,171 @@
+"""The clinical library, as source material for a draft.
+
+An indexed shelf of reference works, searched over MCP by the same server the
+clinical assistant uses. Same index, deliberately: a second one over the same
+documents would be a copy that drifts. What differs is the budget — a chat
+answer wants a few tight excerpts because somebody is waiting, and an article
+synthesises a whole topic, so it wants more of them and longer.
+
+Excerpts are working material. What comes back is passed to the model as
+context to write *from*, in its own words, the way anybody writing from a
+reference book does — the prompt says so, and the draft is reviewed by an
+educator before it is published either way.
+
+Never raises. A library that cannot be reached must not fail the article
+somebody is writing; it just means they are writing without it, and the screen
+says so.
+"""
+import json
+import logging
+import threading
+import time
+import urllib.request
+
+from app.config import settings
+from app.services import site_settings
+
+logger = logging.getLogger(__name__)
+
+TOOL = "clinical_semantic_search"
+TIMEOUT = 60
+#: Generous, but not unbounded. "No limit" only moves the ceiling to the
+#: model's context window, where overflow truncates the middle of the prompt
+#: silently — the worst possible place to lose source material.
+DEFAULT_LIMIT = 24
+DEFAULT_CONTEXT = 1200
+#: An MCP session is a handshake plus an id; reusing it saves two round trips
+#: per search. Ten minutes, after which the server may have forgotten us.
+SESSION_TTL = 600
+
+_session: dict = {}
+_lock = threading.Lock()
+
+
+def endpoint() -> str:
+ return (site_settings.get_value("clinical_mcp_url")
+ or getattr(settings, "CLINICAL_MCP_URL", "")
+ or "").strip()
+
+
+def is_enabled() -> bool:
+ return bool(endpoint()) and site_settings.get_flag("clinical_library_enabled", False)
+
+
+def _post(url: str, body: dict, session_id: str | None = None) -> tuple[str | None, str]:
+ headers = {"Content-Type": "application/json",
+ "Accept": "application/json, text/event-stream"}
+ if session_id:
+ headers["mcp-session-id"] = session_id
+ request = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
+ return response.headers.get("mcp-session-id"), response.read().decode("utf-8", "replace")
+
+
+def _payload(raw: str) -> dict:
+ """The JSON out of an SSE body, or out of a plain one."""
+ for line in raw.splitlines():
+ if line.startswith("data: "):
+ try:
+ return json.loads(line[6:])
+ except ValueError:
+ continue
+ try:
+ return json.loads(raw)
+ except ValueError:
+ return {}
+
+
+def _open_session(url: str) -> str:
+ session_id, _ = _post(url, {
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {},
+ "clientInfo": {"name": "pedshub", "version": "1.0"}},
+ })
+ if not session_id:
+ raise RuntimeError("the library did not open a session")
+ _post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, session_id)
+ return session_id
+
+
+def _session_for(url: str) -> str:
+ with _lock:
+ fresh = (_session.get("url") == url
+ and _session.get("id")
+ and time.time() - _session.get("at", 0) < SESSION_TTL)
+ if not fresh:
+ _session.update({"url": url, "id": _open_session(url), "at": time.time()})
+ return _session["id"]
+
+
+def _drop_session() -> None:
+ with _lock:
+ _session.clear()
+
+
+def search(query: str, *, limit: int = DEFAULT_LIMIT,
+ context_chars: int = DEFAULT_CONTEXT) -> dict:
+ """Excerpts from the library for a topic, or an empty list and a reason."""
+ text = str(query or "").strip()
+ if not text:
+ return {"results": [], "reason": "empty query"}
+ url = endpoint()
+ if not url:
+ return {"results": [], "reason": "No clinical library is configured"}
+ if not site_settings.get_flag("clinical_library_enabled", False):
+ return {"results": [], "reason": "The clinical library is turned off for this site"}
+
+ body = {
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
+ "params": {"name": TOOL, "arguments": {
+ "query": text[:400], "limit": limit, "doc_types": ["file"],
+ "score_threshold": 0, "fusion": "rrf",
+ "include_context": True, "context_chars": context_chars,
+ }},
+ }
+ for attempt in (1, 2):
+ try:
+ _, raw = _post(url, body, _session_for(url))
+ payload = _payload(raw)
+ if payload.get("error"):
+ # A dead session looks like an error, not a connection failure,
+ # so one retry starts a new one rather than giving up.
+ if attempt == 1:
+ _drop_session()
+ continue
+ return {"results": [], "reason": "The library refused the search"}
+ content = (payload.get("result") or {}).get("content") or []
+ found = json.loads(content[0]["text"]) if content else {}
+ results = []
+ for row in (found.get("results") or [])[:limit]:
+ excerpt = " ".join(str(row.get("excerpt") or "").split())
+ if not excerpt:
+ continue
+ results.append({
+ "title": str(row.get("title") or "Untitled").replace("%20", " "),
+ "category": row.get("category") or "",
+ "excerpt": excerpt[:context_chars * 2],
+ })
+ return {"results": results, "reason": None if results else "nothing indexed on that"}
+ except Exception as exc:
+ _drop_session()
+ if attempt == 1:
+ continue
+ logger.warning("Clinical library search failed: %s", exc)
+ return {"results": [], "reason": "The clinical library could not be reached"}
+ return {"results": [], "reason": "The clinical library could not be reached"}
+
+
+def for_prompt(results: list[dict]) -> str:
+ """The excerpts, each under the work it came from."""
+ return "\n\n---\n\n".join(
+ f"{row['title']}\n{row['excerpt']}" for row in results)
+
+
+def sources(results: list[dict]) -> list[str]:
+ """The distinct works the excerpts came from, in the order first seen."""
+ seen: list[str] = []
+ for row in results:
+ title = row["title"]
+ if title not in seen:
+ seen.append(title)
+ return seen
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index 63149c9..e4aebd5 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -66,8 +66,23 @@ def _render(md: str) -> str:
def _wrap(subject: str, body_md: str) -> str:
+ """The shell, around a body written in our little markdown."""
+ return _wrap_html(subject, _render(body_md))
+
+
+def _wrap_html(subject: str, body_html: str, *, footer_ignore: bool = True) -> str:
+ """The same shell, around a body that is already HTML.
+
+ One message — the sign-in code — is laid out rather than written, and
+ putting it through the prose renderer would wrap each of its lines in a
+ left-aligned paragraph.
+ """
year = datetime.utcnow().year
- body_html = _render(body_md)
+ # Said once. The sign-in code says it in its own words directly under the
+ # button, and hearing it twice in eleven-point grey reads as boilerplate.
+ ignore_line = ('
'
+ "Didn't expect this email? You can safely ignore it.
- Didn't expect this email? You can safely ignore it.
-
+ {ignore_line}
@@ -157,19 +170,49 @@ We received a request to reset your password. Click below to choose a new one.
async def send_login_code_email(to_email: str, name: str, code: str):
+ """The code, and as little else as possible.
+
+ One thing is being asked of the reader — read six characters and type them
+ — so the code is the biggest thing on the screen and everything else is
+ underneath it in the order it matters: which account this signs into, a way
+ back to the page, and permission to ignore the whole thing.
+
+ Written as its own centred block rather than through the markdown renderer:
+ the renderer lays out prose left to right, which is right for every other
+ message we send and wrong for this one.
+ """
#: The code arrives here already grouped for reading; any shape it is typed
#: back in is normalised before it is compared.
- subject = "Your PedsHub sign-in code"
- md = f"""# Your sign-in code
+ subject = "Sign in to PedsHub"
+ body = f"""
+
+
+ Sign in to PedsHub
+
-Hi **{name}**,
+
+ {code}
+
-Enter this code on the sign-in page you just came from.
+
-> Expires in **15 minutes** · Works once. If you asked more than once, only the newest code works.
-
-Nobody from PedsHub will ever ask you to read this code out, on the phone or anywhere else. If somebody has, they are not us. If you didn't ask for this code, ignore this email — nobody can get in without it.
+
+ The code works once and expires in 15 minutes.
+ If you didn't try to log in, you can ignore this email.
+
+
"""
- await _send(to_email, subject, _wrap(subject, md))
+ await _send(to_email, subject, _wrap_html(subject, body, footer_ignore=False))
diff --git a/backend/app/services/pubmed.py b/backend/app/services/pubmed.py
new file mode 100644
index 0000000..ffaf548
--- /dev/null
+++ b/backend/app/services/pubmed.py
@@ -0,0 +1,218 @@
+"""The literature, as a source of its own.
+
+A web result is a page whose citation has to be reconstructed from its title; a
+PubMed record is structured — title, journal, year, authors, PMID — so a
+reference can be exact, and a reference list can carry a number somebody can
+look up.
+
+NCBI's E-utilities need no key. A key raises the rate limit from three requests
+a second to ten, which matters for indexing and not for one person drafting one
+article, so the key is optional and its absence is not a misconfiguration.
+Either way this reaches NCBI rather than a commercial third party.
+
+Ported from the same thing in ped-ai, including the two lessons that cost
+somebody an afternoon there: the query ladder, and the spacing between retries.
+"""
+import logging
+import re
+import time
+import urllib.parse
+import urllib.request
+
+from app.services import site_settings
+
+logger = logging.getLogger(__name__)
+
+BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
+MAX_RESULTS = 6
+TIMEOUT = 15
+#: NCBI asks callers to identify themselves so they can get in touch before
+#: blocking you. Free, and the polite thing to do.
+TOOL_NAME = "pedshub"
+
+#: PubMed ANDs every term after automatic mapping, so one unrecognised word
+#: takes the whole query to zero: "febrile seizures" finds six papers,
+#: "febrile seizures in under-fives" finds none. A topic written by a person is
+#: full of such words.
+STOPWORDS = set((
+ "a an and are as at be but by for from how in into is it its of on or "
+ "that the their there these this to under over with what when where which "
+ "who why show shows showed evidence review overview update current recent "
+ "latest new approach approaches management use using guidance guidelines"
+).split())
+
+
+def settings() -> dict:
+ return {
+ "enabled": site_settings.get_flag("pubmed_enabled", False),
+ "api_key": (site_settings.get_value("pubmed_api_key") or "").strip(),
+ "email": (site_settings.get_value("pubmed_contact_email") or "").strip(),
+ }
+
+
+def _clip(text, limit: int) -> str:
+ return re.sub(r"\s+", " ", str(text or "")).strip()[:limit]
+
+
+def _year(pubdate) -> str:
+ found = re.search(r"\d{4}", str(pubdate or ""))
+ return found.group(0) if found else ""
+
+
+def _url(path: str, params: dict, config: dict) -> str:
+ query = {k: v for k, v in params.items() if v not in (None, "")}
+ query["tool"] = TOOL_NAME
+ if config.get("email"):
+ query["email"] = config["email"]
+ if config.get("api_key"):
+ query["api_key"] = config["api_key"]
+ return f"{BASE}/{path}?{urllib.parse.urlencode(query)}"
+
+
+def _get(url: str) -> str:
+ request = urllib.request.Request(url, headers={"Accept": "application/json"})
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
+ return response.read().decode("utf-8", "replace")
+
+
+def _content_words(text: str) -> list[str]:
+ # Anything after a colon or a dash is almost always a subtitle, not a subject.
+ head = re.split(r"[:—–]|\s-\s", str(text))[0]
+ cleaned = re.sub(r"[^a-z0-9\s-]", " ", head.lower())
+ return [word for word in cleaned.split() if len(word) > 2 and word not in STOPWORDS]
+
+
+def _candidates(text: str) -> list[str]:
+ """Progressively shorter versions of the query, longest first.
+
+ So a topic that finds nothing is retried against something PubMed can
+ actually match, and the most specific query that works is the one used.
+ """
+ words = _content_words(text)
+ tried = [text.strip()]
+ for count in range(min(len(words), 4), 1, -1):
+ candidate = " ".join(words[:count])
+ if candidate and candidate not in tried:
+ tried.append(candidate)
+ if len(words) == 1 and words[0] not in tried:
+ tried.append(words[0])
+ return tried
+
+
+def _abstracts(pmids: list[str], config: dict) -> dict[str, str]:
+ """Fetched separately, because esummary does not carry them.
+
+ An abstract is what makes a record useful to write from rather than merely
+ cite. Parsed leniently: a missing one is normal — editorials, letters — and
+ must not lose the record.
+ """
+ if not pmids:
+ return {}
+ try:
+ raw = _get(_url("efetch.fcgi", {
+ "db": "pubmed", "id": ",".join(pmids), "retmode": "xml", "rettype": "abstract",
+ }, config))
+ except Exception:
+ return {}
+ out: dict[str, str] = {}
+ for chunk in raw.split("")[1:]:
+ found = re.search(r"]*>(\d+)", chunk)
+ if not found:
+ continue
+ parts = [re.sub(r"<[^>]+>", " ", part)
+ for part in re.findall(r"]*>([\s\S]*?)", chunk)]
+ if parts:
+ out[found.group(1)] = _clip(" ".join(parts), 1500)
+ return out
+
+
+def search(query: str, limit: int = MAX_RESULTS) -> dict:
+ """Search PubMed for a topic.
+
+ Never raises, for the same reason corpus retrieval does not: a lookup
+ failing must not fail the article somebody is writing.
+ """
+ text = str(query or "").strip()[:400]
+ if not text:
+ return {"results": [], "reason": "empty query"}
+ config = settings()
+ if not config["enabled"]:
+ return {"results": [], "reason": "PubMed is turned off for this site"}
+
+ try:
+ import json
+
+ def ids_for(term: str) -> list[str]:
+ found = json.loads(_get(_url("esearch.fcgi", {
+ "db": "pubmed", "term": term, "retmode": "json",
+ "retmax": str(limit), "sort": "relevance",
+ }, config)))
+ return (found.get("esearchresult") or {}).get("idlist", [])[:limit]
+
+ # Three requests a second without a key, ten with one. The ladder can
+ # make three esearch calls before the esummary and efetch that follow,
+ # which is enough to turn a working search into a 429 — measured, on
+ # this exact path. So retries are spaced; the first attempt, which is
+ # the usual case, waits for nothing.
+ tries = _candidates(text)
+ gap = 0.12 if config["api_key"] else 0.38
+ used, pmids = tries[0], []
+ for index, term in enumerate(tries):
+ if index:
+ time.sleep(gap)
+ used = term
+ pmids = ids_for(term)
+ if pmids:
+ break
+ if not pmids:
+ return {"results": [], "reason": "no results", "query": used}
+
+ summary = json.loads(_get(_url("esummary.fcgi", {
+ "db": "pubmed", "id": ",".join(pmids), "retmode": "json",
+ }, config))).get("result", {})
+ abstracts = _abstracts(pmids, config)
+
+ results = []
+ for pmid in pmids:
+ record = summary.get(pmid) or {}
+ names = [a.get("name") for a in record.get("authors", []) if a.get("name")]
+ results.append({
+ "pmid": pmid,
+ "title": _clip(record.get("title"), 300) or "Untitled",
+ "journal": _clip(record.get("source"), 150),
+ "year": _year(record.get("pubdate")),
+ # Three and "et al" is how a citation reads; the full list is
+ # noise here.
+ "authors": ", ".join(names[:3]) + (", et al" if len(names) > 3 else ""),
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
+ "abstract": abstracts.get(pmid, ""),
+ })
+ # Saying which query worked matters: the screen reports what was
+ # searched, and reporting the phrase somebody typed when a narrowed one
+ # found the results would be a lie.
+ return {"results": results, "reason": None, "query": used}
+ except Exception as exc:
+ logger.warning("PubMed search failed: %s", exc)
+ return {"results": [], "reason": "PubMed could not be reached"}
+
+
+def for_prompt(results: list[dict]) -> str:
+ """Formatted so a model can cite it exactly, PMID and all."""
+ blocks = []
+ for record in results:
+ blocks.append("\n".join(filter(None, [
+ record["title"],
+ ". ".join(filter(None, [record["authors"], record["journal"], record["year"]])),
+ f"PMID: {record['pmid']} — {record['url']}",
+ record["abstract"] or "(no abstract available)",
+ ])))
+ 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
diff --git a/backend/app/services/site_settings.py b/backend/app/services/site_settings.py
index 81b653e..d813018 100644
--- a/backend/app/services/site_settings.py
+++ b/backend/app/services/site_settings.py
@@ -30,6 +30,24 @@ FLAGS: dict[str, bool] = {
#: and told it may reveal it, so during an exam it would simply hand it
#: over. This switch decides whether even study mode gets it.
"tutor_in_quiz": True,
+ #: Whether an AI draft may be grounded in the indexed clinical library.
+ #: Off until somebody points the site at a library and turns it on.
+ "clinical_library_enabled": False,
+ #: Whether an AI draft may search PubMed for published literature to cite.
+ "pubmed_enabled": False,
+}
+
+#: Settings that are text rather than a switch: an address, a key, an email.
+#: name -> default. Anything not listed here cannot be set, for the same reason
+#: the flags cannot: a typo should fail loudly rather than write a key nothing
+#: will ever read.
+VALUES: dict[str, str] = {
+ #: Where the clinical library answers. Blank means there is not one.
+ "clinical_mcp_url": "",
+ #: NCBI raises the rate limit for a caller who identifies themselves. Both
+ #: optional: E-utilities works without either, more slowly.
+ "pubmed_api_key": "",
+ "pubmed_contact_email": "",
}
@@ -40,10 +58,10 @@ def _client():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
-def get_flag(name: str) -> bool:
+def get_flag(name: str, default: bool | None = None) -> bool:
if name not in FLAGS:
raise KeyError(name)
- default = FLAGS[name]
+ default = FLAGS[name] if default is None else default
try:
value = _client().get(f"settings:{name}")
except Exception:
@@ -62,3 +80,25 @@ def set_flag(name: str, value: bool) -> None:
def all_flags() -> dict[str, bool]:
return {name: get_flag(name) for name in FLAGS}
+
+
+def get_value(name: str) -> str:
+ if name not in VALUES:
+ raise KeyError(name)
+ default = VALUES[name]
+ try:
+ value = _client().get(f"settings:{name}")
+ except Exception:
+ logger.warning("Redis unavailable reading %s; using the default", name, exc_info=True)
+ return default
+ return default if value is None else value
+
+
+def set_value(name: str, value: str) -> None:
+ if name not in VALUES:
+ raise KeyError(name)
+ _client().set(f"settings:{name}", (value or "").strip())
+
+
+def all_values() -> dict[str, str]:
+ return {name: get_value(name) for name in VALUES}
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 7f9d564..b1843dd 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -954,6 +954,55 @@ class ArticleDraftError(RuntimeError):
"""A refusal with a sentence the educator can act on."""
+def _draft_grounding(r, job_id: str, topic: str, use_library: bool, use_pubmed: bool):
+ """Source material for a draft, and the references that come with it.
+
+ Both lookups are optional, both are best-effort, and neither can fail the
+ draft: an educator who ticks a box and gets nothing is told so on the
+ progress line and still gets their article.
+
+ Returned as a block to append to the prompt, so the prompt itself — which
+ is working — does not change shape when a source is added or removed.
+ """
+ from app.services import clinical_corpus, pubmed
+
+ blocks: list[str] = []
+ references: list[str] = []
+
+ if use_library:
+ _push_step(r, job_id, "ai", "Reading the clinical library…")
+ found = clinical_corpus.search(topic)
+ if found["results"]:
+ blocks.append(
+ "Excerpts from this institution's clinical library, for you to write "
+ "from. Use them for the facts, the structure and the emphasis, and "
+ "write the article in your own words — do not copy sentences out of "
+ "them. They are reference material, not a draft.\n\n"
+ + clinical_corpus.for_prompt(found["results"]))
+ _push_step(r, job_id, "ai",
+ f"{len(found['results'])} excerpts from the library.")
+ else:
+ _push_step(r, job_id, "ai", f"Library: {found['reason']}.")
+
+ if use_pubmed:
+ _push_step(r, job_id, "ai", "Searching PubMed…")
+ found = pubmed.search(topic)
+ if found["results"]:
+ blocks.append(
+ "Published literature on this topic, found on PubMed. Where you use "
+ "one, cite it in the text as (Author, year) and nothing more — the "
+ "reference list is written for you from these records, so do not "
+ "invent entries and do not add any of your own.\n\n"
+ + pubmed.for_prompt(found["results"]))
+ references = pubmed.as_references(found["results"])
+ _push_step(r, job_id, "ai",
+ f"{len(found['results'])} papers, searched as \"{found.get('query') or topic}\".")
+ else:
+ _push_step(r, job_id, "ai", f"PubMed: {found['reason']}.")
+
+ return ("\n\n".join(blocks), references)
+
+
ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform.
Topic: {topic}
{instructions}
@@ -969,7 +1018,8 @@ Rules: markdown formatting; headings, lists and tables welcome; no fabricated re
@celery_app.task(name="generate_article_draft", bind=True)
def generate_article_draft(self, job_id: str, user_id: int, topic: str,
instructions: str = "", article_id: int | None = None,
- model_id: str | None = None):
+ model_id: str | None = None, use_library: bool = False,
+ use_pubmed: bool = False):
"""Create or refine an educator article draft; never publishes."""
import re
import uuid
@@ -1003,6 +1053,13 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "",
existing=existing_block,
)
+ # Anything the educator asked us to draw on, added after the prompt
+ # rather than woven into it: a draft with nothing to draw on is exactly
+ # the draft that has been working, and this is the only way to keep
+ # that true as sources are added.
+ grounding, references = _draft_grounding(r, job_id, topic, use_library, use_pubmed)
+ if grounding:
+ prompt = f"{prompt}\n\n{grounding}"
# 4,000 was the cap, and it is what broke this: the prompt asks for a
# full article *plus* a high-yield view plus a clinical one, the reply
# ran past the ceiling, and `json.loads` failed on a string the model
@@ -1059,10 +1116,25 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
while db.query(Article.id).filter(Article.slug == slug).first():
slug = f"{base_slug}-{n}"
n += 1
+ # Stamped with the model that wrote it. The column existed and
+ # nothing set it, so an AI draft was indistinguishable from one a
+ # person typed — and the editorial queue that lists generated
+ # drafts never showed a single one of them.
article = Article(slug=slug, title=title, summary=str(data.get("summary", "") or "")[:2000],
content=str(data.get("content", "") or ""), sections=sections,
- user_id=user_id, status="draft")
+ user_id=user_id, status="draft",
+ generated_by=str(ai_model_id or "ai")[:80])
db.add(article)
+ # The reference list is written from the records rather than by the
+ # model, which is the whole reason for searching PubMed rather than
+ # asking it what the literature says: every line here is a paper that
+ # 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)
+ article.references_json = kept
db.commit()
# A draft that is not indexed is a draft nobody can find. Every writer of
# `Article.sections` has to do this; the ones that did not left 323
@@ -1123,7 +1195,12 @@ def generate_article_cards(self, job_id: str, user_id: int, article_id: int,
FlashcardDeck.deleted_at.is_(None),
).first()
if not deck:
- deck = FlashcardDeck(title=f"Cards: {article.title}", user_id=user_id, card_count=0, is_shared=0)
+ # Filed where the article is filed. A deck written from an
+ # article belongs to the same topic, and asking somebody to
+ # choose the category again is asking them to repeat a fact
+ # the system already knows.
+ deck = FlashcardDeck(title=f"Cards: {article.title}", user_id=user_id,
+ category_id=article.category_id, card_count=0, is_shared=0)
db.add(deck)
db.flush()
new_cards = []
diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json
index 4212db2..fbaf5c4 100644
--- a/backend/tests/api-contract.json
+++ b/backend/tests/api-contract.json
@@ -606,6 +606,13 @@
"422"
]
},
+ "GET /api/v1/articles/ai-sources": {
+ "body": false,
+ "params": [],
+ "responses": [
+ "200"
+ ]
+ },
"GET /api/v1/articles/by-slug/{slug}": {
"body": false,
"params": [
diff --git a/backend/tests/test_shared_category.py b/backend/tests/test_shared_category.py
index f4c5776..c199479 100644
--- a/backend/tests/test_shared_category.py
+++ b/backend/tests/test_shared_category.py
@@ -29,7 +29,11 @@ class SharedCategoryTests(unittest.TestCase):
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
- self.user = User(id=1, name="Owner", email="owner@example.test", hashed_password="unused")
+ # A moderator, because filing and renaming a deck is an educator's
+ # job: cards are study material, and who may write them is not decided
+ # by who happens to own the row.
+ self.user = User(id=1, name="Owner", email="owner@example.test",
+ hashed_password="unused", role="moderator")
self.db.add(self.user)
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=1),
diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
index 8ede2f9..3c8feb6 100644
--- a/frontend/src/components/RichText.jsx
+++ b/frontend/src/components/RichText.jsx
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
+import { Link } from 'react-router-dom'
import ArticleLink from './ArticleLink'
import rehypeHighlightOffsets from '../utils/highlightOffsets'
import remarkTipTerms from '../utils/tipTerms'
@@ -67,6 +68,14 @@ export default function RichText({
attemptId,
className = '',
linkArticles = false,
+ // Whether a cross-reference shows a preview card or simply goes.
+ //
+ // A card is not a page: it is a box a few lines tall, often inside a
+ // flipping panel, and a hover card anchored inside one has nowhere to open —
+ // it is drawn off the edge or clipped by the box, and the link reads as
+ // broken because clicking it appears to do nothing. Where there is no room
+ // to preview, the honest behaviour is to take the reader there.
+ previewLinks = true,
// Passing a textId turns on the highlight layer: text keeps the source
// offsets that highlights and the read-aloud cursor are stored against.
textId = null,
@@ -98,11 +107,12 @@ export default function RichText({
),
a: ({ node, href, children, ...props }) => {
const target = linkArticles && internalArticle(href)
- if (target) {
+ if (target && previewLinks) {
return (
{children}
)
}
+ if (target) return {children}
return {children}
},
// `==key point==`, highlighted. Passed through with its class rather than
@@ -113,7 +123,7 @@ export default function RichText({
table: ({ node, ...props }) => (
api.put('/admin/settings', { [name]: value }), 'Could not save that')
}
+ const save = (name, value) => {
+ setValues(prev => ({ ...prev, [name]: value }))
+ return run(() => api.put('/admin/settings', { [name]: value }), 'Could not save that')
+ }
+
const issue = () => run(
() => api.post('/admin/invites', { note: note.trim() || null }).then(res => { setNote(''); return res }),
'Could not create a code')
@@ -145,6 +164,62 @@ export default function SitePolicy() {
+ {/* What an AI draft may draw on besides the model. Both off until
+ somebody sets them up, and each one says where the query goes: a
+ library search stays inside the building, a PubMed search does not. */}
+
+ {flags.clinical_library_enabled && (
+