From af59fdb9609823ba11e213afa335671c58ed6819 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 17:31:53 +0200 Subject: [PATCH] fix: MinIO name collision, and refuse to write an article from fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MinIO was resolving to the wrong container Putting the backend on danvics_milvus to reach the clinical index gave it a second service called `minio`, and Docker resolved that one first. Every object read failed with InvalidAccessKeyId while the bucket simply looked empty — all 435 stem images unservable, and nothing in the logs saying why. The quiz MinIO now answers to `quiz-minio`, which nothing else on this host claims. A topic named after a shelf retrieved headings, not prose "Pediatric Pulmonology" returned ten chunks whose top hit was 29 characters — `**270** Pediatric Pulmonology`, an index line. Chapter titles rank well against a query that looks like a chapter title. The model was handed a prompt with citations and no content and said so, which was the correct response and read as a JSON failure. Two gates, both stated in the code. A chunk under 200 characters is a heading or a running header rather than something to write from. A topic whose passages total under 3,000 characters is skipped with the count in the reason, rather than asking a model to write a medical article out of fragments — it will either refuse or invent, and only one of those is visible. The 71 generated drafts are deleted at the user's request. Nothing linked to them and generation is resumable, so the cost was model calls rather than work. Question bank corrections, from the agent that ran alongside: 262 questions had OCR-mangled units repaired — `inEq/L`, `mrnol/L`, flattened `10⁹` superscripts and the rest — each with a version snapshot written first, so every edit is reversible from the existing question editor. 94 stem images that belonged to the explanation were removed; PREP's own `Item Q37A` / `Item C37B` labels turned out to be a far better signal than word cues, taking the confident split from 69/58/308 to 300/81/54. 13 uncertain images are listed for a person. 203 backend, 223 frontend green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/article_writer.py | 14 +- backend/app/services/clinical_library.py | 6 +- backend/scripts/triage_question_images.py | 273 +++++++++++++++++++++ backend/tests/test_bank_cleanup.py | 279 ++++++++++++++++++++++ docker-compose.yml | 8 + 5 files changed, 575 insertions(+), 5 deletions(-) create mode 100644 backend/scripts/triage_question_images.py create mode 100644 backend/tests/test_bank_cleanup.py diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py index 0dbe014..52ee1b0 100644 --- a/backend/app/services/article_writer.py +++ b/backend/app/services/article_writer.py @@ -29,7 +29,10 @@ from app.services.ai_service import _call_model, get_model_for_task logger = logging.getLogger(__name__) -PASSAGES = 10 +PASSAGES = 14 +# Enough retrieved prose to write from. Below this the model is being asked to +# write a medical article out of fragments, and it will either refuse or invent. +MIN_SOURCE_CHARS = 3000 # Long enough to be worth reading, short enough that nobody skims past the point. LONG_WORDS = 550 SHELF = "Pediatrics" @@ -134,10 +137,13 @@ def write_article(db: Session, topic: str, category_id: int | None = None, background run over hundreds of topics and wants to know what happened. """ passages = clinical_library.search(topic, limit=PASSAGES, folder_contains=SHELF) - if len(passages) < 3: + source_chars = sum(len(p["text"]) for p in passages) + if len(passages) < 3 or source_chars < MIN_SOURCE_CHARS: # Too little to ground an article. Writing one anyway would produce - # exactly the confident unverifiable prose this is designed to avoid. - return {"topic": topic, "status": "skipped", "reason": "not enough source material", + # exactly the confident unverifiable prose this is designed to avoid, + # and a topic named after a shelf rather than a condition lands here. + return {"topic": topic, "status": "skipped", + "reason": f"only {source_chars} chars from {len(passages)} passages", "passages": len(passages)} model_id, api_key = get_model_for_task(db, "extraction") diff --git a/backend/app/services/clinical_library.py b/backend/app/services/clinical_library.py index 79beab6..b1bc849 100644 --- a/backend/app/services/clinical_library.py +++ b/backend/app/services/clinical_library.py @@ -30,6 +30,10 @@ 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 TIMEOUT = 20 @@ -138,7 +142,7 @@ def search(query: str, limit: int = DEFAULT_LIMIT, except ValueError: raw = {} text = _tidy(raw.get("excerpt")) - if not text: + if len(text) < MIN_PROSE_CHARS: continue results.append({ "text": text[:SNIPPET_CHARS], diff --git a/backend/scripts/triage_question_images.py b/backend/scripts/triage_question_images.py new file mode 100644 index 0000000..e076269 --- /dev/null +++ b/backend/scripts/triage_question_images.py @@ -0,0 +1,273 @@ +"""Take the answer figure off the question stem. + +PDF extraction attached one image per question to `image_path`, which the quiz +player shows above the stem. For many questions that image is not the vignette's +photograph at all — it is the figure from the *explanation*, the annotated smear +or the labelled radiograph that gives the answer away. Showing it during a test +is a spoiler. + +Three groups, decided in this order: + + 1. the stem itself refers to a figure → the image belongs there, leave it; + 2. only the explanation refers to one → the image belongs to the explanation, + so it comes off the stem; + (PREP labels its figures by where they are printed — "Item Q37A" beside the + question, "Item C37B" beside the critique — which decides most of these;) + 3. neither says anything → ask a vision model what the picture + shows and which half of the question it illustrates. + +Removal means `image_path = NULL`. The image is not moved to +`explanation_image_path`: an unverified guess about where a figure belongs is +worth less than a clean stem, and the file itself is untouched in object +storage. Every removal snapshots the question first, through the same helper the +edit UI uses, so any of this can be restored from the question's version history. + + docker compose exec backend python -m scripts.triage_question_images + docker compose exec backend python -m scripts.triage_question_images --apply + + --no-vision only apply the deterministic rule, ask no model + --limit N classify at most N unknowns (for a spot check) + --model ID override the model (default: the configured extraction model) + --report PATH where the human-review list is written + +Anything the model is not confident about is left exactly as it is and listed in +the report, with the question id and the image path, for a person to decide. +""" +import argparse +import base64 +import json +import mimetypes +import re +import sys +from concurrent.futures import ThreadPoolExecutor + +import litellm + +from app.config import settings +from app.database import SessionLocal +from app.models.question import Question +from app.routers.questions import _snapshot_question +from app.services import storage_service +from app.services.ai_service import _proxy_model, get_model_for_task + +SCRIPT_EDITOR_ID = None + +# Does this text tell the reader to look at a picture? The stem list is the +# wider of the two on purpose: a stem that might need its figure keeps it. +STEM_CUE = re.compile( + r"figure|photograph|photo\b|image|picture|pictured|shown|depicted|illustrat|" + r"radiograph|x-ray|xray|roentgenogram|ultrasound|scan\b|smear|tracing|" + r"electrocardiogram|\becg\b|\bekg\b|\bmri\b|\bct\b|growth chart", + re.I) + +# Evidence that the figure is the explanation's — the same words. An +# explanation saying "the rash shown is typical of…" is describing a figure +# printed with the critique, not with the vignette. +EXPLANATION_CUE = STEM_CUE + +# The strongest signal in this bank, and one specific to PREP: figures are +# labelled by where they are printed. "Item Q37A" is a figure beside the +# question; "Item C37B" is a figure beside the critique. The stem citing an +# Item Q settles it on its own, and only a critique cites an Item C. +# "ltem" with a lowercase L is the scanner reading a capital I. +STEM_FIGURE_LABEL = re.compile(r"\b[Il]tem\s*Q\s*\d", re.I) +EXPLANATION_FIGURE_LABEL = re.compile(r"\b[Il]tem\s*C\s*\d", re.I) + +# How sure the model has to be before an image is removed on its say-so. Set +# from a spot check of the replies: every 0.95 verdict held up against the +# picture, while one of the three 0.85s was wrong — it called an unlabelled +# photograph of the vignette's own rash an explanation figure. Below the bar +# the question is left untouched and goes on the report for a person to read. +MIN_CONFIDENCE = 0.9 + +PROMPT = """You are auditing a pediatric board-exam question. + +The image above was attached to this question during PDF extraction. It may +belong to the QUESTION STEM (a photograph the examinee must look at to answer) +or to the EXPLANATION (a figure printed with the answer critique, which would +give the answer away if shown during the test). + +QUESTION STEM: +{stem} + +EXPLANATION: +{explanation} + +Decide which one the image illustrates. Weigh these signals: +- Does the stem describe a finding that this image shows? Then it is "stem". +- Is the image annotated, labelled, captioned, a diagram, a table, an algorithm, + a graph, or does it show the diagnosis being named? Then it is "explanation". +- Is it a page header, logo, decorative rule, or blank? Then it is "neither". + +Answer with ONLY this JSON, no markdown fence and no preamble: +{{"belongs": "stem" | "explanation" | "neither" | "unclear", + "confidence": <0.0-1.0>, + "reason": ""}}""" + + +def image_bytes(key: str) -> tuple[bytes, str] | None: + """The image and the media type a vision model will accept, or None.""" + data = storage_service.s3_object(key) or storage_service.load(key) + if not data: + return None + media_type = mimetypes.guess_type(key)[0] or "image/jpeg" + if key.lower().endswith((".jpx", ".jp2")) or media_type in ("image/jp2", "image/jpx"): + # JPEG 2000 is not accepted by the vision APIs; PyMuPDF is already a + # dependency of the PDF pipeline and re-encodes it without a new one. + try: + import fitz + + return fitz.Pixmap(data).tobytes("png"), "image/png" + except Exception: + return None + return data, media_type + + +def classify(stem: str, explanation: str, key: str, model: str, + api_key: str | None) -> dict: + """Ask the vision model where the image belongs. Never raises.""" + loaded = image_bytes(key) + if not loaded: + return {"belongs": "unclear", "confidence": 0.0, "reason": "image could not be read"} + data, media_type = loaded + + kwargs = { + "model": _proxy_model(model), + "temperature": 0, + "messages": [{"role": "user", "content": [ + {"type": "image_url", "image_url": { + "url": f"data:{media_type};base64,{base64.b64encode(data).decode()}"}}, + {"type": "text", "text": PROMPT.format( + stem=(stem or "")[:4000] or "(empty)", + explanation=(explanation or "")[:4000] or "(empty)")}, + ]}], + } + if api_key or settings.LITELLM_API_KEY: + kwargs["api_key"] = api_key or settings.LITELLM_API_KEY + if settings.LITELLM_API_BASE: + kwargs["api_base"] = settings.LITELLM_API_BASE + + try: + text = litellm.completion(**kwargs).choices[0].message.content or "" + except Exception as error: # a failed call must not decide anything + return {"belongs": "unclear", "confidence": 0.0, "reason": f"model error: {error}"[:160]} + + match = re.search(r"\{.*\}", text, re.S) + if not match: + return {"belongs": "unclear", "confidence": 0.0, "reason": "unparseable reply"} + try: + answer = json.loads(match.group(0)) + except json.JSONDecodeError: + return {"belongs": "unclear", "confidence": 0.0, "reason": "unparseable reply"} + return { + "belongs": str(answer.get("belongs", "unclear")).lower(), + "confidence": float(answer.get("confidence") or 0.0), + "reason": str(answer.get("reason", ""))[:200], + } + + +def deterministic_verdict(stem: str, explanation: str) -> str: + """"keep" when the stem needs the figure, "remove" when only the explanation + mentions one, "unknown" when neither says anything.""" + stem, explanation = stem or "", explanation or "" + if STEM_FIGURE_LABEL.search(stem) or STEM_CUE.search(stem): + return "keep" + if EXPLANATION_FIGURE_LABEL.search(explanation) or EXPLANATION_CUE.search(explanation): + return "remove" + return "unknown" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="write the changes") + parser.add_argument("--no-vision", action="store_true", + help="deterministic rule only; ask no model") + parser.add_argument("--limit", type=int, default=0, help="classify at most N unknowns") + parser.add_argument("--model", help="vision model id (default: the extraction model)") + parser.add_argument("--workers", type=int, default=4, help="parallel vision calls") + parser.add_argument("--report", default="image_triage_report.txt", + help="where the human-review list is written") + args = parser.parse_args() + + db = SessionLocal() + try: + questions = db.query(Question).filter( + Question.image_path.isnot(None), Question.image_path != "" + ).order_by(Question.id).all() + + keep, remove, unknown = [], [], [] + for question in questions: + verdict = deterministic_verdict(question.question_text, question.explanation) + {"keep": keep, "remove": remove, "unknown": unknown}[verdict].append(question) + + print(f"stem images : {len(questions)}") + print(f" stem names a figure : {len(keep)} (left alone)") + print(f" only the explanation : {len(remove)} (remove)") + print(f" neither : {len(unknown)}") + + judged: dict[int, dict] = {} + if unknown and not args.no_vision: + model = args.model or get_model_for_task(db, "extraction")[0] + api_key = None if args.model else get_model_for_task(db, "extraction")[1] + batch = unknown[: args.limit] if args.limit else unknown + print(f"\n asking {model} about {len(batch)} image(s)…") + payload = [(q.id, q.question_text, q.explanation, q.image_path) for q in batch] + with ThreadPoolExecutor(max_workers=args.workers) as pool: + results = list(pool.map( + lambda row: (row[0], classify(row[1], row[2], row[3], model, api_key)), + payload)) + judged = dict(results) + + # Only a confident "explanation" removes an image. "neither" is a page + # header or a logo — junk, but junk that gives nothing away, so it stays + # for a person to look at rather than being deleted on a guess. + by_id = {q.id: q for q in unknown} + vision_removals, uncertain = [], [] + for qid, answer in judged.items(): + if answer["belongs"] == "explanation" and answer["confidence"] >= MIN_CONFIDENCE: + vision_removals.append((by_id[qid], answer)) + elif answer["belongs"] != "stem": + uncertain.append((by_id[qid], answer)) + + if judged: + counts: dict[str, int] = {} + for answer in judged.values(): + counts[answer["belongs"]] = counts.get(answer["belongs"], 0) + 1 + print(" model verdicts : " + + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) + print(f" confident enough to act : {len(vision_removals)}") + print(f" for human review : {len(uncertain)}") + + removals = [(q, {"reason": "only the explanation mentions a figure"}) for q in remove] + removals += vision_removals + + for question, answer in removals: + print(f" remove q{question.id} {question.image_path} — {answer['reason']}") + if args.apply: + _snapshot_question(db, question, SCRIPT_EDITOR_ID) + question.image_path = None + if args.apply: + db.commit() + + if uncertain: + lines = ["Questions whose stem image could not be classified confidently.", + "The image is still attached to the stem; decide by hand.", + ""] + lines += [f"question {q.id}\t{q.image_path}\t{a['belongs']} " + f"({a['confidence']:.2f})\t{a['reason']}" for q, a in uncertain] + with open(args.report, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + print(f"\n {len(uncertain)} left for review -> {args.report}") + + print("\n" + ("APPLIED" if args.apply else "DRY RUN")) + print(f" images removed from stems : {len(removals)}") + print(f" images kept : {len(questions) - len(removals)}") + if not args.apply: + print("\n Re-run with --apply to write these changes.") + return 0 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_bank_cleanup.py b/backend/tests/test_bank_cleanup.py new file mode 100644 index 0000000..184667d --- /dev/null +++ b/backend/tests/test_bank_cleanup.py @@ -0,0 +1,279 @@ +"""The two bank-repair scripts: OCR units, lab tables, and stem-image triage. + +Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests. +No network and no AI: the vision classifier is never reached, only the +deterministic rule that decides most of the images on the text alone. +""" +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") + +import sys +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata. +from app.models.media import MediaAsset # noqa — health report walks every embeddable table. +from app.models.question import Question, QuestionVersion +from app.models.user import User +from scripts import fix_lab_formatting as labs +from scripts import triage_question_images as images + +# Question 3333 as it came out of the scanner: two mangled units, a lost +# superscript, and a per-decilitre spelling of a per-litre value. +MANGLED_STEM = ( + "Laboratory studies demonstrate the following: " + "Complete blood cell count, within normal limits; " + "White blood cell count, 3,500/µL (3.5 x 109/L); " + "Hemoglobin, 13.5 g/dL (135 g/L); " + "Chloride, 105 inEq/L (105 mrnol/L); " + "Phosphorus, 1.8 mg/dL (0.58 mmol/dL); " + "Glucose, 115 mg/dL (6.4 mmol/dL). " + "Of the following, you are MOST likely to recommend") + + +class UnitRepairTests(unittest.TestCase): + def test_the_repairs_fire_on_real_mangled_input(self): + fixed, fired = labs.repair_units(MANGLED_STEM) + self.assertIn("105 mEq/L (105 mmol/L)", fixed) + self.assertIn("0.58 mmol/L", fixed) + self.assertIn("6.4 mmol/L", fixed) + self.assertIn("3.5 × 10⁹/L", fixed) + self.assertEqual(len(fired), 4) + # Everything that was already right is untouched. + self.assertIn("13.5 g/dL (135 g/L)", fixed) + self.assertIn("3,500/µL", fixed) + + def test_repairs_are_idempotent(self): + once, _ = labs.repair_units(MANGLED_STEM) + twice, fired = labs.repair_units(once) + self.assertEqual(once, twice) + self.assertEqual(fired, []) + + def test_only_real_powers_of_ten_get_a_superscript(self): + # 10³, 10⁶, 10⁹ and 10¹² name units; "10¹" is a lost digit, not a lost + # superscript, and inventing one would invent a number. + for source, expected in [ + ("Platelets, 86 x 103/µL", "86 × 10³/µL"), + ("Red blood cells, 4.5 x 106/µL", "4.5 × 10⁶/µL"), + ("Red blood cells, 4.5 x 1012/L", "4.5 × 10¹²/L"), + ]: + self.assertIn(expected, labs.repair_units(source)[0], source) + unchanged = "White blood cell count, 10.0 x 101/µL" + self.assertEqual(labs.repair_units(unchanged)[0], unchanged) + # A count that merely happens to read "100/µL" is not a power of ten. + plain = "Eosinophils, 100/µL" + self.assertEqual(labs.repair_units(plain)[0], plain) + + def test_conservative_about_ambiguous_units(self): + # "3,000/mL" is almost certainly "/µL" and "10A" a mangled exponent, + # but neither correction is certain, so neither is made. The lost + # superscript beside them is unambiguous and is still fixed. + out, _ = labs.repair_units("White blood cells 3,000/mL (3 x 109/L)") + self.assertEqual(out, "White blood cells 3,000/mL (3 × 10⁹/L)") + for source in ["White blood cell count of 14,600/µL (14.6 x 10A)", + "a 4-year-old boy", "the 2013 guideline", + "Creatinine, 1.0 mg/dL (88.4 mcmol/L)"]: + self.assertEqual(labs.repair_units(source)[0], source, source) + + +class LabTableTests(unittest.TestCase): + def test_a_full_panel_becomes_a_table(self): + fixed, _ = labs.repair_units(MANGLED_STEM) + out, panels = labs.tabulate_panels(fixed) + self.assertEqual(panels, 1) + self.assertIn("| Test | Result | SI units |", out) + self.assertIn("| Chloride | 105 mEq/L | 105 mmol/L |", out) + self.assertIn("| Complete blood cell count | within normal limits | |", out) + # The prose on either side survives, and the table stands apart from it. + self.assertTrue(out.startswith("Laboratory studies demonstrate the following:")) + self.assertTrue(out.rstrip().endswith("you are MOST likely to recommend")) + self.assertIn("\n\n|", out) + + def test_tabulating_is_idempotent(self): + once, _ = labs.tabulate_panels(labs.repair_units(MANGLED_STEM)[0]) + twice, panels = labs.tabulate_panels(once) + self.assertEqual((twice, panels), (once, 0)) + + def test_a_differential_is_left_as_prose(self): + # "29% segmented neutrophils, 28% bands" reads the other way round; + # pairing name with value here would shift every result by one. + text = ("Laboratory data shows: Differential count, 29% segmented neutrophils, " + "28% bands, 30% lymphocytes, 11% monocytes, 2% eosinophils") + self.assertEqual(labs.tabulate_panels(text), (text, 0)) + + def test_a_sentence_of_vital_signs_is_left_as_prose(self): + text = ("On examination, his temperature is 37.2°C, heart rate is 96 beats/min, " + "and respiratory rate is 18 breaths/min. The boy appears ill.") + self.assertEqual(labs.tabulate_panels(text), (text, 0)) + + def test_a_short_run_is_left_as_prose(self): + text = "Initial studies show: Sodium, 139 mEq/L; Potassium, 3.5 mEq/L. He is admitted." + self.assertEqual(labs.tabulate_panels(text), (text, 0)) + + def test_a_reference_range_annotates_the_result_above_it(self): + text = ("Laboratory tests produce the following results: " + "Hemoglobin, 12.8 g/dL (128 g/L); " + "White blood cell count, 6,500/µL (6.5 × 10⁹/L); " + "Aspartate aminotransferase, 125 U/L; reference range, ≤40 U/L; " + "Alanine aminotransferase, 35 U/L; reference range, ≤30 U/L; " + "Total bilirubin, 2.0 mg/dL (34.2 µmol/L). Results are normal.") + out, panels = labs.tabulate_panels(text) + self.assertEqual(panels, 1) + self.assertIn("| Aspartate aminotransferase | 125 U/L (reference range, ≤40 U/L) |", out) + self.assertNotIn("| reference range |", out) + + def test_an_si_column_only_holds_a_single_value(self): + items = [("Sodium", "139 mEq/L", "139 mmol/L")] + self.assertIn("| Sodium | 139 mEq/L | 139 mmol/L |", labs.render_table(items)) + # A differential in brackets opens with a digit too, but it is not SI. + text = ("Results: White blood cells, 147/µL (4% segmented, 83% lymphocytes); " + "Red blood cells, 10/µL; Glucose, 25 mg/dL (1.4 mmol/L); " + "Protein, 179 mg/dL. Cultures are pending.") + out, _ = labs.tabulate_panels(text) + self.assertIn("| White blood cells | 147/µL (4% segmented, 83% lymphocytes) | |", out) + + +class SqliteBankTests(unittest.TestCase): + """A disposable bank, with the scripts pointed at it instead of the real DB.""" + + def setUp(self): + self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, + poolclass=StaticPool) + Base.metadata.create_all(self.engine) + self.db = Session(self.engine) + self.db.add(User(id=1, name="Mod", email="mod@example.test", + hashed_password="unused", role="moderator")) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def add_question(self, **fields): + question = Question(question_type="mcq", options=["a", "b"], correct_answer="a", + is_shared=1, **fields) + self.db.add(question) + self.db.commit() + return question + + def run_script(self, module, *argv): + with patch.object(module, "SessionLocal", lambda: self.db), \ + patch.object(sys, "argv", ["script", *argv]): + return module.main() + + +class LabScriptTests(SqliteBankTests): + def test_an_edit_snapshots_the_stem_before_changing_it(self): + question = self.add_question(id=1, question_text=MANGLED_STEM, + explanation="Refeeding syndrome.") + self.assertEqual(self.run_script(labs, "--apply"), 0) + + versions = self.db.query(QuestionVersion).filter_by(question_id=1).all() + self.assertEqual(len(versions), 1) + # The snapshot holds the text as it was, so the edit can be undone. + self.assertEqual(versions[0].snapshot["question_text"], MANGLED_STEM) + stem = self.db.get(Question, 1).question_text + self.assertIn("105 mEq/L (105 mmol/L)", stem) + self.assertNotIn("inEq", stem) + + def test_a_dry_run_writes_nothing(self): + self.add_question(id=1, question_text=MANGLED_STEM) + self.assertEqual(self.run_script(labs), 0) + self.assertEqual(self.db.query(QuestionVersion).count(), 0) + self.assertEqual(self.db.get(Question, 1).question_text, MANGLED_STEM) + + def test_a_clean_stem_is_neither_touched_nor_versioned(self): + clean = "A 5-year-old girl has a temperature of 38.5°C and a heart rate of 120 beats/min." + self.add_question(id=1, question_text=clean) + self.assertEqual(self.run_script(labs, "--apply"), 0) + self.assertEqual(self.db.query(QuestionVersion).count(), 0) + self.assertEqual(self.db.get(Question, 1).question_text, clean) + + def test_tables_are_opt_in(self): + self.add_question(id=1, question_text=MANGLED_STEM) + self.run_script(labs, "--apply") + self.assertNotIn("| Test |", self.db.get(Question, 1).question_text) + self.run_script(labs, "--apply", "--tables") + self.assertIn("| Test | Result | SI units |", self.db.get(Question, 1).question_text) + + +class ImageTriageRuleTests(unittest.TestCase): + def test_the_rule_fires_only_when_the_explanation_alone_names_a_figure(self): + verdict = images.deterministic_verdict + self.assertEqual(verdict("The rash shown below is on his trunk.", + "The photograph shows erythema migrans."), "keep") + self.assertEqual(verdict("He has a rash on his trunk.", + "The photograph shows erythema migrans."), "remove") + self.assertEqual(verdict("He has a rash on his trunk.", + "Erythema migrans is the classic finding."), "unknown") + # A stem that names a figure keeps its image even when the critique + # names one too — both halves have a figure, and only one is attached. + self.assertEqual(verdict("The lesion pictured is tender.", + "The figure shows the same lesion."), "keep") + + def test_prep_figure_labels_decide_where_the_figure_was_printed(self): + verdict = images.deterministic_verdict + # "Item Q22" is printed beside the question, "Item C28" beside the critique. + self.assertEqual(verdict("The ankle is in a varus position (Item Q22).", + "Clubfoot is treated with casting."), "keep") + self.assertEqual(verdict("A mother presents with premature rupture of membranes.", + "Survival rises with antenatal steroids (Item C28)."), "remove") + # The scanner reads a capital I as a lowercase L. + self.assertEqual(verdict("The rash is widespread (ltem Q37A).", ""), "keep") + + def test_empty_text_is_never_a_removal(self): + self.assertEqual(images.deterministic_verdict("", ""), "unknown") + self.assertEqual(images.deterministic_verdict(None, None), "unknown") + + +class ImageTriageScriptTests(SqliteBankTests): + def test_only_the_explanation_only_case_loses_its_image(self): + spoiler = self.add_question( + id=1, question_text="He has a rash on his trunk.", + explanation="The photograph shows erythema migrans.", + image_path="images/doc_5/page_26_img_0.jpeg") + needed = self.add_question( + id=2, question_text="The rash shown below is on his trunk.", + explanation="Erythema migrans.", image_path="images/doc_5/page_27_img_0.jpeg") + unknown = self.add_question( + id=3, question_text="He has a rash.", explanation="Erythema migrans.", + image_path="images/doc_5/page_28_img_0.jpeg") + + spoiler_id, needed_id, unknown_id = spoiler.id, needed.id, unknown.id + self.assertEqual(self.run_script(images, "--apply", "--no-vision"), 0) + paths = {q.id: q.image_path for q in self.db.query(Question).all()} + self.assertIsNone(paths[spoiler_id]) + self.assertEqual(paths[needed_id], "images/doc_5/page_27_img_0.jpeg") + # Without the model there is nothing to say about the unknown, so it stays. + self.assertEqual(paths[unknown_id], "images/doc_5/page_28_img_0.jpeg") + # The removal is undoable: the path is in the snapshot. + version = self.db.query(QuestionVersion).filter_by(question_id=1).one() + self.assertEqual(version.snapshot["image_path"], "images/doc_5/page_26_img_0.jpeg") + + def test_a_dry_run_removes_nothing(self): + question = self.add_question( + id=1, question_text="He has a rash.", + explanation="The photograph shows erythema migrans.", + image_path="images/doc_5/page_26_img_0.jpeg") + question_id = question.id + self.assertEqual(self.run_script(images, "--no-vision"), 0) + self.assertEqual(self.db.get(Question, question_id).image_path, + "images/doc_5/page_26_img_0.jpeg") + self.assertEqual(self.db.query(QuestionVersion).count(), 0) + + def test_rerunning_changes_nothing_further(self): + self.add_question(id=1, question_text="He has a rash.", + explanation="The photograph shows erythema migrans.", + image_path="images/doc_5/page_26_img_0.jpeg") + self.run_script(images, "--apply", "--no-vision") + self.run_script(images, "--apply", "--no-vision") + self.assertEqual(self.db.query(QuestionVersion).count(), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/docker-compose.yml b/docker-compose.yml index 0099b88..07dfd21 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,14 @@ services: MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD} volumes: - minio_data:/data + networks: + default: + # An unambiguous name. The backend also sits on danvics_milvus, which + # has a MinIO of its own called `minio`, and Docker resolved that one + # first — every object read failed with InvalidAccessKeyId while the + # bucket looked simply empty. + aliases: + - quiz-minio # No host ports: the backend reaches MinIO over the compose network, and # 9000/9001 are already taken on this host. Publish deliberately if the # console is ever needed from outside.