**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
171 lines
6.5 KiB
Python
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
|