bounded-calcs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bounded_calcs/__init__.py +15 -0
- bounded_calcs/engine.py +344 -0
- bounded_calcs/mcp_server.py +86 -0
- bounded_calcs/spec.py +513 -0
- bounded_calcs/specs/__init__.py +0 -0
- bounded_calcs/specs/curb65.yaml +97 -0
- bounded_calcs/specs/heart.yaml +148 -0
- bounded_calcs/specs/perc.yaml +104 -0
- bounded_calcs/specs/wells_pe.yaml +78 -0
- bounded_calcs/testing.py +222 -0
- bounded_calcs-0.1.0.dist-info/METADATA +214 -0
- bounded_calcs-0.1.0.dist-info/RECORD +15 -0
- bounded_calcs-0.1.0.dist-info/WHEEL +4 -0
- bounded_calcs-0.1.0.dist-info/entry_points.txt +2 -0
- bounded_calcs-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Clinical calculators that treat unknown as unknown."""
|
|
2
|
+
|
|
3
|
+
from bounded_calcs.engine import InputError, evaluate, schema
|
|
4
|
+
from bounded_calcs.spec import Calculator, SpecError, load_builtin, load_file, parse
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Calculator",
|
|
8
|
+
"InputError",
|
|
9
|
+
"SpecError",
|
|
10
|
+
"evaluate",
|
|
11
|
+
"load_builtin",
|
|
12
|
+
"load_file",
|
|
13
|
+
"parse",
|
|
14
|
+
"schema",
|
|
15
|
+
]
|
bounded_calcs/engine.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Bounded evaluation: what can be concluded when some items are unknown.
|
|
2
|
+
|
|
3
|
+
The engine never assumes an unknown item is normal or absent. It computes every score the
|
|
4
|
+
patient could still have, the decision categories those scores fall into, and which unknown
|
|
5
|
+
items could change that set. All results are exact (no sampling): components are independent,
|
|
6
|
+
so reachable scores are the sumset of each component's reachable points.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from bounded_calcs.spec import Calculator, Component, Item
|
|
15
|
+
|
|
16
|
+
UNKNOWN_WORDS = {"unknown", "not_mentioned", "not mentioned", "unk", ""}
|
|
17
|
+
TRUE_WORDS = {"present", "yes", "true", "y", "1"}
|
|
18
|
+
FALSE_WORDS = {"absent", "no", "false", "n", "0"}
|
|
19
|
+
|
|
20
|
+
# Internal state per item: bool (boolean), str level (ordinal), int band index (numeric),
|
|
21
|
+
# or None (unknown).
|
|
22
|
+
State = dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InputError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _Normalized:
|
|
31
|
+
state: State
|
|
32
|
+
raw_numeric: dict[str, float]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize(calc: Calculator, inputs: dict[str, Any] | None) -> _Normalized:
|
|
36
|
+
inputs = dict(inputs or {})
|
|
37
|
+
extra = set(inputs) - set(calc.items)
|
|
38
|
+
if extra:
|
|
39
|
+
raise InputError(
|
|
40
|
+
f"{calc.id}: unknown item(s) {sorted(extra)}; valid items: {sorted(calc.items)}"
|
|
41
|
+
)
|
|
42
|
+
state: State = {}
|
|
43
|
+
raw_numeric: dict[str, float] = {}
|
|
44
|
+
for iid, item in calc.items.items():
|
|
45
|
+
v = inputs.get(iid)
|
|
46
|
+
if v is None or (isinstance(v, str) and v.strip().lower() in UNKNOWN_WORDS):
|
|
47
|
+
state[iid] = None
|
|
48
|
+
continue
|
|
49
|
+
if item.type == "boolean":
|
|
50
|
+
if isinstance(v, bool):
|
|
51
|
+
state[iid] = v
|
|
52
|
+
elif isinstance(v, str) and v.strip().lower() in TRUE_WORDS:
|
|
53
|
+
state[iid] = True
|
|
54
|
+
elif isinstance(v, str) and v.strip().lower() in FALSE_WORDS:
|
|
55
|
+
state[iid] = False
|
|
56
|
+
else:
|
|
57
|
+
raise InputError(f"{calc.id}.{iid}: expected present, absent or unknown, got {v!r}")
|
|
58
|
+
elif item.type == "ordinal":
|
|
59
|
+
if v not in item.levels:
|
|
60
|
+
raise InputError(
|
|
61
|
+
f"{calc.id}.{iid}: expected one of {list(item.levels)} or unknown, got {v!r}"
|
|
62
|
+
)
|
|
63
|
+
state[iid] = v
|
|
64
|
+
else: # numeric
|
|
65
|
+
hint = (
|
|
66
|
+
f"{calc.id}.{iid}: expected a number in {item.unit}, or unknown. Send unknown "
|
|
67
|
+
f"when the source gives no number (for example only 'normal'); got {v!r}"
|
|
68
|
+
)
|
|
69
|
+
if isinstance(v, bool):
|
|
70
|
+
raise InputError(hint)
|
|
71
|
+
try:
|
|
72
|
+
x = float(v)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
raise InputError(hint) from None
|
|
75
|
+
if x != x or x in (float("inf"), float("-inf")):
|
|
76
|
+
raise InputError(hint)
|
|
77
|
+
state[iid] = next(i for i, b in enumerate(calc.numeric_bands(iid)) if b.matches(x))
|
|
78
|
+
raw_numeric[iid] = x
|
|
79
|
+
return _Normalized(state, raw_numeric)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _domain(calc: Calculator, item: Item) -> list[Any]:
|
|
83
|
+
"""Every internal value an unknown item could take."""
|
|
84
|
+
if item.type == "boolean":
|
|
85
|
+
return [True, False]
|
|
86
|
+
if item.type == "ordinal":
|
|
87
|
+
return list(item.levels)
|
|
88
|
+
return list(range(len(calc.numeric_bands(item.id))))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _component_points(comp: Component, state: State) -> set[float]:
|
|
92
|
+
if comp.rule in ("present", "absent"):
|
|
93
|
+
v = state[comp.items[0]]
|
|
94
|
+
assert comp.points is not None
|
|
95
|
+
if v is None:
|
|
96
|
+
return {comp.points, 0.0}
|
|
97
|
+
return {comp.points if v == (comp.rule == "present") else 0.0}
|
|
98
|
+
if comp.rule == "levels":
|
|
99
|
+
v = state[comp.items[0]]
|
|
100
|
+
return {comp.level_points[v]} if v is not None else set(comp.level_points.values())
|
|
101
|
+
if comp.rule == "bands":
|
|
102
|
+
v = state[comp.items[0]]
|
|
103
|
+
return {comp.bands[v].points} if v is not None else {b.points for b in comp.bands}
|
|
104
|
+
if comp.rule == "any_of":
|
|
105
|
+
assert comp.points is not None
|
|
106
|
+
|
|
107
|
+
def met(i: str) -> bool | None:
|
|
108
|
+
v = state[i]
|
|
109
|
+
if v is None:
|
|
110
|
+
return None
|
|
111
|
+
return bool(v) if i not in comp.conditions else v == 0
|
|
112
|
+
|
|
113
|
+
flags = [met(i) for i in comp.items]
|
|
114
|
+
if any(f is True for f in flags):
|
|
115
|
+
return {comp.points}
|
|
116
|
+
return {0.0, comp.points} if any(f is None for f in flags) else {0.0}
|
|
117
|
+
# count
|
|
118
|
+
members = [i for i in comp.items if i != comp.override]
|
|
119
|
+
known = sum(1 for i in members if state[i] is True)
|
|
120
|
+
unknown = sum(1 for i in members if state[i] is None)
|
|
121
|
+
|
|
122
|
+
def by_count(n: int) -> float:
|
|
123
|
+
return next(b.points for b in comp.bands if b.op is None or n >= (b.value or 0))
|
|
124
|
+
|
|
125
|
+
from_count = {by_count(n) for n in range(known, known + unknown + 1)}
|
|
126
|
+
if comp.override is None:
|
|
127
|
+
return from_count
|
|
128
|
+
ov = state[comp.override]
|
|
129
|
+
assert comp.override_points is not None
|
|
130
|
+
if ov is True:
|
|
131
|
+
return {comp.override_points}
|
|
132
|
+
if ov is False:
|
|
133
|
+
return from_count
|
|
134
|
+
return from_count | {comp.override_points}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reachable_scores(calc: Calculator, state: State) -> set[float]:
|
|
138
|
+
scores = {0.0}
|
|
139
|
+
for comp in calc.components:
|
|
140
|
+
pts = _component_points(comp, state)
|
|
141
|
+
scores = {s + p for s in scores for p in pts}
|
|
142
|
+
return scores
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _categories(calc: Calculator, state: State) -> set[str]:
|
|
146
|
+
return {calc.category(s).name for s in _reachable_scores(calc, state)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _describe_value(calc: Calculator, item: Item, v: Any) -> str:
|
|
150
|
+
if item.type == "boolean":
|
|
151
|
+
return "present" if v else "absent"
|
|
152
|
+
if item.type == "ordinal":
|
|
153
|
+
return str(v)
|
|
154
|
+
band = calc.numeric_bands(item.id)[v]
|
|
155
|
+
if calc.component_of(item.id).rule == "any_of" and band.op is None:
|
|
156
|
+
met = calc.numeric_bands(item.id)[0]
|
|
157
|
+
return "not " + met.describe(item.unit)
|
|
158
|
+
return band.describe(item.unit)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fmt(x: float) -> float | int:
|
|
162
|
+
return int(x) if float(x).is_integer() else x
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def evaluate(calc: Calculator, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
166
|
+
"""Evaluate a calculator on partial inputs.
|
|
167
|
+
|
|
168
|
+
`inputs` maps item id to a value. Items that are omitted, None, or "unknown" are unknown.
|
|
169
|
+
Boolean items take present/absent (or true/false); ordinal items take one of their
|
|
170
|
+
levels; numeric items take a number in the item's unit.
|
|
171
|
+
"""
|
|
172
|
+
norm = _normalize(calc, inputs)
|
|
173
|
+
state = norm.state
|
|
174
|
+
scores = _reachable_scores(calc, state)
|
|
175
|
+
cats = {calc.category(s).name for s in scores}
|
|
176
|
+
ordered = calc.by_risk(cats)
|
|
177
|
+
settled = len(cats) == 1
|
|
178
|
+
unknown_ids = [i for i, v in state.items() if v is None]
|
|
179
|
+
|
|
180
|
+
decisive: list[dict[str, Any]] = []
|
|
181
|
+
irrelevant: list[str] = []
|
|
182
|
+
jointly: list[str] = []
|
|
183
|
+
for iid in unknown_ids:
|
|
184
|
+
item = calc.items[iid]
|
|
185
|
+
if settled:
|
|
186
|
+
irrelevant.append(iid)
|
|
187
|
+
continue
|
|
188
|
+
outcomes = []
|
|
189
|
+
score_sets = []
|
|
190
|
+
for v in _domain(calc, item):
|
|
191
|
+
sub_state = {**state, iid: v}
|
|
192
|
+
score_sets.append(_reachable_scores(calc, sub_state))
|
|
193
|
+
sub = calc.by_risk({calc.category(s).name for s in score_sets[-1]})
|
|
194
|
+
outcomes.append({"if": _describe_value(calc, item, v), "possible_categories": sub})
|
|
195
|
+
narrows = any(len(o["possible_categories"]) < len(cats) for o in outcomes)
|
|
196
|
+
if not narrows:
|
|
197
|
+
# Alone it cannot change the category set; it may still matter together with
|
|
198
|
+
# other unknowns if it can move the score at all.
|
|
199
|
+
moves_score = any(s != scores for s in score_sets)
|
|
200
|
+
(jointly if moves_score else irrelevant).append(iid)
|
|
201
|
+
continue
|
|
202
|
+
settling = sum(1 for o in outcomes if len(o["possible_categories"]) == 1)
|
|
203
|
+
mean_left = sum(len(o["possible_categories"]) for o in outcomes) / len(outcomes)
|
|
204
|
+
decisive.append(
|
|
205
|
+
{
|
|
206
|
+
"item": iid,
|
|
207
|
+
"label": item.label,
|
|
208
|
+
"kind": item.kind,
|
|
209
|
+
"question": item.question or item.label,
|
|
210
|
+
"settles_decision_in": f"{settling} of {len(outcomes)} answers",
|
|
211
|
+
"outcomes": outcomes,
|
|
212
|
+
# Prefer items that settle more often, then narrow more on average; among
|
|
213
|
+
# equals, ask for data before asking for a judgment.
|
|
214
|
+
"_rank": (-settling / len(outcomes), mean_left, item.kind == "judgment"),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
decisive.sort(key=lambda d: d["_rank"])
|
|
218
|
+
for d in decisive:
|
|
219
|
+
del d["_rank"]
|
|
220
|
+
|
|
221
|
+
components = []
|
|
222
|
+
for comp in calc.components:
|
|
223
|
+
pts = sorted(_component_points(comp, state))
|
|
224
|
+
entry: dict[str, Any] = {"component": comp.id, "label": comp.label}
|
|
225
|
+
if len(pts) == 1:
|
|
226
|
+
entry["points"] = _fmt(pts[0])
|
|
227
|
+
else:
|
|
228
|
+
entry["points_range"] = [_fmt(pts[0]), _fmt(pts[-1])]
|
|
229
|
+
entry["unknown_items"] = [i for i in comp.items if state[i] is None]
|
|
230
|
+
components.append(entry)
|
|
231
|
+
|
|
232
|
+
result: dict[str, Any] = {
|
|
233
|
+
"calculator": calc.id,
|
|
234
|
+
"calculator_version": calc.version,
|
|
235
|
+
"settled": settled,
|
|
236
|
+
"category": ordered[0] if settled else None,
|
|
237
|
+
"possible_categories": ordered,
|
|
238
|
+
"worst_case_category": ordered[-1],
|
|
239
|
+
"score": _fmt(next(iter(scores))) if len(scores) == 1 else None,
|
|
240
|
+
"score_range": [_fmt(min(scores)), _fmt(max(scores))],
|
|
241
|
+
"decisive_unknowns": decisive,
|
|
242
|
+
"jointly_relevant_unknowns": jointly,
|
|
243
|
+
"irrelevant_unknowns": irrelevant,
|
|
244
|
+
"components": components,
|
|
245
|
+
}
|
|
246
|
+
if settled and unknown_ids:
|
|
247
|
+
result["note"] = (
|
|
248
|
+
"The category is settled: no value of the remaining unknown items can change it."
|
|
249
|
+
)
|
|
250
|
+
elif not settled:
|
|
251
|
+
ask = "a decisive unknown" if decisive else "the jointly relevant unknowns"
|
|
252
|
+
result["note"] = (
|
|
253
|
+
f"Not settled. Unknown items were not assumed normal. Resolve {ask} (ask the "
|
|
254
|
+
"clinician) and evaluate again; do not report a single category yet."
|
|
255
|
+
)
|
|
256
|
+
judgment_pending = [d["item"] for d in decisive if d["kind"] == "judgment"]
|
|
257
|
+
if judgment_pending:
|
|
258
|
+
result["judgment_items_pending"] = judgment_pending
|
|
259
|
+
result["judgment_note"] = (
|
|
260
|
+
"These items are clinician judgments. Ask the clinician; do not infer them from a note."
|
|
261
|
+
)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def schema(calc: Calculator) -> dict[str, Any]:
|
|
266
|
+
"""A description of a calculator's inputs and outputs, for clients and LLMs."""
|
|
267
|
+
items = []
|
|
268
|
+
for item in calc.items.values():
|
|
269
|
+
entry: dict[str, Any] = {
|
|
270
|
+
"id": item.id,
|
|
271
|
+
"label": item.label,
|
|
272
|
+
"type": item.type,
|
|
273
|
+
"kind": item.kind,
|
|
274
|
+
}
|
|
275
|
+
if item.type == "boolean":
|
|
276
|
+
entry["values"] = ["present", "absent", "unknown"]
|
|
277
|
+
elif item.type == "ordinal":
|
|
278
|
+
entry["values"] = [*item.levels, "unknown"]
|
|
279
|
+
else:
|
|
280
|
+
entry["unit"] = item.unit
|
|
281
|
+
entry["values"] = f"number in {item.unit}, or unknown"
|
|
282
|
+
if item.question:
|
|
283
|
+
entry["question"] = item.question
|
|
284
|
+
if item.notes:
|
|
285
|
+
entry["notes"] = list(item.notes)
|
|
286
|
+
items.append(entry)
|
|
287
|
+
|
|
288
|
+
components = []
|
|
289
|
+
for comp in calc.components:
|
|
290
|
+
c: dict[str, Any] = {"id": comp.id, "label": comp.label, "rule": comp.rule}
|
|
291
|
+
if comp.rule in ("present", "absent"):
|
|
292
|
+
c["item"], c["points"] = comp.items[0], _fmt(comp.points or 0)
|
|
293
|
+
elif comp.rule == "levels":
|
|
294
|
+
c["item"] = comp.items[0]
|
|
295
|
+
c["points"] = {k: _fmt(v) for k, v in comp.level_points.items()}
|
|
296
|
+
elif comp.rule == "bands":
|
|
297
|
+
unit = calc.items[comp.items[0]].unit
|
|
298
|
+
c["item"] = comp.items[0]
|
|
299
|
+
c["bands"] = [{"when": b.describe(unit), "points": _fmt(b.points)} for b in comp.bands]
|
|
300
|
+
elif comp.rule == "any_of":
|
|
301
|
+
conds = []
|
|
302
|
+
for i in comp.items:
|
|
303
|
+
if i in comp.conditions:
|
|
304
|
+
conds.append(f"{i} {comp.conditions[i].describe(calc.items[i].unit)}")
|
|
305
|
+
else:
|
|
306
|
+
conds.append(f"{i} present")
|
|
307
|
+
c["any_of"], c["points"] = conds, _fmt(comp.points or 0)
|
|
308
|
+
else:
|
|
309
|
+
c["items"] = [i for i in comp.items if i != comp.override]
|
|
310
|
+
c["points_by_count"] = [
|
|
311
|
+
{"when": b.describe("present") if b.op else "otherwise", "points": _fmt(b.points)}
|
|
312
|
+
for b in comp.bands
|
|
313
|
+
]
|
|
314
|
+
if comp.override:
|
|
315
|
+
c["override"] = {"item": comp.override, "points": _fmt(comp.override_points or 0)}
|
|
316
|
+
components.append(c)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"id": calc.id,
|
|
320
|
+
"name": calc.name,
|
|
321
|
+
"version": calc.version,
|
|
322
|
+
"purpose": calc.purpose,
|
|
323
|
+
"citation": calc.citation,
|
|
324
|
+
"references": list(calc.references),
|
|
325
|
+
"items": items,
|
|
326
|
+
"components": components,
|
|
327
|
+
"categories": [
|
|
328
|
+
{
|
|
329
|
+
"name": c.name,
|
|
330
|
+
"label": c.label,
|
|
331
|
+
"when": "lowest" if c.lower == float("-inf")
|
|
332
|
+
else f"score {'>' if c.lower_strict else '>='} {c.lower:g}",
|
|
333
|
+
"meaning": c.meaning,
|
|
334
|
+
}
|
|
335
|
+
for c in calc.categories
|
|
336
|
+
],
|
|
337
|
+
"notes": list(calc.notes),
|
|
338
|
+
"input_rules": (
|
|
339
|
+
"Pass only what is documented or known. Omit an item, or send 'unknown', when the "
|
|
340
|
+
"source does not mention it: never fill it in as absent or normal. A numeric item "
|
|
341
|
+
"described only as 'normal', with no number, is unknown. Items of kind 'judgment' "
|
|
342
|
+
"must come from the clinician, not from a note."
|
|
343
|
+
),
|
|
344
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""MCP server exposing the bounded calculators (stdio by default, so notes stay local).
|
|
2
|
+
|
|
3
|
+
Tools:
|
|
4
|
+
list_calculators() ids, names and purposes
|
|
5
|
+
get_schema(calculator_id) items, allowed values, scoring, categories, citation
|
|
6
|
+
evaluate(calculator_id, findings) bounded result: settled?, possible categories,
|
|
7
|
+
decisive unknowns to ask about
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mcp.server import MCPServer
|
|
16
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
17
|
+
|
|
18
|
+
from bounded_calcs.engine import InputError, evaluate as _evaluate, schema as _schema
|
|
19
|
+
from bounded_calcs.spec import load_builtin
|
|
20
|
+
|
|
21
|
+
INSTRUCTIONS = """\
|
|
22
|
+
Clinical calculators that never assume an unmentioned finding is normal.
|
|
23
|
+
|
|
24
|
+
Workflow:
|
|
25
|
+
1. list_calculators, then get_schema for the calculator you need.
|
|
26
|
+
2. Read the note and send to `evaluate` only what it actually documents: present, absent, a
|
|
27
|
+
level, or a number. Leave out (or send "unknown") anything the note does not mention.
|
|
28
|
+
Never fill an unmentioned item as absent or normal. A numeric item described only as
|
|
29
|
+
"normal", with no number, is unknown: do not invent a value. Convert units yourself to the
|
|
30
|
+
unit the schema states.
|
|
31
|
+
3. If the result is settled, report its category. If not, ask the clinician about the
|
|
32
|
+
decisive unknowns (in the order given), add the answers, and call evaluate again.
|
|
33
|
+
4. Items of kind "judgment" (for example HEART history, Wells "alternative diagnosis less
|
|
34
|
+
likely") must come from the clinician, never from your reading of the note.
|
|
35
|
+
|
|
36
|
+
Research and reference use only. Not a medical device; not for unsupervised clinical decisions.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
CALCS = load_builtin()
|
|
40
|
+
server = MCPServer(name="bounded-calcs", instructions=INSTRUCTIONS, version="0.1.0")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get(calculator_id: str):
|
|
44
|
+
if calculator_id not in CALCS:
|
|
45
|
+
raise ToolError(f"Unknown calculator '{calculator_id}'. Available: {sorted(CALCS)}")
|
|
46
|
+
return CALCS[calculator_id]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@server.tool()
|
|
50
|
+
def list_calculators() -> list[dict[str, str]]:
|
|
51
|
+
"""List the available calculators (id, name, purpose)."""
|
|
52
|
+
return [{"id": c.id, "name": c.name, "purpose": c.purpose} for c in CALCS.values()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@server.tool()
|
|
56
|
+
def get_schema(calculator_id: str) -> dict[str, Any]:
|
|
57
|
+
"""Describe a calculator: its items and allowed values, how points are assigned, the
|
|
58
|
+
decision categories, the source citation, and open review notes."""
|
|
59
|
+
return _schema(_get(calculator_id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@server.tool()
|
|
63
|
+
def evaluate(calculator_id: str, findings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
64
|
+
"""Evaluate a calculator on what is actually known.
|
|
65
|
+
|
|
66
|
+
`findings` maps item ids to values: "present" / "absent" for boolean items, one of the
|
|
67
|
+
listed levels for ordinal items, a number for numeric items. Omit items that are not
|
|
68
|
+
documented (or send "unknown"); they are treated as unknown, never as normal.
|
|
69
|
+
|
|
70
|
+
Returns whether the decision category is settled, every category still possible, the
|
|
71
|
+
worst case, the score range, and the decisive unknowns: the only items worth asking the
|
|
72
|
+
clinician about, each with what the category would be for each possible answer.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
return _evaluate(_get(calculator_id), findings or {})
|
|
76
|
+
except InputError as e:
|
|
77
|
+
raise ToolError(str(e)) from None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
|
|
82
|
+
server.run(transport=transport) # type: ignore[arg-type]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|