From 05047003880902b2e9315bfa4fbfa9438bd28ab1 Mon Sep 17 00:00:00 2001 From: "Brandon Dent, MD" Date: Mon, 31 Aug 2026 16:06:17 -0300 Subject: [PATCH 1/3] fix: close fail-closed gaps (iteration cap, bad JSON, silent empty review) + ignore .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1: .gitignore now excludes .env/.env.* (keeping .env.example), .venv/, *.pem, *.key, .coverage — the quickstart creates .env at the repo root and nothing guarded it (folds in PR #1's .venv/.coverage lines). P1-2: agent_loop treats stop_reason="tool_use" on the final message (the SDK runner exhausted max_iterations with the model still requesting tools) as LLMFailure, and run_compliance_review raises LLMFailure when the agent never called report_findings — a compliance audit can no longer render as a clean pass with zero findings. P1-3: structured() wraps json.loads so malformed JSON raises LLMFailure (the documented contract) instead of escaping as a raw JSONDecodeError / HTTP 500. Tests: fake tool-runner coverage for agent_loop (success, iteration cap, no messages), invalid-JSON structured(), and both compliance fail-closed paths. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01142EwVwkXFRGnYu7YgHSYL --- .gitignore | 10 ++++++ STATUS.md => docs/HACKATHON_LOG.md | 0 src/halo/llm.py | 9 +++-- src/halo/mci/compliance.py | 5 +++ tests/test_compliance.py | 39 +++++++++++++++++++++ tests/test_llm.py | 55 ++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 2 deletions(-) rename STATUS.md => docs/HACKATHON_LOG.md (100%) diff --git a/.gitignore b/.gitignore index 726d055..06fd245 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,18 @@ synthetic-ambient-fhir-25/index.html synthetic-ambient-fhir-25/synthetic-ambient-fhir-25.json +# Secrets — never commit key material (.env.example is the committed template) +.env +.env.* +!.env.example +*.pem +*.key + # Build/demo artifacts +.venv/ __pycache__/ *.egg-info/ +*.egg-link out/ .DS_Store +.coverage diff --git a/STATUS.md b/docs/HACKATHON_LOG.md similarity index 100% rename from STATUS.md rename to docs/HACKATHON_LOG.md diff --git a/src/halo/llm.py b/src/halo/llm.py index 6f21dae..625c8a3 100644 --- a/src/halo/llm.py +++ b/src/halo/llm.py @@ -91,7 +91,9 @@ def agent_loop( trail.append({"tool": block.name, "input": block.input}) if last is None: raise LLMFailure("fail-closed: agent loop produced no message") - if last.stop_reason in ("refusal", "max_tokens", "pause_turn"): + # ``tool_use`` as the *final* stop reason means the runner hit ``max_iterations`` + # mid-loop: the model still wanted tools and never produced a final answer. + if last.stop_reason in ("refusal", "max_tokens", "pause_turn", "tool_use"): raise LLMFailure(f"fail-closed: stop_reason={last.stop_reason}") return _text(last), trail @@ -115,7 +117,10 @@ def structured( ) if response.stop_reason in ("refusal", "max_tokens"): raise LLMFailure(f"fail-closed: stop_reason={response.stop_reason}") - data = json.loads(_text(response)) + try: + data = json.loads(_text(response)) + except json.JSONDecodeError as exc: + raise LLMFailure(f"fail-closed: invalid JSON: {exc}") from exc if not isinstance(data, dict): raise LLMFailure(f"expected JSON object, got {type(data).__name__}") return data diff --git a/src/halo/mci/compliance.py b/src/halo/mci/compliance.py index 790afc3..c8e45e8 100644 --- a/src/halo/mci/compliance.py +++ b/src/halo/mci/compliance.py @@ -144,6 +144,11 @@ def report_findings(findings_json: str, summary: str) -> str: max_iterations=8, ) + # Fail closed: a review that never reached report_findings must not render as a + # clean pass with zero findings — refuse instead of reporting nothing. + if not summary_holder: + raise llm.LLMFailure("fail-closed: agent never called report_findings") + # Deterministic verification: rule must exist; evidence must actually appear. known_rules = {r["rule_id"] for r in RULES} haystack = "\n".join(_log_lines()) + "\n" + json.dumps(_snapshot()) diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 2e61f00..fdcfbac 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -106,6 +106,45 @@ def test_snapshot_evidence_is_verifiable(monkeypatch: pytest.MonkeyPatch) -> Non assert len(out["findings"]) == 1 +def test_review_fails_closed_when_report_never_called(monkeypatch: pytest.MonkeyPatch) -> None: + # End-to-end through the real agent_loop with a fake runner: the model ends its + # turn without ever calling report_findings. This must raise, never render as a + # clean review with zero findings. + from types import SimpleNamespace + + message = SimpleNamespace( + stop_reason="end_turn", + content=[SimpleNamespace(type="text", text="all looks fine")], + ) + + def tool_runner(**_kwargs: Any) -> Any: + return iter([message]) + + fake = SimpleNamespace(beta=SimpleNamespace(messages=SimpleNamespace(tool_runner=tool_runner))) + monkeypatch.setattr(llm, "client", lambda: fake) + with pytest.raises(llm.LLMFailure, match="report_findings"): + comp.run_compliance_review() + + +def test_review_fails_closed_at_iteration_cap(monkeypatch: pytest.MonkeyPatch) -> None: + # Runner exhausted max_iterations with the model still requesting tools + # (stop_reason stays "tool_use"): agent_loop itself must refuse. + from types import SimpleNamespace + + message = SimpleNamespace( + stop_reason="tool_use", + content=[SimpleNamespace(type="tool_use", name="get_rules", input={})], + ) + + def tool_runner(**_kwargs: Any) -> Any: + return iter([message]) + + fake = SimpleNamespace(beta=SimpleNamespace(messages=SimpleNamespace(tool_runner=tool_runner))) + monkeypatch.setattr(llm, "client", lambda: fake) + with pytest.raises(llm.LLMFailure, match="tool_use"): + comp.run_compliance_review() + + def test_endpoint_fails_closed_on_llm_failure(monkeypatch: pytest.MonkeyPatch) -> None: def boom(*_a: Any, **_k: Any): raise llm.LLMFailure("refusal") diff --git a/tests/test_llm.py b/tests/test_llm.py index 994c687..567633b 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -60,3 +60,58 @@ def test_structured_rejects_non_object(monkeypatch: pytest.MonkeyPatch) -> None: _patch_client(monkeypatch, _resp("end_turn", json.dumps([1, 2]))) with pytest.raises(llm.LLMFailure): llm.structured("p", {"type": "object"}) + + +def test_structured_fails_closed_on_invalid_json(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_client(monkeypatch, _resp("end_turn", "{not valid json")) + with pytest.raises(llm.LLMFailure, match="invalid JSON"): + llm.structured("p", {"type": "object"}) + + +def _patch_runner(monkeypatch: pytest.MonkeyPatch, messages: list[SimpleNamespace]) -> None: + def tool_runner(**_kwargs: Any) -> Any: + return iter(messages) + + fake = SimpleNamespace(beta=SimpleNamespace(messages=SimpleNamespace(tool_runner=tool_runner))) + monkeypatch.setattr(llm, "client", lambda: fake) + + +def test_agent_loop_returns_text_and_trail(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runner( + monkeypatch, + [ + SimpleNamespace( + stop_reason="tool_use", + content=[SimpleNamespace(type="tool_use", name="lookup", input={"q": 1})], + ), + SimpleNamespace( + stop_reason="end_turn", + content=[SimpleNamespace(type="text", text="done")], + ), + ], + ) + text, trail = llm.agent_loop("p", []) + assert text == "done" + assert trail == [{"tool": "lookup", "input": {"q": 1}}] + + +def test_agent_loop_fails_closed_on_iteration_cap(monkeypatch: pytest.MonkeyPatch) -> None: + # The SDK runner exits at max_iterations leaving stop_reason="tool_use" on the last + # message — the agent never produced a final answer, so this must raise, not return. + _patch_runner( + monkeypatch, + [ + SimpleNamespace( + stop_reason="tool_use", + content=[SimpleNamespace(type="tool_use", name="lookup", input={})], + ), + ], + ) + with pytest.raises(llm.LLMFailure, match="tool_use"): + llm.agent_loop("p", []) + + +def test_agent_loop_fails_closed_on_no_messages(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runner(monkeypatch, []) + with pytest.raises(llm.LLMFailure, match="no message"): + llm.agent_loop("p", []) From 3675c51c54356b32624dfb9bd4a6f292b5a8af29 Mon Sep 17 00:00:00 2001 From: "Brandon Dent, MD" Date: Mon, 31 Aug 2026 16:06:27 -0300 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20true=20up=20claims=20=E2=80=94=20go?= =?UTF-8?q?ldset=20N,=20test=20count,=20CME=20draft=20status,=20dataset=20?= =?UTF-8?q?NOTICE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-4: README metrics line now states the run explicitly (single run, 2026-07-18, on 12 of the current 13 goldset cases; rerun pending) and the test count matches the collected suite (297). The goldset rerun itself is deferred to the owner: python -m halo.mci.demo, then commit evals/goldset_.json. P1-5: docs/ECOSYSTEM.md no longer says "MD-signed" — the CME cards are hand-built with physician sign-off pending (all four carry review.status="draft"). P1-6: synthetic-ambient-fhir-25/NOTICE states the dataset was provided by Abridge for hackathon use and that redistribution terms are unconfirmed (not covered by the repo's Apache-2.0). Confirming terms with Abridge is deferred to the owner. Also: scenario count consistency (6 scripted scenarios, README + demo.py). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01142EwVwkXFRGnYu7YgHSYL --- README.md | 16 +++++++++------- docs/ECOSYSTEM.md | 6 +++++- src/halo/mci/demo.py | 4 ++-- synthetic-ambient-fhir-25/NOTICE | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 synthetic-ambient-fhir-25/NOTICE diff --git a/README.md b/README.md index f444fdc..5c18f9e 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,10 @@ verification layer over every agent output. Full inventory: [docs/AGENTS.md](doc | Any identity match | At most a *candidate*; confirmation is a human act | **Cardinal metric: under-triage false negatives = 0.** Current results on the synthetic -goldset (N=12 notes, claude-opus-4-8, single run, method in `src/halo/mci/demo.py`): +goldset (single run, 2026-07-18, claude-opus-4-8, on 12 of the current 13 goldset cases — +`mci-013` was added after that run; rerun pending; method in `src/halo/mci/demo.py`): 11/12 category agreement, **0 under-triage FNs**, 78/84 extraction fields correct; the sole -disagreement was safe-direction over-triage. 275 offline tests (including full input-space totality sweeps over the triage and +disagreement was safe-direction over-triage. 297 offline tests (including full input-space totality sweeps over the triage and surge rule tables) gate CI. ## Quickstart @@ -120,7 +121,7 @@ Live surfaces (need `ANTHROPIC_API_KEY`): ```sh .venv/bin/python -m halo.mci.demo # goldset eval: extraction + triage + FN gate -.venv/bin/python -m halo.mci.demo --handoff # 4 scripted end-to-end scenarios (see below) +.venv/bin/python -m halo.mci.demo --handoff # 6 scripted end-to-end scenarios (see below) .venv/bin/python -m halo.mci.demo --surge # offline: reverse-triage the census, print plan make serve # then open http://127.0.0.1:8000 ``` @@ -131,7 +132,7 @@ care tethers, and the surge action per bed) plus a **door triage** tab (paste or note, run the handoff, read the evidence-quoted observations, identity candidates with care flags, the agent tool trail, and the FHIR write-back preview). -The three scripted scenarios mirror the published failure modes: +The six scripted scenarios mirror the published failure modes; three examples: 1. **Route 91 pattern** — self-transported, partial identity, head strike; resolves deterministically and surfaces the clopidogrel occult-hemorrhage flag. @@ -154,12 +155,13 @@ The three scripted scenarios mirror the published failure modes: | `docs/GOVERNANCE.md` | Medical, legal, and ethical posture; AI risk register; limitations. | | `docs/WORKFLOW.md` | Who actually does MCI triage (nurses) and where each surface sits. | | `docs/INTEGRATION.md` | Epic/FHIR incorporation: SMART launch, alias write-back, CDS Hooks, chart bloat. | -| `CLAUDE.md` / `STATUS.md` | Working charter and live multi-terminal coordination board. | +| `CLAUDE.md` / `docs/HACKATHON_LOG.md` | Working charter and the archived hackathon coordination board. | ## Working agreements -Several agent terminals work in this repo concurrently (full rules in [CLAUDE.md](CLAUDE.md)): -claim a lane in [STATUS.md](STATUS.md) first; stage files by name; `git pull --rebase` + +During the hackathon several agent terminals worked in this repo concurrently (full rules +in [CLAUDE.md](CLAUDE.md)): claim a lane in the coordination board (now archived at +[docs/HACKATHON_LOG.md](docs/HACKATHON_LOG.md)) first; stage files by name; `git pull --rebase` + `make check` before every push; synthetic data only; never read or print `.env` contents. ## Scope & claims diff --git a/docs/ECOSYSTEM.md b/docs/ECOSYSTEM.md index 541cd7a..c52b72c 100644 --- a/docs/ECOSYSTEM.md +++ b/docs/ECOSYSTEM.md @@ -16,7 +16,8 @@ these repos.** They interoperate by shared *data formats* and shared ▼ ┌────────────────────────────┐ │ HALO edu / CME engine │ depth: step-graded drills, computable - │ (hand-built, MD-signed) │ dosing, fail-closed refusal + │ (hand-built; all cards │ dosing, fail-closed refusal + │ draft, sign-off pending) │ └────────────┬───────────────┘ │ hash-chained CME evidence (identical primitive) ▼ @@ -26,6 +27,9 @@ these repos.** They interoperate by shared *data formats* and shared LostBench / SafeShift ``` +CME engine content status: hand-built; physician sign-off pending (all four cards +carry `review.status="draft"`). + ## Seam 1 — OpenEM as citation & provenance backbone *(wired)* OpenEM supplies breadth and citations; HALO supplies depth. The boundary is diff --git a/src/halo/mci/demo.py b/src/halo/mci/demo.py index ea027c2..3c3ebe0 100644 --- a/src/halo/mci/demo.py +++ b/src/halo/mci/demo.py @@ -4,7 +4,7 @@ ``python -m halo.mci.demo`` goldset eval — extraction accuracy vs gold observations and the under-triage FN gate (target 0). -``python -m halo.mci.demo --handoff`` three scripted scenarios mirroring published MCI +``python -m halo.mci.demo --handoff`` six scripted scenarios mirroring published MCI failure modes (Route 91 / Beirut / Boston patterns): triage + identity reconciliation + care flags. """ @@ -36,7 +36,7 @@ def _rule(title: str) -> None: def run_handoff_scenarios() -> int: - """Three scripted end-to-end scenarios — the judged demo path.""" + """All scripted end-to-end scenarios (``SCENARIOS``) — the judged demo path.""" from halo.mci.panel import care_flags from halo.mci.reconcile import reconcile from halo.mci.scenarios import INCIDENT_DATE, SCENARIOS diff --git a/synthetic-ambient-fhir-25/NOTICE b/synthetic-ambient-fhir-25/NOTICE new file mode 100644 index 0000000..0bb3e6d --- /dev/null +++ b/synthetic-ambient-fhir-25/NOTICE @@ -0,0 +1,14 @@ +NOTICE — synthetic-ambient-fhir-25 dataset + +This directory contains the "synthetic-ambient-fhir-25" dataset: 25 fully +synthetic patients (Synthea-simulated records with LLM-generated transcripts +and notes; no real patient data). It was provided by Abridge for use during +the Future of Agentic AI in Healthcare hackathon (Abridge x Anthropic x +Lightspeed, San Francisco, 2026-07-18). + +This dataset is NOT covered by the repository's Apache-2.0 license. Its +redistribution terms have not been confirmed with Abridge. Until they are, +treat the dataset as provided for hackathon use only: do not redistribute it +separately from this repository, and do not rely on it under Apache-2.0. + +Questions: b@thegoatnote.com From 3f3a8d21c251c6757a815f06e56f169960b9d522 Mon Sep 17 00:00:00 2001 From: "Brandon Dent, MD" Date: Mon, 31 Aug 2026 16:06:48 -0300 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20readiness=20polish=20=E2=80=94=20S?= =?UTF-8?q?ECURITY/CONTRIBUTING,=20SHA-pinned=20actions,=20mypy=20strict?= =?UTF-8?q?=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SECURITY.md (disclosure contact, scope, supported versions) and a short CONTRIBUTING.md. - CI actions pinned to commit SHAs (checkout v4.4.0, setup-python v5.6.0). - anthropic bounded to >=1.0,<2 (code depends on 1.x beta surfaces). - Fix the 5 mypy-strict errors in src/halo/mci (env-default Path handling in panel/census, typed _invert helper in triage, extract values dict) — mypy strict now clean on all 36 source files, matching the log's claim. - STATUS.md archived to docs/HACKATHON_LOG.md (hackathon is over); README and CLAUDE.md references updated. - HandoffRequest.incident_date defaults to today (UTC) instead of the hard-coded hackathon date. - edu/drill.py and edu/lookup.py log at warning level before failing closed, so an auth/config outage is distinguishable from a model decline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01142EwVwkXFRGnYu7YgHSYL --- .github/workflows/ci.yml | 4 ++-- CLAUDE.md | 9 +++++---- CONTRIBUTING.md | 15 +++++++++++++++ SECURITY.md | 19 +++++++++++++++++++ docs/HACKATHON_LOG.md | 7 ++++++- pyproject.toml | 2 +- src/halo/app.py | 6 +++++- src/halo/edu/drill.py | 8 +++++++- src/halo/edu/lookup.py | 8 +++++++- src/halo/mci/census.py | 5 +++-- src/halo/mci/extract.py | 2 +- src/halo/mci/panel.py | 5 +++-- src/halo/mci/triage.py | 9 ++++++--- 13 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ec1c15..5471536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,8 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" cache: pip diff --git a/CLAUDE.md b/CLAUDE.md index 34fed99..2bd66c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ anything committed here at any time. ## Start of session — do this first -1. Read `STATUS.md`. Claim a lane (add/edit a row, commit + push it) **before** writing code. +1. Read `docs/HACKATHON_LOG.md` (the coordination board, archived post-hackathon at that + path). Claim a lane (add/edit a row, commit + push it) **before** writing code. 2. `make setup` once per machine. `make check` must be green before every push. ## Commands @@ -21,12 +22,12 @@ anything committed here at any time. Several Claude Code sessions share this repo. To keep git history clean: -- **One terminal = one lane.** Scope is a set of files/dirs, recorded in `STATUS.md`. Do not edit - files outside your lane; if you must, coordinate via a note in `STATUS.md` first. +- **One terminal = one lane.** Scope is a set of files/dirs, recorded in `docs/HACKATHON_LOG.md`. + Do not edit files outside your lane; if you must, coordinate via a note there first. - **Commit small, commit often.** Stage files **by name** — never `git add -A` or `git add .`. - **Before every push:** `git pull --rebase origin main`, re-run `make check`, then push. - If a rebase conflicts in files outside your lane, stop — don't resolve someone else's work. -- Shared files (`STATUS.md`, `README.md`, `pyproject.toml`) get one-line focused commits so +- Shared files (`docs/HACKATHON_LOG.md`, `README.md`, `pyproject.toml`) get one-line focused commits so rebases stay trivial. ## Hard rules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..01e5854 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +HALO is a solo-maintained research project; issues and small PRs are welcome. + +- Setup: `make setup` (venv + dev deps), then `make check` (ruff + pytest, + offline) — it must be green before and after your change. +- Stage files by name (never `git add -A`); keep commits focused. +- Synthetic data only. Never commit `.env`, keys, or anything derived from a + real patient record. +- Fail-closed is the house rule: on model refusal, truncation, bad JSON, or a + missing final answer, raise `halo.llm.LLMFailure` — never return a partial. +- Type checking is strict on `src` (`.venv/bin/python -m mypy`); keep it clean. +- Metrics claims need N and method (see CLAUDE.md). + +Security reports: see [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..42f0a67 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security policy + +This repository is research software. It is not a medical device and must not +be used for clinical decision-making. All data in this repository is synthetic. + +## Reporting a vulnerability + +Email b@thegoatnote.com with the repository name, a description, and +reproduction steps. You will receive an acknowledgement within 5 business days. +Please do not open a public issue for security reports. + +## Scope + +- Code in this repository and its GitHub Actions workflows. +- Out of scope: third-party services referenced in docs, synthetic demo data. + +## Supported versions + +Only the default branch is supported. diff --git a/docs/HACKATHON_LOG.md b/docs/HACKATHON_LOG.md index 9b37071..0aa4ea3 100644 --- a/docs/HACKATHON_LOG.md +++ b/docs/HACKATHON_LOG.md @@ -1,4 +1,9 @@ -# STATUS — live coordination board +# HACKATHON LOG — archived coordination board + +> Archived 2026-08-31. This was the live multi-terminal lane board for the +> 2026-07-18 hackathon build (formerly `STATUS.md` at the repo root), +> preserved verbatim below as the build log. Counts and claims reflect the +> moment each lane closed, not the current tree. Every terminal updates this file when claiming or finishing work. Commit it with your changes — it is the source of truth for who is working where. diff --git a/pyproject.toml b/pyproject.toml index 61a3b99..e2c3ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" license = "Apache-2.0" authors = [{ name = "GOATnote Inc." }] dependencies = [ - "anthropic>=0.116", + "anthropic>=1.0,<2", "pydantic>=2", "fastapi>=0.110", "uvicorn>=0.29", diff --git a/src/halo/app.py b/src/halo/app.py index fdfbf5a..beeb55b 100644 --- a/src/halo/app.py +++ b/src/halo/app.py @@ -1,6 +1,7 @@ """API surface + demo UI. Run locally: ``make serve`` -> http://127.0.0.1:8000""" from dataclasses import asdict +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -351,7 +352,10 @@ class HandoffRequest(BaseModel): """ note: str = Field(min_length=1) - incident_date: str = Field(default="2026-07-18", pattern=r"^\d{4}-\d{2}-\d{2}$") + incident_date: str = Field( + default_factory=lambda: datetime.now(tz=UTC).date().isoformat(), + pattern=r"^\d{4}-\d{2}-\d{2}$", + ) likely_survivable: bool | None = None diff --git a/src/halo/edu/drill.py b/src/halo/edu/drill.py index 7f83ae1..d5c4136 100644 --- a/src/halo/edu/drill.py +++ b/src/halo/edu/drill.py @@ -20,11 +20,14 @@ from __future__ import annotations +import logging import re from halo.edu.corpus import module_version from halo.edu.models import DecisionPoint, DrillResult, ProcedureModule, StepGrade +logger = logging.getLogger(__name__) + def phrase_hits(phrase: str, text: str) -> bool: """Left word boundary always; right boundary for digit-final or short phrases. @@ -70,7 +73,10 @@ def _llm_adjudicate(point: DecisionPoint, answer: str) -> bool: ) try: result = llm.structured(prompt, schema) - except Exception: # noqa: BLE001 — fail closed: any LLM/parse error must not grant credit + except Exception as exc: # noqa: BLE001 — fail closed: any LLM/parse error must not grant credit + logger.warning( + "LLM adjudication unavailable (%s: %s); no credit granted", type(exc).__name__, exc + ) return False return result.get("credit") is True diff --git a/src/halo/edu/lookup.py b/src/halo/edu/lookup.py index ff41ad9..f8233d4 100644 --- a/src/halo/edu/lookup.py +++ b/src/halo/edu/lookup.py @@ -10,12 +10,15 @@ from __future__ import annotations +import logging import re from dataclasses import dataclass from halo.edu.corpus import load_corpus from halo.edu.models import ProcedureModule +logger = logging.getLogger(__name__) + _ALIAS_SUBSTRING = 3.0 _ALIAS_TOKEN = 2.0 _TEXT_TOKEN = 1.0 @@ -146,7 +149,10 @@ def route_with_claude(query: str) -> str | None: ) try: result = llm.structured(prompt, schema) - except Exception: # noqa: BLE001 — fail closed: any LLM/parse error routes to no match + except Exception as exc: # noqa: BLE001 — fail closed: any LLM/parse error routes to no match + logger.warning( + "LLM lookup unavailable (%s: %s); routing to no match", type(exc).__name__, exc + ) return None module_id = result.get("module_id") return module_id if module_id in ids else None diff --git a/src/halo/mci/census.py b/src/halo/mci/census.py index 2f522db..95bb365 100644 --- a/src/halo/mci/census.py +++ b/src/halo/mci/census.py @@ -55,8 +55,9 @@ def open_beds(self) -> int: def load_census(path: str | Path | None = None) -> Census: - p = Path(path or os.environ.get("HALO_CENSUS_PATH", DEFAULT_CENSUS_PATH)) - return _load_census_cached(str(p.resolve())) + if path is None: + path = os.environ.get("HALO_CENSUS_PATH") or DEFAULT_CENSUS_PATH + return _load_census_cached(str(Path(path).resolve())) @lru_cache(maxsize=4) diff --git a/src/halo/mci/extract.py b/src/halo/mci/extract.py index c29d2f5..ab91929 100644 --- a/src/halo/mci/extract.py +++ b/src/halo/mci/extract.py @@ -79,7 +79,7 @@ def extract_observations(note: str) -> tuple[Observations, dict[str, str]]: EXTRACTION_SCHEMA, system=SYSTEM, ) - values: dict[str, bool | int | None] = {} + values: dict[str, Any] = {} evidence: dict[str, str] = {} for name in _FIELDS: entry = data.get(name) diff --git a/src/halo/mci/panel.py b/src/halo/mci/panel.py index 313c8c2..2fe5e6f 100644 --- a/src/halo/mci/panel.py +++ b/src/halo/mci/panel.py @@ -80,8 +80,9 @@ class IdentityCues: def load_panel(path: str | Path | None = None) -> tuple[PanelPatient, ...]: - p = Path(path or os.environ.get("HALO_PANEL_PATH", DEFAULT_PANEL_PATH)) - return _load_panel_cached(str(p.resolve())) + if path is None: + path = os.environ.get("HALO_PANEL_PATH") or DEFAULT_PANEL_PATH + return _load_panel_cached(str(Path(path).resolve())) @lru_cache(maxsize=4) diff --git a/src/halo/mci/triage.py b/src/halo/mci/triage.py index 8a0652f..071e834 100644 --- a/src/halo/mci/triage.py +++ b/src/halo/mci/triage.py @@ -47,10 +47,13 @@ _RR_DISTRESS_THRESHOLD = 30 +def _invert(v: bool | None) -> bool | None: + return None if v is None else not v + + def _screen_answers(obs: Observations) -> tuple[dict[str, bool | None], tuple[str, ...]]: - invert = lambda v: None if v is None else not v derivations: list[str] = [] - no_distress = invert(obs.respiratory_distress) + no_distress = _invert(obs.respiratory_distress) if no_distress is None and obs.respiratory_rate is not None: no_distress = obs.respiratory_rate < _RR_DISTRESS_THRESHOLD comparator = "<" if no_distress else ">=" @@ -62,7 +65,7 @@ def _screen_answers(obs: Observations) -> tuple[dict[str, bool | None], tuple[st "obeys_commands": obs.obeys_commands, "peripheral_pulse": obs.peripheral_pulse, "no_respiratory_distress": no_distress, - "hemorrhage_controlled": invert(obs.major_hemorrhage_uncontrolled), + "hemorrhage_controlled": _invert(obs.major_hemorrhage_uncontrolled), } return answers, tuple(derivations)