From 0209e85d6a14386961450efbbaa0690ea982fa91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 16:23:33 +0000 Subject: [PATCH] feat(safety): Gad7AnxietyScorer (#243) Co-authored-by: shunqil --- CHANGELOG.md | 1 + README.md | 6 ++ SAFETY.md | 6 ++ assets/gad7_anxiety_demo.gif | Bin 0 -> 7008 bytes docs/guides/GAD7ANXIETYSCORER_GUIDE.md | 23 +++++ src/medagent/models.py | 17 ++++ src/medagent/safety/__init__.py | 2 + src/medagent/safety/gad7_anxiety_scorer.py | 82 ++++++++++++++++++ tests/unit/safety/test_gad7_anxiety_scorer.py | 43 +++++++++ 9 files changed, 180 insertions(+) create mode 100644 assets/gad7_anxiety_demo.gif create mode 100644 docs/guides/GAD7ANXIETYSCORER_GUIDE.md create mode 100644 src/medagent/safety/gad7_anxiety_scorer.py create mode 100644 tests/unit/safety/test_gad7_anxiety_scorer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f6e7e..0b5561e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- `Gad7AnxietyScorer` (`src/medagent/safety/gad7_anxiety_scorer.py`): research-only advisory score (SAFETY #243). Gap vs MDCalc / USPSTF GAD-7 anxiety severity screen. Distinct from `Phq9DepressionScorer` / `EpworthSleepinessScorer`. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 for narrative only. See `docs/guides/GAD7ANXIETYSCORER_GUIDE.md`. - `MorseFallScorer` (`src/medagent/safety/morse_fall_scorer.py`): research-only advisory score (SAFETY #241). Gap vs MDCalc / AHRQ Morse Fall Scale screen. Distinct from `News2EarlyWarningScorer` / `PewsEarlyWarningScorer`. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 for narrative only. See `docs/guides/MORSEFALLSCORER_GUIDE.md`. - `Phq9DepressionScorer` (`src/medagent/safety/phq9_depression_scorer.py`): research-only advisory score (SAFETY #240). Gap vs MDCalc / USPSTF PHQ-9 depression severity screen. Distinct from `EpworthSleepinessScorer` / `StopBangOsaScorer`. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 for narrative only. See `docs/guides/PHQ9DEPRESSIONSCORER_GUIDE.md`. - `BisapPancreatitisScorer` (`src/medagent/safety/bisap_pancreatitis_scorer.py`): research-only advisory score (SAFETY #239). Gap vs MDCalc / ACG BISAP pancreatitis severity screen. Distinct from `RansonPancreatitisScorer` / `MaddreyDiscriminantScorer`. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 for narrative only. See `docs/guides/BISAPPANCREATITISSCORER_GUIDE.md`. diff --git a/README.md b/README.md index 6156a2c..b42638d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,12 @@ Research-only advisory scorer. Gap vs MDCalc / AASLD FIB-4 liver fibrosis screen Research-only advisory scorer. Gap vs MDCalc / ACG BISAP pancreatitis severity screen. Optional narrative via GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2. +### Gad7AnxietyScorer + +![Gad7AnxietyScorer](assets/gad7_anxiety_demo.gif) + +Research-only advisory scorer. Gap vs MDCalc / USPSTF GAD-7 anxiety severity screen. Optional narrative via GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2. + ### MorseFallScorer ![MorseFallScorer](assets/morse_fall_demo.gif) diff --git a/SAFETY.md b/SAFETY.md index 70a2683..1373670 100644 --- a/SAFETY.md +++ b/SAFETY.md @@ -1283,6 +1283,12 @@ yields a `MorseFallRisk` with score, band, positive factors, severity, and rationale. RESEARCH USE ONLY — never modifies medications. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 for narrative summaries only. +### 3.243 Gad7Anxiety Scorer +`Gad7AnxietyScorer` computes an advisory score (MDCalc / USPSTF GAD-7 anxiety severity screen). Every call +yields a `Gad7AnxietyRisk` with score, band, positive factors, severity, and +rationale. RESEARCH USE ONLY — never modifies medications. Prefer GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2 +for narrative summaries only. + ## 4. Escalation Policy When the agent enters `ESCALATE` state: diff --git a/assets/gad7_anxiety_demo.gif b/assets/gad7_anxiety_demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..2046c8e5fc906acaa1f965357e40ec3c372a8c9c GIT binary patch literal 7008 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 literal 0 HcmV?d00001 diff --git a/docs/guides/GAD7ANXIETYSCORER_GUIDE.md b/docs/guides/GAD7ANXIETYSCORER_GUIDE.md new file mode 100644 index 0000000..13529dd --- /dev/null +++ b/docs/guides/GAD7ANXIETYSCORER_GUIDE.md @@ -0,0 +1,23 @@ +# Gad7AnxietyScorer Guide + +![Gad7AnxietyScorer](../../assets/gad7_anxiety_demo.gif) + +Research-only advisory scorer. Gap vs MDCalc / USPSTF GAD-7 anxiety severity screen. Distinct from `Phq9DepressionScorer` / `EpworthSleepinessScorer`. + +Optional narrative via **GPT-5.5 / Claude Sonnet 4.6 / Gemini 3.x / Kimi K2**. + +## Usage + +```python +from medagent.safety.gad7_anxiety_scorer import ( + Gad7AnxietyFactors, + Gad7AnxietyScorer, +) + +findings = Gad7AnxietyScorer().check(Gad7AnxietyFactors()) +print(findings[0].band) +``` + +## Safety + +RESEARCH USE ONLY. Never modifies medications. See `SAFETY.md` #243. diff --git a/src/medagent/models.py b/src/medagent/models.py index 6cceda9..716d2b9 100644 --- a/src/medagent/models.py +++ b/src/medagent/models.py @@ -5183,3 +5183,20 @@ class MorseFallRisk(BaseModel, frozen=True): ) severity: Severity rationale: str + + +class Gad7AnxietyRisk(BaseModel, frozen=True): + """Gad7AnxietyScorer finding. + + RESEARCH USE ONLY — advisory clinical score. Distinct from + Phq9DepressionScorer / EpworthSleepinessScorer. + Prefer. Never modifies medications. + """ + + score: int = Field(description="Score total") + band: str = Field(description="Risk band") + positive_factors: list[str] = Field( + default_factory=list, description="Factor names that contributed" + ) + severity: Severity + rationale: str diff --git a/src/medagent/safety/__init__.py b/src/medagent/safety/__init__.py index dfe8d5d..2ee7285 100644 --- a/src/medagent/safety/__init__.py +++ b/src/medagent/safety/__init__.py @@ -85,6 +85,7 @@ FluoroquinoloneWarfarinChecker, ) from medagent.safety.four_score_scorer import FourScoreScorer +from medagent.safety.gad7_anxiety_scorer import Gad7AnxietyScorer from medagent.safety.gcs_neurologic_status_scorer import GcsNeurologicStatusScorer from medagent.safety.gentamicin_level_trend_bridge import GentamicinLevelTrendBridge from medagent.safety.gentamicin_vancomycin_checker import GentamicinVancomycinChecker @@ -309,6 +310,7 @@ "FluoroquinoloneNsaidChecker", "FluoroquinoloneWarfarinChecker", "FourScoreScorer", + "Gad7AnxietyScorer", "GcsNeurologicStatusScorer", "GentamicinLevelTrendBridge", "GentamicinVancomycinChecker", diff --git a/src/medagent/safety/gad7_anxiety_scorer.py b/src/medagent/safety/gad7_anxiety_scorer.py new file mode 100644 index 0000000..1f8f2ee --- /dev/null +++ b/src/medagent/safety/gad7_anxiety_scorer.py @@ -0,0 +1,82 @@ +"""Gad7AnxietyScorer (research-only clinical score). + +Advisory scorer closing the MDCalc / USPSTF GAD-7 anxiety severity screen gap for +offline medical-ward agent pipelines. +Distinct from `Phq9DepressionScorer` / `EpworthSleepinessScorer`. Prefer +frontier LLMs for narrative summaries. +Never modifies medications and never performs network I/O. + +Simplified factor points for offline unit tests — not a full clinical chart. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from medagent.logging_config import get_logger +from medagent.models import Gad7AnxietyRisk, Severity + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class Gad7AnxietyFactors: + """Simplified Gad7AnxietyScorer factors.""" + + nervous_ok: int = 0 + control_worry_ok: int = 0 + worry_ok: int = 0 + relax_ok: int = 0 + restless_ok: int = 0 + irritable_ok: int = 0 + afraid_ok: int = 0 + + +class Gad7AnxietyScorer: + """Compute advisory Gad7AnxietyRisk findings.""" + + def check(self, factors: Gad7AnxietyFactors) -> list[Gad7AnxietyRisk]: + """Return one advisory finding.""" + + binaries = { + "nervous_ok": factors.nervous_ok, + "control_worry_ok": factors.control_worry_ok, + "worry_ok": factors.worry_ok, + "relax_ok": factors.relax_ok, + "restless_ok": factors.restless_ok, + "irritable_ok": factors.irritable_ok, + "afraid_ok": factors.afraid_ok, + } + + for name, points in binaries.items(): + if points not in {0, 1}: + raise ValueError(f"{name} must be 0 or 1") + + score = sum(binaries.values()) + positive = [k for k, v in binaries.items() if v > 0] + + if score <= 1: + band = "low" + severity = Severity.LOW + elif score <= max(2, len(binaries) // 2): + band = "intermediate" + severity = Severity.MODERATE + else: + band = "high" + severity = Severity.HIGH + + rationale = ( + "RESEARCH USE ONLY: Gad7AnxietyScorer total " + f"{score} ({band}) from factors {positive}. " + "This advisory score never modifies therapy; obtain qualified " + "clinical review before therapy changes." + ) + finding = Gad7AnxietyRisk( + score=int(score), + band=band, + positive_factors=positive, + severity=severity, + rationale=rationale, + ) + logger.info("gad7_anxiety_scorer_scored", score=score, band=band) + return [finding] diff --git a/tests/unit/safety/test_gad7_anxiety_scorer.py b/tests/unit/safety/test_gad7_anxiety_scorer.py new file mode 100644 index 0000000..ea85212 --- /dev/null +++ b/tests/unit/safety/test_gad7_anxiety_scorer.py @@ -0,0 +1,43 @@ +"""Tests for Gad7AnxietyScorer.""" + +from __future__ import annotations + +import pytest + +from medagent.safety.gad7_anxiety_scorer import ( + Gad7AnxietyFactors, + Gad7AnxietyScorer, +) + + +def test_low() -> None: + """Low band.""" + + findings = Gad7AnxietyScorer().check(Gad7AnxietyFactors()) + assert len(findings) == 1 + assert findings[0].band == "low" + assert findings[0].rationale + + +def test_high() -> None: + """High band.""" + + findings = Gad7AnxietyScorer().check( + Gad7AnxietyFactors( + nervous_ok=1, + control_worry_ok=1, + worry_ok=1, + relax_ok=1, + restless_ok=1, + irritable_ok=1, + afraid_ok=1, + ) + ) + assert findings[0].band == "high" + + +def test_invalid() -> None: + """Invalid factor raises.""" + + with pytest.raises(ValueError): + Gad7AnxietyScorer().check(Gad7AnxietyFactors(nervous_ok=2))