feat: ground AI drafts in the library and PubMed, and mend the card system
**Two sources an AI draft can draw on**, both off until an administrator turns them on, both appended to the prompt as extra material rather than woven into it — so a draft with nothing to draw on is byte-for-byte the draft that has been working well. - *The clinical library.* The indexed shelf the clinical assistant already searches, over MCP on the internal network. Ported from ped-ai: sessions are reused, a dead one is reopened once, and a library that cannot be reached never fails the article — it just means the educator is writing without it, and the progress line says so. - *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the two lessons that cost somebody an afternoon over there: PubMed ANDs every term, so "bronchiolitis management in infants" can find nothing where "bronchiolitis management" finds six — hence the query ladder — and three esearch calls in a row will trip the rate limit, hence the spacing. The reference list is written from the records rather than by the model, so every line is a paper that exists with a PMID somebody can look up. Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text citations, in one draft. **The card system, which turned out to be half-built:** - There was no way to make a deck by hand, and no way to edit a card at all — you could browse, view and delete. Both are there now, the editor taking front, back and a picture. - Filing, writing, sharing and deleting are all educator work now, behind one named gate rather than four scattered checks. A learner studies. - A deck generated from an article inherits that article's category instead of landing in Uncategorized for somebody to file by hand. - A link inside a card previewed instead of going. A card is a box a few lines tall, often inside a flipping panel, and a hover card anchored in one is clipped by it — so the link read as broken because clicking it did nothing. Where there is no room to preview, the honest behaviour is to take you there. **An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so a drafted article was neither "generated, unread" nor anything else: the tile counted it and there was nowhere to click. Drafts are stamped with the model that wrote them, and there is now a plain Drafts queue that cannot be fallen through. **The sign-in code email** is laid out rather than written: the code is the biggest thing on the screen, then which account it signs into, then a way back to the page, then permission to ignore the whole thing. Also: a back link out of a deck, in the same words as the rest of the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
158930d532
commit
febb14490c
22 changed files with 937 additions and 38 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
171
backend/app/services/clinical_corpus.py
Normal file
171
backend/app/services/clinical_corpus.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 = ('<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">'
|
||||
"Didn't expect this email? You can safely ignore it.</p>"
|
||||
) if footer_ignore else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
|
|
@ -93,9 +108,7 @@ def _wrap(subject: str, body_md: str) -> str:
|
|||
PedsHub · Pediatric Knowledge Platform<br/>
|
||||
<a href="{settings.APP_URL}" style="color:#a1a1aa;text-decoration:underline;">{settings.APP_URL}</a>
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">
|
||||
Didn't expect this email? You can safely ignore it.
|
||||
</p>
|
||||
{ignore_line}
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
|
|
@ -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"""
|
||||
<div style="text-align:center;">
|
||||
<h1 style="margin:0 0 28px;font-size:26px;font-weight:600;color:#09090b;letter-spacing:-0.3px;">
|
||||
Sign in to PedsHub
|
||||
</h1>
|
||||
|
||||
Hi **{name}**,
|
||||
<div style="display:inline-block;background:#f8fafc;border:1px solid #2563eb;border-radius:10px;
|
||||
padding:18px 30px;margin-bottom:22px;">
|
||||
<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:30px;
|
||||
font-weight:700;letter-spacing:0.30em;color:#2563eb;">{code}</span>
|
||||
</div>
|
||||
|
||||
Enter this code on the sign-in page you just came from.
|
||||
<p style="margin:0 0 26px;font-size:15px;color:#3f3f46;line-height:1.6;">
|
||||
Enter this code to sign in as<br/>
|
||||
<a href="mailto:{to_email}" style="color:#2563eb;font-weight:600;text-decoration:underline;">{to_email}</a>
|
||||
</p>
|
||||
|
||||
[code:{code}]
|
||||
<p style="margin:0 0 26px;">
|
||||
<a href="{settings.APP_URL}/login"
|
||||
style="display:inline-block;border:1px solid #2563eb;border-radius:8px;padding:12px 28px;
|
||||
font-size:15px;color:#2563eb;text-decoration:none;letter-spacing:0.02em;">
|
||||
Sign in to PedsHub
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> 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.
|
||||
<p style="margin:0;font-size:13px;color:#a1a1aa;line-height:1.6;">
|
||||
The code works once and expires in 15 minutes.<br/>
|
||||
If you didn't try to log in, you can ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
await _send(to_email, subject, _wrap(subject, md))
|
||||
await _send(to_email, subject, _wrap_html(subject, body, footer_ignore=False))
|
||||
|
|
|
|||
218
backend/app/services/pubmed.py
Normal file
218
backend/app/services/pubmed.py
Normal file
|
|
@ -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("<PubmedArticle>")[1:]:
|
||||
found = re.search(r"<PMID[^>]*>(\d+)</PMID>", chunk)
|
||||
if not found:
|
||||
continue
|
||||
parts = [re.sub(r"<[^>]+>", " ", part)
|
||||
for part in re.findall(r"<AbstractText[^>]*>([\s\S]*?)</AbstractText>", 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ArticleLink slug={target.slug} sectionId={target.sectionId}>{children}</ArticleLink>
|
||||
)
|
||||
}
|
||||
if (target) return <Link to={href} className="al-link">{children}</Link>
|
||||
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
},
|
||||
// `==key point==`, highlighted. Passed through with its class rather than
|
||||
|
|
@ -113,7 +123,7 @@ export default function RichText({
|
|||
table: ({ node, ...props }) => (
|
||||
<div className="rich-table-wrap"><table {...props} /></div>
|
||||
),
|
||||
}), [attemptId, linkArticles, onTipOpen])
|
||||
}), [attemptId, linkArticles, previewLinks, onTipOpen])
|
||||
|
||||
return (
|
||||
<div className={`rich-text ${className}`.trim()}
|
||||
|
|
|
|||
|
|
@ -58,3 +58,14 @@
|
|||
background: var(--wrong-bg); color: var(--wrong-fg);
|
||||
border: 1px solid var(--wrong-bd);
|
||||
}
|
||||
|
||||
/* A setting that is text rather than a switch, tucked under the switch it
|
||||
belongs to so it is plainly part of the same decision. */
|
||||
.sp-field { margin: -2px 0 10px 30px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.sp-field label { font-size: .8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.sp-field label small { font-weight: 400; }
|
||||
.sp-field input {
|
||||
padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font: inherit; font-size: .84rem;
|
||||
}
|
||||
.sp-field input:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export default function SitePolicy() {
|
|||
const [flags, setFlags] = useState({
|
||||
registration_enabled: true, sharing_enabled: true, invite_only: false,
|
||||
tutor_in_quiz: true, sso_only: false,
|
||||
clinical_library_enabled: false, pubmed_enabled: false,
|
||||
})
|
||||
//: The addresses and keys behind the two grounding switches. Typed in a
|
||||
//: field and saved on blur rather than as you type, because a key half
|
||||
//: entered is a key that does not work.
|
||||
const [values, setValues] = useState({
|
||||
clinical_mcp_url: '', pubmed_api_key: '', pubmed_contact_email: '',
|
||||
})
|
||||
const [sso, setSso] = useState({ configured: false, name: '' })
|
||||
const [codes, setCodes] = useState([])
|
||||
|
|
@ -36,6 +43,13 @@ export default function SitePolicy() {
|
|||
invite_only: settings.data.invite_only === true,
|
||||
tutor_in_quiz: settings.data.tutor_in_quiz !== false,
|
||||
sso_only: settings.data.sso_only === true,
|
||||
clinical_library_enabled: settings.data.clinical_library_enabled === true,
|
||||
pubmed_enabled: settings.data.pubmed_enabled === true,
|
||||
})
|
||||
setValues({
|
||||
clinical_mcp_url: settings.data.clinical_mcp_url || '',
|
||||
pubmed_api_key: settings.data.pubmed_api_key || '',
|
||||
pubmed_contact_email: settings.data.pubmed_contact_email || '',
|
||||
})
|
||||
setSso({
|
||||
configured: !!settings.data.sso_configured,
|
||||
|
|
@ -63,6 +77,11 @@ export default function SitePolicy() {
|
|||
return run(() => 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() {
|
|||
</span>
|
||||
</label>
|
||||
|
||||
{/* 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. */}
|
||||
<label className="sp-switch">
|
||||
<input type="checkbox" checked={flags.clinical_library_enabled} disabled={busy}
|
||||
onChange={e => toggle('clinical_library_enabled', e.target.checked)} />
|
||||
<span>
|
||||
<strong>Ground AI drafts in the clinical library</strong>
|
||||
<small>
|
||||
An educator drafting an article can ask for excerpts from the indexed
|
||||
library to write from. Needs the address below; the search does not
|
||||
leave this network.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{flags.clinical_library_enabled && (
|
||||
<div className="sp-field">
|
||||
<label htmlFor="mcp-url">Library address</label>
|
||||
<input id="mcp-url" value={values.clinical_mcp_url} disabled={busy}
|
||||
placeholder="http://mcp:8000/mcp"
|
||||
onChange={e => setValues(v => ({ ...v, clinical_mcp_url: e.target.value }))}
|
||||
onBlur={e => save('clinical_mcp_url', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="sp-switch">
|
||||
<input type="checkbox" checked={flags.pubmed_enabled} disabled={busy}
|
||||
onChange={e => toggle('pubmed_enabled', e.target.checked)} />
|
||||
<span>
|
||||
<strong>Let AI drafts cite PubMed</strong>
|
||||
<small>
|
||||
The topic is sent to NCBI and the papers found come back into the
|
||||
draft's reference list, written from the records rather than by the
|
||||
model. No key is needed; one raises the rate limit.
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{flags.pubmed_enabled && (
|
||||
<>
|
||||
<div className="sp-field">
|
||||
<label htmlFor="pubmed-key">NCBI API key <small>optional</small></label>
|
||||
<input id="pubmed-key" value={values.pubmed_api_key} disabled={busy}
|
||||
autoComplete="off" placeholder="Raises the limit from 3 to 10 requests a second"
|
||||
onChange={e => setValues(v => ({ ...v, pubmed_api_key: e.target.value }))}
|
||||
onBlur={e => save('pubmed_api_key', e.target.value)} />
|
||||
</div>
|
||||
<div className="sp-field">
|
||||
<label htmlFor="pubmed-email">Contact address <small>optional</small></label>
|
||||
<input id="pubmed-email" value={values.pubmed_contact_email} disabled={busy}
|
||||
autoComplete="off" placeholder="NCBI writes here before they block you"
|
||||
onChange={e => setValues(v => ({ ...v, pubmed_contact_email: e.target.value }))}
|
||||
onBlur={e => save('pubmed_contact_email', e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sso.configured && (
|
||||
<label className="sp-switch">
|
||||
<input type="checkbox" checked={flags.sso_only} disabled={busy}
|
||||
|
|
|
|||
|
|
@ -491,3 +491,18 @@
|
|||
border-left: 3px solid var(--primary); border-radius: 8px;
|
||||
}
|
||||
.article-ai-note .spinner { width: 14px; height: 14px; border-width: 2px; flex: none; }
|
||||
|
||||
/* What a draft may draw on. Checkboxes with the reason beside them, because
|
||||
"PubMed" alone does not tell an educator that the query leaves the building. */
|
||||
.ai-draw-on { margin: 12px 0 4px; padding: 0; border: 0; }
|
||||
.ai-draw-on legend {
|
||||
padding: 0; font-size: .78rem; font-weight: 700; letter-spacing: .04em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
}
|
||||
.ai-draw-on label {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
margin-top: 8px; cursor: pointer; font-size: .9rem;
|
||||
}
|
||||
.ai-draw-on input { margin-top: 3px; flex: none; }
|
||||
.ai-draw-on span { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ai-draw-on small { font-size: .78rem; line-height: 1.5; color: var(--text-muted); }
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ export default function ArticlesPage() {
|
|||
const [error, setError] = useState('')
|
||||
const [aiTopic, setAiTopic] = useState('')
|
||||
const [aiInstructions, setAiInstructions] = useState('')
|
||||
//: What a draft may draw on here, and what this educator has asked it to.
|
||||
//: Asked of the server rather than assumed, so a source nobody has set up
|
||||
//: is not offered as a switch that does nothing.
|
||||
const [aiSources, setAiSources] = useState({})
|
||||
const [useLibrary, setUseLibrary] = useState(false)
|
||||
const [usePubmed, setUsePubmed] = useState(false)
|
||||
const [aiStatus, setAiStatus] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -71,6 +77,13 @@ export default function ArticlesPage() {
|
|||
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAi || !user?.is_moderator) return
|
||||
api.get('/articles/ai-sources')
|
||||
.then(res => setAiSources(res.data || {}))
|
||||
.catch(() => setAiSources({}))
|
||||
}, [showAi, user?.is_moderator])
|
||||
|
||||
const create = async () => {
|
||||
setError('')
|
||||
if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return }
|
||||
|
|
@ -87,7 +100,10 @@ export default function ArticlesPage() {
|
|||
setError('')
|
||||
if (!aiTopic.trim()) { setError('Topic is required'); return }
|
||||
try {
|
||||
const res = await api.post('/articles/ai-draft', { topic: aiTopic, instructions: aiInstructions })
|
||||
const res = await api.post('/articles/ai-draft', {
|
||||
topic: aiTopic, instructions: aiInstructions,
|
||||
use_library: useLibrary, use_pubmed: usePubmed,
|
||||
})
|
||||
const startedAt = Date.now()
|
||||
setAiStatus('Drafting…')
|
||||
const poll = async () => {
|
||||
|
|
@ -145,6 +161,31 @@ export default function ArticlesPage() {
|
|||
<p className="articles-subtitle" style={{ marginBottom: 8 }}>Drafts stay private until you publish them. The model does not invent references.</p>
|
||||
<label className="form-label" htmlFor="ai-topic">Topic</label>
|
||||
<input id="ai-topic" className="input" value={aiTopic} onChange={e => setAiTopic(e.target.value)} placeholder="e.g. Febrile seizures" />
|
||||
{/* What to draw on besides the model's own knowledge. Only the
|
||||
sources this site actually has: a switch for a library nobody has
|
||||
configured is a switch that does nothing, and ticking it and
|
||||
waiting is the worst way to find that out. */}
|
||||
{(aiSources.library?.available || aiSources.pubmed?.available) && (
|
||||
<fieldset className="ai-draw-on">
|
||||
<legend>Draw on</legend>
|
||||
{aiSources.library?.available && (
|
||||
<label>
|
||||
<input type="checkbox" checked={useLibrary}
|
||||
onChange={e => setUseLibrary(e.target.checked)} />
|
||||
<span>{aiSources.library.label}
|
||||
<small>{aiSources.library.note}</small></span>
|
||||
</label>
|
||||
)}
|
||||
{aiSources.pubmed?.available && (
|
||||
<label>
|
||||
<input type="checkbox" checked={usePubmed}
|
||||
onChange={e => setUsePubmed(e.target.checked)} />
|
||||
<span>{aiSources.pubmed.label}
|
||||
<small>{aiSources.pubmed.note} References are written from the records, not by the model.</small></span>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
<label className="form-label" htmlFor="ai-instructions">Instructions (optional)</label>
|
||||
<textarea id="ai-instructions" className="input" rows={2} value={aiInstructions} onChange={e => setAiInstructions(e.target.value)} placeholder="Include an initial workup section" />
|
||||
{aiStatus && <p className="articles-subtitle" role="status">{aiStatus}</p>}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const apiError = (err, fallback) => {
|
|||
const BUCKETS = [
|
||||
{ key: 'awaiting_review', title: 'Waiting for review',
|
||||
blurb: 'Someone has finished with these and asked for a second pair of eyes.' },
|
||||
{ key: 'drafts', title: 'Drafts',
|
||||
blurb: 'Unfinished, and invisible to learners until published. Yours to pick up.' },
|
||||
{ key: 'machine_drafts', title: 'Generated, unread',
|
||||
blurb: 'Written from the library and never opened by a person. Not visible to learners.' },
|
||||
{ key: 'published_without_references', title: 'Published without sources',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import RichText from '../components/RichText'
|
||||
import ImageFigure from '../components/ImageFigure'
|
||||
|
|
@ -117,6 +117,11 @@ export default function FlashcardStudyPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* The way out, above the title and in the same words as everywhere
|
||||
else in the app — not a grey button at the end of a row of controls
|
||||
that change what you are studying. */}
|
||||
<Link to="/flashcards" className="articles-back">← Cards</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
|
|
@ -134,7 +139,6 @@ export default function FlashcardStudyPage() {
|
|||
)}
|
||||
<button className="btn btn-sm btn-secondary" onClick={shuffle}>Shuffle</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={reset}>Reset</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => navigate('/flashcards')}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
|
|
@ -181,7 +185,7 @@ export default function FlashcardStudyPage() {
|
|||
everything else here, so `[[264|respiratory failure]]`,
|
||||
`==key points==` and a figure all work on a card — which is
|
||||
most of what "link cards to things" turns out to mean. */}
|
||||
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
|
||||
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles previewLinks={false} />
|
||||
</p>
|
||||
{/* The picture on the card. A card could carry one — the column is
|
||||
there, the editor accepts one, the API returns it — and no view
|
||||
|
|
|
|||
9
frontend/src/pages/FlashcardsPage.css
Normal file
9
frontend/src/pages/FlashcardsPage.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* Correcting a card, in the row the card is in. */
|
||||
.fc-edit {
|
||||
margin: -4px 0 10px; padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.fc-edit-hint { margin: 0; font-size: .76rem; line-height: 1.6; color: var(--text-muted); }
|
||||
.fc-edit-hint code { font-size: .74rem; }
|
||||
.fc-edit-image { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||
|
|
@ -3,7 +3,9 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import RichText from '../components/RichText'
|
||||
import ImageFigure from '../components/ImageFigure'
|
||||
import ImagePicker from '../components/ImagePicker'
|
||||
import api from '../api/client'
|
||||
import './FlashcardsPage.css'
|
||||
|
||||
function StarRating({ rating, onRate, readonly = false }) {
|
||||
const [hover, setHover] = useState(0)
|
||||
|
|
@ -29,6 +31,14 @@ function StarRating({ rating, onRate, readonly = false }) {
|
|||
export default function FlashcardsPage() {
|
||||
const [tab, setTab] = useState('decks')
|
||||
const [making, setMaking] = useState(false)
|
||||
//: The card being corrected, and its fields while they are being typed. A
|
||||
//: card could be written and never afterwards fixed, which for study
|
||||
//: material written in a hurry is the wrong way round.
|
||||
const [editCard, setEditCard] = useState(null)
|
||||
const [editFront, setEditFront] = useState('')
|
||||
const [editBack, setEditBack] = useState('')
|
||||
const [editImage, setEditImage] = useState('')
|
||||
const [pickingFor, setPickingFor] = useState(null)
|
||||
const [newDeckTitle, setNewDeckTitle] = useState('')
|
||||
const [decks, setDecks] = useState([])
|
||||
const [categories, setCategories] = useState([])
|
||||
|
|
@ -54,6 +64,10 @@ export default function FlashcardsPage() {
|
|||
const [linkError, setLinkError] = useState('')
|
||||
const [cardLinks, setCardLinks] = useState({})
|
||||
const { user } = useAuth()
|
||||
//: Writing and filing cards is an educator's job. Everything that changes a
|
||||
//: deck or a card hangs off this one name, so there is a single place to
|
||||
//: read to know what a learner can do here: study, and nothing else.
|
||||
const educator = !!user?.is_moderator
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const LIMIT = 50
|
||||
|
|
@ -81,6 +95,25 @@ export default function FlashcardsPage() {
|
|||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const startEdit = (card) => {
|
||||
setEditCard(card)
|
||||
setEditFront(card.front || '')
|
||||
setEditBack(card.back || '')
|
||||
setEditImage(card.image_path || '')
|
||||
setLinkCardId(null)
|
||||
}
|
||||
|
||||
const saveCard = async () => {
|
||||
if (!editCard) return
|
||||
try {
|
||||
await api.put(`/flashcards/cards/${editCard.id}`, {
|
||||
front: editFront, back: editBack, image_path: editImage,
|
||||
})
|
||||
setEditCard(null)
|
||||
loadCards()
|
||||
} catch { /* the row is unchanged, which is the honest signal */ }
|
||||
}
|
||||
|
||||
const makeDeck = async () => {
|
||||
const title = newDeckTitle.trim()
|
||||
if (!title) return
|
||||
|
|
@ -244,7 +277,7 @@ export default function FlashcardsPage() {
|
|||
{/* Every deck used to come out of a model — generated from a
|
||||
document or an article — so an educator who wanted to write six
|
||||
cards by hand had nowhere to put them. */}
|
||||
{user?.is_moderator && (
|
||||
{educator && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setMaking(true)}>+ New deck</button>
|
||||
)}
|
||||
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>My Decks</button>
|
||||
|
|
@ -273,6 +306,10 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ImagePicker open={pickingFor !== null} title="Choose an image for this card"
|
||||
onClose={() => setPickingFor(null)}
|
||||
onPick={(path) => { setEditImage(path); setPickingFor(null) }} />
|
||||
|
||||
{/* Study card modal */}
|
||||
{studyCard && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
|
|
@ -286,7 +323,7 @@ export default function FlashcardsPage() {
|
|||
{/* Rendered, not printed: a card's faces are prose, so a
|
||||
cross-reference on one is a link here too rather than a pair
|
||||
of brackets and a number. */}
|
||||
<RichText value={flipped ? studyCard.back : studyCard.front} linkArticles />
|
||||
<RichText value={flipped ? studyCard.back : studyCard.front} linkArticles previewLinks={false} />
|
||||
{studyCard.image_path && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ImageFigure src={studyCard.image_path} alt={studyCard.front} />
|
||||
|
|
@ -309,6 +346,10 @@ export default function FlashcardsPage() {
|
|||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{decks.map(deck => (
|
||||
<div key={deck.id} className="card" style={{ padding: 20 }}>
|
||||
{/* Filing a deck is an educator's job, and a deck written
|
||||
from an article is filed where the article is. A learner
|
||||
studies decks; they do not curate them. */}
|
||||
{educator && (
|
||||
<div className="deck-category">
|
||||
<label>
|
||||
<span className="sr-only">Category for {deck.title}</span>
|
||||
|
|
@ -323,6 +364,7 @@ export default function FlashcardsPage() {
|
|||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{editingDeckId === deck.id ? (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<input
|
||||
|
|
@ -359,11 +401,17 @@ export default function FlashcardsPage() {
|
|||
) : null}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toggleShare(deck.id)}>
|
||||
{deck.is_shared ? 'Unshare' : 'Share'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setAddCardDeckId(deck.id); setCardError('') }}>Add card</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
|
||||
{educator && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toggleShare(deck.id)}>
|
||||
{deck.is_shared ? 'Unshare' : 'Share'}
|
||||
</button>
|
||||
)}
|
||||
{educator && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setAddCardDeckId(deck.id); setCardError('') }}>Add card</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{addCardDeckId === deck.id && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
|
|
@ -459,16 +507,48 @@ export default function FlashcardsPage() {
|
|||
<button className="btn btn-sm btn-danger" onClick={() => deleteCard(card.id)}>Yes</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(null)}>No</button>
|
||||
</div>
|
||||
) : (
|
||||
) : educator ? (
|
||||
<>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => startEdit(card)}
|
||||
aria-expanded={editCard?.id === card.id}>Edit</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => openLinks(card)}
|
||||
aria-expanded={linkCardId === card.id}>Links</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(card.id)}
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{editCard?.id === card.id && (
|
||||
<div className="fc-edit">
|
||||
<label className="form-label" htmlFor={`front-${card.id}`}>Front</label>
|
||||
<textarea id={`front-${card.id}`} className="input" rows={2} value={editFront}
|
||||
onChange={e => setEditFront(e.target.value)} />
|
||||
<label className="form-label" htmlFor={`back-${card.id}`}>Back</label>
|
||||
<textarea id={`back-${card.id}`} className="input" rows={4} value={editBack}
|
||||
onChange={e => setEditBack(e.target.value)} />
|
||||
<p className="fc-edit-hint">
|
||||
Markdown, and <code>{'[[264|label]]'}</code> links an article.
|
||||
<code>{'[[264#section|label]]'}</code> lands on one of its sections.
|
||||
</p>
|
||||
<div className="fc-edit-image">
|
||||
{editImage ? (
|
||||
<>
|
||||
<ImageFigure src={editImage} alt="Card image" />
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setEditImage('')}>Remove image</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setPickingFor(card.id)}>Add an image</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={saveCard}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditCard(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linkCardId === card.id && (
|
||||
<div style={{ background: 'var(--input-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: '0.85rem' }}>Linked questions</strong>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,19 @@ import FlashcardsPage from './FlashcardsPage'
|
|||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
|
||||
// Filing and writing are an educator's controls, so the page is mounted as one
|
||||
// by default; one test below swaps in a learner to check what they are *not*
|
||||
// offered.
|
||||
const auth = { user: { id: 1, name: 'Educator', role: 'moderator', is_moderator: true } }
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => auth }))
|
||||
|
||||
const card = { id: 7, deck_id: 2, deck_title: 'Neonatology', front: 'Front text', back: 'Back text' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
// Restored, because one test below signs in as a learner and the mock is
|
||||
// module-level: without this the tests after it inherit that.
|
||||
auth.user = { id: 1, name: 'Educator', role: 'moderator', is_moderator: true }
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/flashcards/') return Promise.resolve({ data: [] })
|
||||
if (url === '/flashcards/trash') return Promise.resolve({ data: [] })
|
||||
|
|
@ -72,6 +79,24 @@ it('uses the Cards label and honest empty deck state', async () => {
|
|||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/flashcards/cards/7/links/question/12'))
|
||||
})
|
||||
|
||||
it('offers a learner nothing to curate', async () => {
|
||||
// Cards are written by educators. A learner studies them, and the page
|
||||
// should not show them a filing dropdown, an Add card or a Delete for a
|
||||
// deck they cannot write to anyway.
|
||||
auth.user = { id: 1, name: 'Learner', role: 'user', is_moderator: false }
|
||||
api.get.mockImplementation(url => (url === '/flashcards/'
|
||||
? Promise.resolve({ data: [{ id: 2, title: 'Neonatology', card_count: 4, is_shared: 1 }] })
|
||||
: Promise.resolve({ data: [] })))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Neonatology')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '+ New deck' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add card' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/Category for/)).not.toBeInTheDocument()
|
||||
// Studying is still theirs.
|
||||
expect(screen.getByRole('link', { name: 'Study' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty linked state honestly', async () => {
|
||||
const original = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/flashcards/cards/7/links'
|
||||
|
|
|
|||
Loading…
Reference in a new issue