pdf-quiz-generator/backend/app/services/clinical_corpus.py
Daniel febb14490c
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
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
2026-09-13 02:44:42 +02:00

171 lines
6.5 KiB
Python

"""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