From 5544e79e69e2e6db17d2e778fd8929186d30f948 Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Fri, 23 Oct 2026 08:27:29 -0400 Subject: [PATCH] ADD: pick the CNE model from the notes type and fetch both fine-tuned models from Hugging Face --- scripts/data-manifest.yml | 8 +- tools/CNE/README.md | 13 +- tools/CNE/src/sadt_cne/__init__.py | 27 +++- tools/CNE/src/sadt_cne/extraction.py | 29 +++++ tools/CNE/src/sadt_cne/layout.py | 2 +- tools/CNE/tests/test_run.py | 186 +++++++++++++++++---------- 6 files changed, 181 insertions(+), 84 deletions(-) diff --git a/scripts/data-manifest.yml b/scripts/data-manifest.yml index c162c27..ac9c658 100644 --- a/scripts/data-manifest.yml +++ b/scripts/data-manifest.yml @@ -865,12 +865,12 @@ tools: extract: true # ========================================================================= - # CNE -- clinical note engine. NOT PORTED YET. + # CNE -- clinical note engine. LIVE on the server. # # The only tool whose weights are NOT on GitHub: two quantized GGUF LLMs on - # Hugging Face, in the DCBIA-OrthoLab organization, pulled by - # CNE.getModelPath(). Its test files are bundled in the client repo - # (CNE/Resources/testfiles), so there is nothing to fetch. + # Hugging Face, in the DCBIA-OrthoLab organization, which the former client + # module downloaded itself (CNE.getModelPath()). Each lands in a folder named + # after its note type, which is what the tool reads when no model is named. # # The Ortho repository only ever held model-q4_k_m.gguf; the # model-q4_0.gguf the client used to ask for never existed. diff --git a/tools/CNE/README.md b/tools/CNE/README.md index dd6b286..ae1bb87 100644 --- a/tools/CNE/README.md +++ b/tools/CNE/README.md @@ -27,8 +27,8 @@ For each note found under `notes`: |---|---| | `notes` | one note, or a folder of them. Searched recursively. | | `notes_type` | `TMJ` or `Ortho`. Selects the system prompt and the context window together. | -| `model` | the hosted GGUF bundle, picked by name from `DATA/CNE/models/`. | | `output_dir` | filled in by the server: the job's own output folder. | +| `model` | the hosted GGUF bundle, picked by name from `DATA/CNE/models/`. Left empty, the bundle named after `notes_type`. | | `max_tokens` | the longest answer per note (default 2048). | | `temperature` | default `0.0`, so the same note extracts to the same answer twice. | | `context_tokens` | `0` means the note type's default (TMJ 6144, Ortho 2048). | @@ -39,6 +39,15 @@ For each note found under `notes`: publish it as a **name a client picks from what this server hosts** -- never a file a clinician can upload, and never a download at run time. +It is optional. Left empty -- "(automatic)" in a panel -- the run uses +`DATA/CNE/models//`, the bundle fine-tuned for that note type. That +is how upstream's panel worked (it asked for the note type alone), and it keeps +the Ortho weights from being paired with the TMJ prompt by a slip of the model +dropdown. No such bundle staged is the deployment's to fix and answers 503. + +`notes` publishes the extensions its readers read (`ACCEPTS`), so a client's +file dialog offers notes rather than scans. + ## Provenance Ported from DCBIA-OrthoLab/SlicerAutomatedDentalTools: `CNE_CLI/CNE_CLI.py` @@ -216,7 +225,7 @@ depend on sort order. ```bash cd tools/CNE uv sync --all-groups -uv run pytest # 122 tests, no weights, no GPU +uv run pytest # 165 tests, no weights, no GPU uv run pytest -m models -o addopts= # needs CNE_MODEL= .venv/bin/python -m pyflakes src tests .venv/bin/python ../../scripts/describe.py . # the schema the server publishes diff --git a/tools/CNE/src/sadt_cne/__init__.py b/tools/CNE/src/sadt_cne/__init__.py index c9f7a02..104b932 100644 --- a/tools/CNE/src/sadt_cne/__init__.py +++ b/tools/CNE/src/sadt_cne/__init__.py @@ -16,6 +16,7 @@ build_messages, complete, context_for, + default_model, load_model, model_type_hint, parse_extraction, @@ -42,19 +43,29 @@ REPORT_NAME = "CNE_report.json" +# What `notes` can be, published so a client's file dialog offers notes and +# not scans. A folder still travels as a .zip, which the server accepts for any +# path argument on its own. +ACCEPTS = {"notes": NOTE_EXTENSIONS} + __all__ = ["run"] def run( notes: Path, notes_type: Literal["TMJ", "Ortho"], - model: Path, output_dir: Path, + # After `output_dir` and optional, which is what lets a panel show + # "(automatic)": the note type already says which fine-tune is meant, so + # the panel the clinician used before this port asked for the type alone. + model: Path = "", max_tokens: int = 2048, temperature: float = 0.0, context_tokens: int = 0, seed: int = 0, device: Literal["cpu", "cuda"] = "cpu", + *, + data_root=None, ) -> Path: """Extract structured findings from free-text clinical notes with a local LLM. @@ -64,13 +75,15 @@ def run( matched case-insensitively, so notes filed per patient and a `NOTE.PDF` off a Windows share are both found. notes_type: Which fine-tune is being run, "TMJ" or "Ortho". It selects - the system prompt and the context window together, and it must match - the model picked below -- they are two different models. - model: The quantised GGUF model, named from what this server hosts. A - hosted bundle folder holding exactly one .gguf resolves to it; one - holding several is refused rather than resolved by sort order. + the system prompt, the context window and, unless a model is named + below, the model itself -- they are two different models. output_dir: Where the extractions are written. Nothing is written outside it, and it may not be the notes folder itself. + model: The quantised GGUF model, named from what this server hosts. + Left empty, the bundle named after the note type is used, which is + the one fine-tuned for it. A hosted bundle folder holding exactly + one .gguf resolves to it; one holding several is refused rather + than resolved by sort order. max_tokens: The longest answer the model may give per note. An answer cut off at this limit is reported as a failure and never written: truncated JSON parses as nothing, and upstream wrote the fragment as @@ -130,7 +143,7 @@ def run( root = notes.parent if notes.is_file() else notes destinations = plan_outputs(found, root, output_dir) - model_file = resolve_model_file(model) + model_file = resolve_model_file(model or default_model(notes_type, data_root)) warnings = [] hint = model_type_hint(model_file) if hint and hint != notes_type: diff --git a/tools/CNE/src/sadt_cne/extraction.py b/tools/CNE/src/sadt_cne/extraction.py index 2ccf21a..1f5b995 100644 --- a/tools/CNE/src/sadt_cne/extraction.py +++ b/tools/CNE/src/sadt_cne/extraction.py @@ -130,6 +130,35 @@ def resolve_model_file(model) -> Path: return candidates[0] +# The DATA folder this tool's weights live in. Written rather than derived, as +# CLIC's is: which folder holds which bundle is a deployment fact. +DATA_NAME = "CNE" + + +def default_model(notes_type: str, data_root) -> Path: + """The bundle a run uses when the caller names none: the note type's own. + + `scripts/data-manifest.yml` stages each fine-tune in a folder named after + its note type (`DATA/CNE/models/TMJ/`, `DATA/CNE/models/Ortho/`), so the + type alone picks the matching weights -- which is how upstream's panel + worked, and what keeps the Ortho model from ever being paired with the TMJ + prompt by a slip of the model dropdown. + """ + if data_root is None: + raise ValueError( + "No 'model' given and no data root to look in. Name the model, or " + "run this through a server that hosts one." + ) + bundle = Path(data_root) / DATA_NAME / "models" / notes_type + if not bundle.is_dir(): + # The deployment's to fix, not the caller's: 503, not 422. + raise ToolUnavailableError( + f"No {notes_type} model is staged on this server. Stage it with " + f"`setup-models.sh --tool CNE`, or name another model." + ) + return bundle + + def model_type_hint(model_file) -> str: """"TMJ", "Ortho" or "" -- what the model's own path says it is for. diff --git a/tools/CNE/src/sadt_cne/layout.py b/tools/CNE/src/sadt_cne/layout.py index ca5177b..c3b82fa 100644 --- a/tools/CNE/src/sadt_cne/layout.py +++ b/tools/CNE/src/sadt_cne/layout.py @@ -18,7 +18,7 @@ "model": {"section": INPUT, "label": "Model"}, "max_tokens": {"section": GENERATION, "label": "Longest answer (tokens)"}, "temperature": {"section": GENERATION, "label": "Temperature"}, - "context_tokens": {"section": GENERATION, "label": "Context window (tokens)"}, + "context_tokens": {"section": GENERATION, "label": "Context window (tokens, 0 = auto)"}, "seed": {"section": GENERATION, "label": "Seed"}, "device": {"section": GENERATION, "label": "Device"}, } diff --git a/tools/CNE/tests/test_run.py b/tools/CNE/tests/test_run.py index bd0cdd0..5bcca93 100644 --- a/tools/CNE/tests/test_run.py +++ b/tools/CNE/tests/test_run.py @@ -234,7 +234,7 @@ def test_archive_residue_directories_are_skipped(tmp_path): def test_a_single_note_file_is_accepted_as_input(tmp_path, model, llm): note = write_txt(tmp_path / "one.txt") - output = sadt_cne.run(note, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(note, "TMJ", tmp_path / "out", model) assert (output / "Extraction_one.txt.json").is_file() @@ -245,19 +245,19 @@ def test_an_empty_folder_is_refused_not_reported_as_a_finished_run(tmp_path, mod (tmp_path / "notes").mkdir() with pytest.raises(ValueError, match="No note found"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) def test_a_folder_of_unsupported_files_is_refused(tmp_path, model, llm): write_txt(tmp_path / "notes" / "scan.dcm") with pytest.raises(ValueError, match="No note found"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) def test_a_missing_input_path_is_a_file_not_found_error(tmp_path, model, llm): with pytest.raises(FileNotFoundError): - sadt_cne.run(tmp_path / "nowhere", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "nowhere", "TMJ", tmp_path / "out", model) # -------------------------------------------------------------------------- @@ -273,7 +273,7 @@ def test_three_notes_sharing_a_stem_write_three_extractions(tmp_path, model, llm write_pdf(notes / "B_001.pdf", "PDF note, right side.") write_docx(notes / "B_001.docx", ["Word note, both sides."]) - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) written = sorted(path.name for path in output.glob("Extraction_*.json")) assert written == [ @@ -287,7 +287,7 @@ def test_three_notes_sharing_a_stem_write_three_extractions(tmp_path, model, llm def test_the_output_name_keeps_the_source_extension(tmp_path, model, llm): write_txt(tmp_path / "notes" / "B_001.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert (output / "Extraction_B_001.txt.txt").is_file() assert (output / "Extraction_B_001.txt.json").is_file() @@ -296,7 +296,7 @@ def test_the_output_name_keeps_the_source_extension(tmp_path, model, llm): def test_subfolders_are_mirrored_in_the_output(tmp_path, model, llm): write_txt(tmp_path / "notes" / "patient_1" / "visit_a" / "note.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert (output / "patient_1" / "visit_a" / "Extraction_note.txt.json").is_file() @@ -305,7 +305,7 @@ def test_two_notes_of_one_name_in_different_folders_do_not_collide(tmp_path, mod write_txt(tmp_path / "notes" / "patient_1" / "note.txt", "First patient.") write_txt(tmp_path / "notes" / "patient_2" / "note.txt", "Second patient.") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert (output / "patient_1" / "Extraction_note.txt.json").is_file() assert (output / "patient_2" / "Extraction_note.txt.json").is_file() @@ -334,7 +334,7 @@ def test_a_json_is_written_beside_every_text_extraction(tmp_path, model, llm): step and threw the object away, so nothing downstream could read it.""" write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert json.loads((output / "Extraction_a.txt.json").read_text()) == { "chief_complaint": "jaw pain", "side": "left", @@ -344,7 +344,7 @@ def test_a_json_is_written_beside_every_text_extraction(tmp_path, model, llm): def test_the_text_extraction_is_key_colon_value(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert (output / "Extraction_a.txt.txt").read_text() == ( "chief_complaint : jaw pain\nside : left\n" @@ -362,7 +362,7 @@ def test_the_engine_creates_its_own_output_directory(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") destination = tmp_path / "does" / "not" / "exist" - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, destination) + output = sadt_cne.run(tmp_path / "notes", "TMJ", destination, model) assert output.is_dir() assert (output / "Extraction_a.txt.json").is_file() @@ -371,7 +371,7 @@ def test_the_engine_creates_its_own_output_directory(tmp_path, model, llm): def test_mirrored_subdirectories_are_created_too(tmp_path, model, llm): write_txt(tmp_path / "notes" / "deep" / "deeper" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert (output / "deep" / "deeper" / "Extraction_a.txt.json").is_file() @@ -386,7 +386,7 @@ def test_writing_into_the_notes_folder_is_refused(tmp_path, model, llm): write_txt(notes / "a.txt") with pytest.raises(ValueError, match="must not be the notes folder"): - sadt_cne.run(notes, "TMJ", model, notes) + sadt_cne.run(notes, "TMJ", notes, model) def test_an_output_folder_inside_the_notes_folder_is_not_discovered(tmp_path, model, llm): @@ -394,7 +394,7 @@ def test_an_output_folder_inside_the_notes_folder_is_not_discovered(tmp_path, mo write_txt(notes / "a.txt") write_txt(notes / "extractions" / "leftover.txt", "A previous run's output.") - output = sadt_cne.run(notes, "TMJ", model, notes / "extractions") + output = sadt_cne.run(notes, "TMJ", notes / "extractions", model) assert [entry["input"] for entry in report_of(output)["notes"]] == ["a.txt"] @@ -404,8 +404,8 @@ def test_a_second_run_extracts_the_same_notes_not_its_own_output(tmp_path, model write_txt(notes / "a.txt") output_dir = notes / "extractions" - sadt_cne.run(notes, "TMJ", model, output_dir) - second = sadt_cne.run(notes, "TMJ", model, output_dir) + sadt_cne.run(notes, "TMJ", output_dir, model) + second = sadt_cne.run(notes, "TMJ", output_dir, model) assert report_of(second)["summary"] == "1/1 note(s) extracted" @@ -422,7 +422,7 @@ def test_a_note_with_no_text_is_never_sent_to_the_model(tmp_path, model, llm): write_pdf(notes / "scanned.pdf", text="") write_txt(notes / "real.txt") - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) sent = [call["messages"][-1]["content"] for call in llm.calls] assert sent == ["Patient reports left jaw pain."] @@ -433,7 +433,7 @@ def test_a_note_with_no_text_writes_nothing(tmp_path, model, llm): write_pdf(notes / "scanned.pdf", text="") write_txt(notes / "real.txt") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert not (output / "Extraction_scanned.pdf.json").exists() assert not (output / "Extraction_scanned.pdf.txt").exists() @@ -444,7 +444,7 @@ def test_a_note_with_no_text_is_reported_as_failed_and_says_why(tmp_path, model, write_pdf(notes / "scanned.pdf", text="") write_txt(notes / "real.txt") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) entry = entry_for(output, "scanned.pdf") assert entry["status"] == "failed" @@ -457,7 +457,7 @@ def test_a_whitespace_only_note_counts_as_empty(tmp_path, model, llm): write_txt(notes / "blank.txt", "\n\n \t\n") write_txt(notes / "real.txt") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert entry_for(output, "blank.txt")["status"] == "failed" @@ -476,7 +476,7 @@ def test_the_character_count_of_every_note_is_reported(tmp_path, model, llm): published rather than judged.""" write_txt(tmp_path / "notes" / "a.txt", "Pain 7/10.") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert entry_for(output, "a.txt")["characters"] == len("Pain 7/10.") @@ -520,7 +520,7 @@ def test_a_utf8_bom_is_stripped_rather_than_read_as_a_character(tmp_path): def test_the_encoding_that_worked_is_reported(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt", "Café", encoding="cp1252") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert entry_for(output, "a.txt")["encoding"] == "cp1252" @@ -658,7 +658,7 @@ def flaky(path): return real(path) monkeypatch.setattr(sadt_cne, "read_note", flaky) - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert report_of(output)["summary"] == "1/2 note(s) extracted" assert entry_for(output, "good.txt")["status"] == "ok" @@ -678,7 +678,7 @@ def refuse(name, package=""): monkeypatch.setattr(readers, "require", refuse) write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert report_of(output)["summary"] == "1/1 note(s) extracted" @@ -692,7 +692,7 @@ def no_fitz(name): write_pdf(notes / "a.pdf") write_txt(notes / "b.txt") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert entry_for(output, "b.txt")["status"] == "ok" assert "pymupdf" in entry_for(output, "a.pdf")["reason"] @@ -708,7 +708,7 @@ def no_fitz(name): write_pdf(tmp_path / "notes" / "a.pdf") with pytest.raises(ToolUnavailableError, match="pymupdf"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) def test_a_missing_llama_cpp_is_a_tool_unavailable_error(tmp_path, monkeypatch): @@ -735,7 +735,7 @@ def test_the_tmj_prompt_and_context_are_chosen_from_one_table(tmp_path, model, l the Ortho 2048 window and long notes were silently truncated.""" write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert llm.loaded["context_tokens"] == 6144 assert llm.calls[0]["messages"][0] == { @@ -748,7 +748,7 @@ def test_ortho_gets_no_system_prompt_and_its_own_context(tmp_path, model, llm): instruction it never saw in training changes what it emits.""" write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "Ortho", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "Ortho", tmp_path / "out", model) assert llm.loaded["context_tokens"] == 2048 assert [message["role"] for message in llm.calls[0]["messages"]] == ["user"] @@ -761,7 +761,7 @@ def test_the_note_type_table_and_the_prompt_table_cover_the_same_types(): def test_an_explicit_context_size_overrides_the_note_type_default(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, context_tokens=12288) assert llm.loaded["context_tokens"] == 12288 @@ -775,7 +775,7 @@ def test_a_negative_context_size_is_refused(): def test_the_note_type_is_recorded_in_the_report(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "Ortho", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "Ortho", tmp_path / "out", model) assert report_of(output)["notes_type"] == "Ortho" assert report_of(output)["system_prompt"] is False @@ -792,7 +792,7 @@ def test_a_truncated_answer_is_a_failure_not_a_written_result(tmp_path, model, l write_txt(tmp_path / "notes" / "b.txt") llm.answers = [('{"pain": "yes"', "length"), (ANSWER, "stop")] - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert entry_for(output, "a.txt")["status"] == "failed" assert entry_for(output, "b.txt")["status"] == "ok" @@ -804,7 +804,7 @@ def test_a_truncated_answer_writes_nothing(tmp_path, model, llm): write_txt(tmp_path / "notes" / "b.txt") llm.answers = [('{"pain": "yes"', "length"), (ANSWER, "stop")] - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert not (output / "Extraction_a.txt.txt").exists() assert not (output / "Extraction_a.txt.json").exists() @@ -814,7 +814,7 @@ def test_the_default_temperature_is_zero_so_a_run_can_be_checked(tmp_path, model """Upstream used 0.1, so the same note extracted twice gave two answers.""" write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert llm.calls[0]["temperature"] == 0.0 @@ -822,7 +822,7 @@ def test_the_default_temperature_is_zero_so_a_run_can_be_checked(tmp_path, model def test_max_tokens_reaches_the_model(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, max_tokens=64) assert llm.calls[0]["max_tokens"] == 64 @@ -831,7 +831,7 @@ def test_max_tokens_reaches_the_model(tmp_path, model, llm): def test_the_seed_reaches_the_engine_and_the_report(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, seed=17, temperature=0.7) assert llm.loaded["seed"] == 17 @@ -844,7 +844,7 @@ def test_the_cpu_device_offloads_no_layer(tmp_path, model, llm): queue behind a segmentation for the card.""" write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert llm.loaded["n_gpu_layers"] == 0 @@ -852,7 +852,7 @@ def test_the_cpu_device_offloads_no_layer(tmp_path, model, llm): def test_the_cuda_device_offloads_every_layer(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cuda") assert llm.loaded["n_gpu_layers"] == -1 @@ -872,7 +872,7 @@ def test_a_cuda_run_on_a_cuda_build_reports_the_offload(tmp_path, model, llm): llm.gpu_offload = True write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cuda") report = report_of(output) @@ -887,7 +887,7 @@ def test_a_cuda_run_on_a_cpu_build_is_reported_as_ignored(tmp_path, model, llm): llm.gpu_offload = False write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cuda") report = report_of(output) @@ -904,7 +904,7 @@ def test_a_cuda_request_a_cpu_build_cannot_honour_is_not_refused(tmp_path, model llm.gpu_offload = False write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cuda") assert entry_for(output, "a.txt")["status"] == "ok" @@ -914,7 +914,7 @@ def test_a_cpu_run_never_claims_the_gpu_however_capable_the_build(tmp_path, mode llm.gpu_offload = True write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cpu") report = report_of(output) @@ -935,7 +935,7 @@ def test_the_build_is_asked_about_the_gpu_before_the_weights_are_loaded( lambda *a, **k: (order.append("loaded"), real_load(*a, **k))[1]) write_txt(tmp_path / "notes" / "a.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out", + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model, device="cuda") assert order == ["asked", "loaded"] @@ -1062,7 +1062,7 @@ def test_the_build_reports_whether_it_can_offload(monkeypatch): def test_the_finish_reason_is_reported_for_every_note(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert entry_for(output, "a.txt")["finish_reason"] == "stop" @@ -1112,7 +1112,7 @@ def test_an_answer_without_json_is_a_failure_not_a_raw_blob(tmp_path, model, llm write_txt(tmp_path / "notes" / "b.txt") llm.answers = ["I am unable to help with that.", ANSWER] - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert not (output / "Extraction_a.txt.txt").exists() assert "no JSON object" in entry_for(output, "a.txt")["reason"] @@ -1139,7 +1139,7 @@ def test_booleans_and_nulls_render_without_python_spelling(): def test_the_field_count_is_reported(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert entry_for(output, "a.txt")["fields"] == 2 @@ -1222,7 +1222,7 @@ def test_a_model_named_for_the_other_note_type_is_warned_about_not_refused(tmp_p Ortho weights with the TMJ instruction gives an answer that looks right.""" write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "Ortho", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "Ortho", tmp_path / "out", model) warnings = report_of(output)["warnings"] assert len(warnings) == 1 and "TMJ" in warnings[0] @@ -1232,7 +1232,7 @@ def test_a_model_named_for_the_other_note_type_is_warned_about_not_refused(tmp_p def test_a_matching_model_produces_no_warning(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert report_of(output)["warnings"] == [] @@ -1240,7 +1240,7 @@ def test_a_matching_model_produces_no_warning(tmp_path, model, llm): def test_the_model_file_name_is_recorded_in_the_report(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert report_of(output)["model"] == "qwen-ft-q4_k_m.gguf" @@ -1258,7 +1258,7 @@ def test_the_report_counts_what_was_written_not_what_was_walked_past(tmp_path, m write_txt(notes / "b.txt") write_pdf(notes / "c.pdf", text="") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert report_of(output)["summary"] == "2/3 note(s) extracted" @@ -1267,7 +1267,7 @@ def test_a_run_where_nothing_succeeded_raises_instead_of_exiting_zero(tmp_path, write_pdf(tmp_path / "notes" / "a.pdf", text="") with pytest.raises(ValueError, match="extracted nothing"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) def test_the_failure_message_names_every_note_and_its_reason(tmp_path, model, llm): @@ -1276,7 +1276,7 @@ def test_the_failure_message_names_every_note_and_its_reason(tmp_path, model, ll write_pdf(notes / "b.pdf", text="") with pytest.raises(ValueError) as raised: - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert "a.pdf" in str(raised.value) and "b.pdf" in str(raised.value) @@ -1286,7 +1286,7 @@ def test_every_note_gets_a_report_entry_whatever_happened(tmp_path, model, llm): write_txt(notes / "a.txt") write_pdf(notes / "b.pdf", text="") - output = sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + output = sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert [entry["input"] for entry in report_of(output)["notes"]] == [ "a.txt", "b.pdf", @@ -1296,7 +1296,7 @@ def test_every_note_gets_a_report_entry_whatever_happened(tmp_path, model, llm): def test_the_report_records_how_long_the_run_took(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert report_of(output)["duration_seconds"] >= 0 @@ -1304,7 +1304,7 @@ def test_the_report_records_how_long_the_run_took(tmp_path, model, llm): def test_run_returns_the_output_directory(tmp_path, model, llm): write_txt(tmp_path / "notes" / "a.txt") - output = sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert output == tmp_path / "out" @@ -1314,7 +1314,7 @@ def test_nothing_is_written_outside_the_output_directory(tmp_path, model, llm): write_txt(notes / "a.txt") before = sorted(path.name for path in notes.iterdir()) - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert sorted(path.name for path in notes.iterdir()) == before @@ -1373,18 +1373,64 @@ def test_every_argument_is_documented_for_the_panel(): import inspect documented = inspect.getdoc(sadt_cne.run) - for name in inspect.signature(sadt_cne.run).parameters: + for name, parameter in inspect.signature(sadt_cne.run).parameters.items(): + # Keyword-only is the server's own injection (`data_root`), never a + # field: describe.py does not publish it. + if parameter.kind is inspect.Parameter.KEYWORD_ONLY: + continue assert f"{name}:" in documented, name def test_the_model_argument_is_a_path_named_model(): """That name and that type are what make the server publish it as a hosted - NAME the client picks -- never a file a clinician can upload.""" + NAME the client picks -- never a file a clinician can upload. Optional, so + the panel offers "(automatic)" and the note type picks the weights.""" import inspect parameter = inspect.signature(sadt_cne.run).parameters["model"] assert parameter.annotation is Path - assert parameter.default is inspect.Parameter.empty + assert parameter.default == "" + + +def test_no_model_named_runs_the_note_types_own_bundle(tmp_path, llm): + for notes_type, name in (("TMJ", "qwen-ft-q4_k_m.gguf"), + ("Ortho", "model-q4_k_m.gguf")): + bundle = tmp_path / "data" / "CNE" / "models" / notes_type + bundle.mkdir(parents=True) + (bundle / name).write_bytes(b"GGUF not really") + write_txt(tmp_path / "notes" / "note.txt", "Patient reports left TMJ pain.") + + output = sadt_cne.run(tmp_path / "notes", "Ortho", tmp_path / "out", + data_root=tmp_path / "data") + + report = report_of(output) + assert report["model"] == "model-q4_k_m.gguf" + assert report["warnings"] == [] + + +def test_no_model_named_and_none_staged_is_the_deployments_fault(tmp_path, llm): + from sadt_cne.dependencies import ToolUnavailableError + + write_txt(tmp_path / "notes" / "note.txt", "Patient reports left TMJ pain.") + (tmp_path / "data" / "CNE" / "models").mkdir(parents=True) + + with pytest.raises(ToolUnavailableError, match="No TMJ model is staged"): + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", + data_root=tmp_path / "data") + + +def test_no_model_named_and_no_data_root_is_refused(tmp_path, llm): + write_txt(tmp_path / "notes" / "note.txt", "Patient reports left TMJ pain.") + + with pytest.raises(ValueError, match="No 'model' given"): + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out") + + +def test_the_notes_argument_accepts_what_the_readers_read(): + """Published as the file dialog's filter; derived, never retyped.""" + from sadt_cne.readers import NOTE_EXTENSIONS + + assert sadt_cne.ACCEPTS == {"notes": NOTE_EXTENSIONS} def test_importing_the_package_loads_no_inference_engine(): @@ -1481,8 +1527,8 @@ def test_a_real_cuda_run_reports_the_offload_and_warns_about_nothing(tmp_path): "Clicking on opening. No locking.", ) - output = sadt_cne.run(tmp_path / "notes", "TMJ", Path(bundle), - tmp_path / "out", device="cuda") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", + Path(bundle), device="cuda") report = report_of(output) assert report["device"] == "cuda" @@ -1510,7 +1556,7 @@ def test_the_real_model_answers_with_json(tmp_path): "Clicking on opening. No locking.", ) - output = sadt_cne.run(tmp_path / "notes", "TMJ", Path(bundle), tmp_path / "out") + output = sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", Path(bundle)) data = json.loads((output / "Extraction_note.txt.json").read_text()) assert isinstance(data, dict) and data @@ -1653,7 +1699,7 @@ def test_progress_announces_the_load_then_counts_notes( write_txt(tmp_path / "notes" / "Jones_Mary.txt") write_txt(tmp_path / "notes" / "Brown_Ann.txt") - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) events, logs = read_events(events_file) assert [event["message"] for event in events] == [ @@ -1679,7 +1725,7 @@ def test_a_note_that_fails_is_logged_to_the_clinician_by_position( write_txt(tmp_path / "notes" / "b_Jones_Mary.txt", text=" ") with caplog.at_level("WARNING", logger="CNE"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) _, logs = read_events(events_file) assert [(log["level"], log["audience"]) for log in logs] == [("warning", "user")] @@ -1701,7 +1747,7 @@ def test_a_failed_note_is_logged_with_position_step_class_and_message( write_txt(notes / "b_Jones_Mary.txt", text=" ") with caplog.at_level("WARNING", logger="CNE"): - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) messages = [record.getMessage() for record in caplog.records] failed = [message for message in messages if message.startswith("note 2 of 2")] @@ -1722,7 +1768,7 @@ def test_a_partial_run_ends_with_a_warning_summary_by_category( write_txt(notes / "b.txt", text=" ") with caplog.at_level("INFO", logger="CNE"): - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) summary = caplog.records[-1] assert summary.levelname == "WARNING" @@ -1735,7 +1781,7 @@ def test_a_complete_run_ends_with_an_info_summary(tmp_path, model, llm, caplog): write_txt(tmp_path / "notes" / "a.txt") with caplog.at_level("INFO", logger="CNE"): - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert caplog.records[-1].levelname == "INFO" assert caplog.records[-1].getMessage() == "1 of 1 notes extracted" @@ -1756,7 +1802,7 @@ def crash(_messages): llm.answers = crash with pytest.raises(RuntimeError) as raised: - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert not isinstance(raised.value, ValueError) assert str(raised.value).startswith( @@ -1779,7 +1825,7 @@ def crash(_messages): llm.answers = crash with pytest.raises(RuntimeError) as raised: - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) assert not isinstance(raised.value, (ValueError, ToolUnavailableError)) @@ -1790,7 +1836,7 @@ def test_nothing_extracted_only_through_the_callers_notes_keeps_a_value_error( write_pdf(tmp_path / "notes" / "a.pdf", text="") with pytest.raises(ValueError) as raised: - sadt_cne.run(tmp_path / "notes", "TMJ", model, tmp_path / "out") + sadt_cne.run(tmp_path / "notes", "TMJ", tmp_path / "out", model) assert "0 of 1 notes extracted; most common failure: ValueError" in str(raised.value) @@ -1807,7 +1853,7 @@ def no_fitz(name): write_txt(notes / "b.txt", text=" ") with pytest.raises(ToolUnavailableError): - sadt_cne.run(notes, "TMJ", model, tmp_path / "out") + sadt_cne.run(notes, "TMJ", tmp_path / "out", model) def test_tool_unavailable_is_a_runtime_error_never_a_value_error():