pdf-quiz-generator/backend/app/services/clinical_library.py
Daniel e1b580386c feat: short / long / clinical, and stop broad topics retrieving index lines
Renames the middle view to Short and puts it first: it is the quickest way to
tell whether this is the article you wanted, and the full text is one click
away. Existing generated articles were migrated in place.

The prompt now asks for bullets that each carry a fact, because "X is important
to recognise" is a bullet that survives revision and teaches nothing.

The retrieval bug that made the last run mostly skips
The prose filter — drop chunks under 200 characters, since they are headings and
index lines — ran *after* taking the top fourteen hits. A broad query like
"Immunodeficiency" or a specialty name matches chapter titles first, so all
fourteen were index lines and the filter left nothing: the topic was skipped as
having no source material when the library holds plenty. Retrieval now asks for
five times what it needs and keeps the first passages that are actually prose.
Immunodeficiency went from 0 passages to 14, Pediatric Cardiology 0 to 14.

That is the same mistake the folder filter has a comment warning about — filter
inside the ranking, not after it — made two functions later.

Two things I got wrong and corrected rather than worked around: a `LIKE
'%key_points%'` check reported the migration had failed, when `_` is a
single-character wildcard and it was matching the title "Key points"; and a
variant count showing no Short sections was taken against the old image, where
Short was not yet a known variant and was being coerced to Long.

203 backend, 234 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:21:21 +02:00

184 lines
6.9 KiB
Python

"""Read-only retrieval from the clinical library index.
The index is a Milvus collection of ~1.8M chunks from the reference library —
Nelson, Harrison's, Mandell's and the rest — embedded with bge-m3 at 1024
dimensions. PedsHub already embeds with the same model through the same LiteLLM
proxy, so a query vector generated here is directly comparable to what is
stored there; no second embedder and no re-indexing.
Two things this module will not do, deliberately:
* **It never writes.** The credentials are a query user and the collection
belongs to another system. Articles are grounded in what comes back; nothing
flows the other way.
* **It never hands back long verbatim passages for publication.** What it
returns is source material for someone to write *from*, plus the citation
needed to say where a fact came from. Copying a textbook into an article and
publishing it is redistribution, whatever produced the copy.
"""
import json
import logging
import re
import urllib.parse
import requests
from app.config import settings
logger = logging.getLogger(__name__)
# Enough context to write a section from, without turning a prompt into a book.
SNIPPET_CHARS = 1200
DEFAULT_LIMIT = 12
# Below this a chunk is a heading, an index line or a running header, not prose.
# They rank well against a query that looks like a chapter title and then fill
# the shortlist with nothing — a topic named after a shelf retrieves ten of them.
MIN_PROSE_CHARS = 200
# Ask for more than we need, because the prose filter runs after ranking. A
# broad query — "Immunodeficiency", a specialty name — matches chapter titles
# and index lines first, and filtering inside a shortlist of fourteen left
# nothing at all.
POOL_MULTIPLIER = 5
TIMEOUT = 20
def _call(path: str, body: dict) -> dict | None:
"""Milvus over its HTTP API rather than gRPC.
The gRPC client drags in a protobuf and grpcio version surface that fights
with what this image already pins for other services. HTTP has neither
problem and this module only ever reads.
"""
if not settings.CLINICAL_MILVUS_URI:
return None
try:
response = requests.post(
f"{settings.CLINICAL_MILVUS_URI.rstrip('/')}{path}",
headers={"Authorization": f"Bearer {settings.CLINICAL_MILVUS_TOKEN}",
"Content-Type": "application/json"},
json=body, timeout=TIMEOUT,
)
except requests.RequestException:
logger.warning("Clinical library unreachable", exc_info=True)
return None
if response.status_code != 200:
logger.warning("Clinical library refused %s: %s %s", path,
response.status_code, response.text[:200])
return None
payload = response.json()
if payload.get("code") not in (0, None):
logger.warning("Clinical library error on %s: %s", path, str(payload)[:200])
return None
return payload
def available() -> bool:
"""Whether the index can be reached at all, for an honest error upstream."""
payload = _call("/v2/vectordb/collections/list", {})
return bool(payload) and settings.CLINICAL_MILVUS_COLLECTION in (payload.get("data") or [])
def _tidy(value: str | None) -> str:
"""File paths in this index are URL-encoded; a citation should not be."""
if not value:
return ""
return re.sub(r"\s+", " ", urllib.parse.unquote(value)).strip()
def _source_of(payload: dict) -> dict:
"""The bibliographic details, separated from the prose."""
title = _tidy(payload.get("title")) or _tidy(payload.get("file_path")).rsplit("/", 1)[-1]
# Library filenames carry the shop they came from; a reference should not.
title = re.sub(r"\s*\((?:z-library|1lib|z-lib)[^)]*\)", "", title, flags=re.I).strip()
title = re.sub(r"\.pdf$", "", title, flags=re.I).strip()
return {
"title": title,
"author": _tidy(payload.get("author")),
"page": payload.get("page_number"),
"specialty": _tidy(payload.get("subcategory")),
}
def search(query: str, limit: int = DEFAULT_LIMIT,
folder_contains: str | None = None) -> list[dict]:
"""Passages relevant to a topic, each with where it came from.
`folder_contains` narrows to one shelf of the library — passing "Pediatrics"
is how an article about a childhood condition is grounded in the paediatric
texts rather than in the adult ones that outnumber them.
"""
query = (query or "").strip()
if not query:
return []
from app.services.embedding_service import generate_embedding
vector = generate_embedding(query)
if not vector:
logger.warning("No query embedding; cannot search the clinical library")
return []
body = {
"collectionName": settings.CLINICAL_MILVUS_COLLECTION,
"data": [vector],
"annsField": "dense",
"limit": limit * POOL_MULTIPLIER,
"outputFields": ["payload", "folder_path", "file_path", "chunk_index"],
}
# Filtering in the query is what keeps the shelf constraint honest: taking
# the top hits and discarding the wrong folder afterwards would silently
# return fewer results the more specific the request was.
if folder_contains:
safe = folder_contains.replace('"', "")
body["filter"] = f'folder_path like "%{safe}%"'
payload = _call("/v2/vectordb/entities/search", body)
if not payload:
return []
results = []
for hit in (payload.get("data") or []):
raw = hit.get("payload") or {}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except ValueError:
raw = {}
text = _tidy(raw.get("excerpt"))
if len(text) < MIN_PROSE_CHARS:
continue
results.append({
"text": text[:SNIPPET_CHARS],
"score": float(hit.get("distance", 0.0)),
"source": _source_of(raw),
"folder": _tidy(hit.get("folder_path")),
})
if len(results) >= limit:
break
return results
def references_from(passages: list[dict], limit: int = 8) -> list[dict]:
"""One entry per book, with the pages that were actually used.
Deduplicated by title because forty chunks of Nelson is one reference, not
forty — a reference list that repeats a book is noise standing where
provenance should be.
"""
by_title: dict[str, dict] = {}
for passage in passages:
source = passage.get("source") or {}
title = source.get("title")
if not title:
continue
entry = by_title.setdefault(title, {
"title": title, "author": source.get("author") or None, "pages": [],
})
page = source.get("page")
if isinstance(page, int) and page not in entry["pages"]:
entry["pages"].append(page)
for entry in by_title.values():
entry["pages"] = sorted(entry["pages"])[:6]
return list(by_title.values())[:limit]