Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions scripts/data-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions tools/CNE/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -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/<notes_type>/`, 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`
Expand Down Expand Up @@ -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=<bundle>
.venv/bin/python -m pyflakes src tests
.venv/bin/python ../../scripts/describe.py . # the schema the server publishes
Expand Down
27 changes: 20 additions & 7 deletions tools/CNE/src/sadt_cne/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
build_messages,
complete,
context_for,
default_model,
load_model,
model_type_hint,
parse_extraction,
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions tools/CNE/src/sadt_cne/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion tools/CNE/src/sadt_cne/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Loading
Loading