Spaces:
Running on Zero
Running on Zero
Sync submission-ready runtime and docs
Browse files- Makefile +13 -1
- README.md +173 -223
- docs/adversarial-review-action-items.md +4 -4
- docs/figment-workback-plan.md +5 -5
- docs/local_llama_eval_evidence.md +56 -6
- docs/model_parameter_evidence_ledger.md +6 -6
- docs/safety_statement.md +1 -1
- docs/smaller-llm-load-bearing-action-plan.md +57 -47
- docs/submission_checklist.md +12 -6
- docs/superpowers/plans/2026-06-05-figment-plan-additions.md +1 -1
- figment/eval_metrics.py +353 -4
- figment/focused_repair.py +83 -2
- figment/model_client.py +12 -3
- figment/navigator.py +262 -12
- figment/prompt_builder.py +132 -1
- figment/rules.py +39 -1
- figment/validators.py +16 -3
- scripts/run_eval.py +465 -24
- scripts/smoke_model_route.py +3 -3
- tests/test_eval_metrics.py +104 -1
- tests/test_eval_runner.py +395 -0
- tests/test_focused_repair.py +76 -0
- tests/test_navigator_safety.py +274 -5
- tests/test_prompt_builder_contract.py +116 -0
- tests/test_rules.py +42 -0
- tests/test_runtime_honesty.py +39 -0
- tests/test_validators_strict.py +42 -2
Makefile
CHANGED
|
@@ -4,7 +4,7 @@ HOST ?= 127.0.0.1
|
|
| 4 |
PORT ?= 7860
|
| 5 |
FIGMENT_SMOKE_TIMEOUT_SECONDS ?= 8
|
| 6 |
|
| 7 |
-
.PHONY: install test run run-hosted-demo build-fts smoke-model-route smoke-local-model-route
|
| 8 |
|
| 9 |
install:
|
| 10 |
$(PIP) install --upgrade pip
|
|
@@ -27,6 +27,12 @@ build-fts:
|
|
| 27 |
echo "scripts/build_fts.py not available yet; skipping FTS build."; \
|
| 28 |
fi
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
smoke-model-route:
|
| 31 |
PYTHON_DOTENV_DISABLED=true FIGMENT_MODE=canned MODEL_STACK=omni_native MODEL_BACKEND=canned AUDIO_BACKEND=none \
|
| 32 |
FIGMENT_SMOKE_ALLOW_NETWORK=false FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
|
|
@@ -35,3 +41,9 @@ smoke-model-route:
|
|
| 35 |
smoke-local-model-route:
|
| 36 |
FIGMENT_MODE=local MODEL_BACKEND=llama_cpp AUDIO_BACKEND=none FIGMENT_SMOKE_ALLOW_NETWORK=true FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
|
| 37 |
$(PYTHON) scripts/smoke_model_route.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
PORT ?= 7860
|
| 5 |
FIGMENT_SMOKE_TIMEOUT_SECONDS ?= 8
|
| 6 |
|
| 7 |
+
.PHONY: install test run run-hosted-demo build-fts audit-claims evidence-gates smoke-model-route smoke-local-model-route local-4b-evidence local-asr-evidence
|
| 8 |
|
| 9 |
install:
|
| 10 |
$(PIP) install --upgrade pip
|
|
|
|
| 27 |
echo "scripts/build_fts.py not available yet; skipping FTS build."; \
|
| 28 |
fi
|
| 29 |
|
| 30 |
+
audit-claims:
|
| 31 |
+
$(PYTHON) scripts/audit_submission_claims.py
|
| 32 |
+
|
| 33 |
+
evidence-gates:
|
| 34 |
+
$(PYTHON) scripts/evidence_gate_status.py --markdown; status=$$?; test $$status -eq 0 -o $$status -eq 2
|
| 35 |
+
|
| 36 |
smoke-model-route:
|
| 37 |
PYTHON_DOTENV_DISABLED=true FIGMENT_MODE=canned MODEL_STACK=omni_native MODEL_BACKEND=canned AUDIO_BACKEND=none \
|
| 38 |
FIGMENT_SMOKE_ALLOW_NETWORK=false FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
|
|
|
|
| 41 |
smoke-local-model-route:
|
| 42 |
FIGMENT_MODE=local MODEL_BACKEND=llama_cpp AUDIO_BACKEND=none FIGMENT_SMOKE_ALLOW_NETWORK=true FIGMENT_SMOKE_TIMEOUT_SECONDS=$(FIGMENT_SMOKE_TIMEOUT_SECONDS) \
|
| 43 |
$(PYTHON) scripts/smoke_model_route.py
|
| 44 |
+
|
| 45 |
+
local-4b-evidence:
|
| 46 |
+
PYTHON_DOTENV_DISABLED=true $(PYTHON) scripts/run_local_4b_evidence.py --base-url "$${LLAMA_BASE_URL:-http://127.0.0.1:8001/v1}"
|
| 47 |
+
|
| 48 |
+
local-asr-evidence:
|
| 49 |
+
PYTHON_DOTENV_DISABLED=true $(PYTHON) scripts/run_local_asr_evidence.py
|
README.md
CHANGED
|
@@ -12,87 +12,133 @@ python_version: 3.11
|
|
| 12 |
|
| 13 |
# Figment
|
| 14 |
|
| 15 |
-
**Protocol
|
| 16 |
|
| 17 |
-
Figment
|
| 18 |
|
| 19 |
-
>
|
| 20 |
|
| 21 |
-
|
| 22 |
-
- **Track target:** Backyard AI (solve a real problem for a specific, real person you know). Final evidence still needs a real trained responder using synthetic or de-identified scenarios; see [user test notes](docs/user_test_notes.md).
|
| 23 |
-
- **Built for:** a real disaster-response volunteer trained in disaster-response first aid and local protocol use; name withheld for privacy.
|
| 24 |
-
- **Model:** NVIDIA **Nemotron 3 Nano Omni 30B-A3B Reasoning** as the v1 default. The model-card body reports 31B total parameters; the workback plan and [parameter/evidence ledger](docs/model_parameter_evidence_ledger.md) track the HF-sidebar count ambiguity, local 4B + Parakeet story, adapter count status, and organizer-confirmation status.
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
|
| 43 |
-
2. **Risk Check** — deterministic red-flag rules fire **before** the LLM and set the minimum urgency floor (e.g. altered mental status, severe respiratory distress, chest pain, stroke signs, pregnancy bleeding, pediatric lethargy, severe dehydration signs, fever escalation criteria, wound infection escalation criteria).
|
| 44 |
-
3. **Protocol Guidance** — local retrieval returns 3–6 relevant protocol cards via SQLite FTS/BM25; the AI navigator selects candidate pathways, flags uncertainty, and plans missing observations.
|
| 45 |
-
4. **Navigator Output + Handoff** — shows candidate protocol pathways, a responder checklist, missing observations, an SBAR note, a referral summary, and source protocol-card IDs.
|
| 46 |
-
5. **Trace** — shows the full pipeline (input → rules → retrieval → prompt → output → validation) so judges and users can see *why*, not just *what*. This is the "show, don't tell" engine.
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
```text
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 63 |
```
|
| 64 |
|
| 65 |
-
|
| 66 |
|
| 67 |
-
- **Rules before
|
| 68 |
-
- **
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
-
|
| 83 |
-
| -------- | --- |
|
| 84 |
-
| `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16` | primary hosted/self-hosted Omni model ID |
|
| 85 |
-
| `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA API Catalog / NIM chat-completions model ID |
|
| 86 |
-
| `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | local text-navigation and first fine-tuning target |
|
| 87 |
-
| `nvidia/parakeet-rnnt-1.1b` | local/offline ASR target, enabled only after the local ASR gate passes |
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
|
| 97 |
```bash
|
| 98 |
python3 -m venv .venv
|
|
@@ -102,224 +148,128 @@ python -m pip install -r requirements.txt -r requirements-dev.txt
|
|
| 102 |
cp .env.example .env
|
| 103 |
```
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
Copy `.env.example` to `.env`, set the hosted model variables, and add `NVIDIA_API_KEY`. The hosted route uses the NVIDIA API Catalog OpenAI-compatible endpoint:
|
| 108 |
-
|
| 109 |
-
```dotenv
|
| 110 |
-
FIGMENT_MODE=hosted
|
| 111 |
-
MODEL_BACKEND=hosted_omni
|
| 112 |
-
MODEL_STACK=omni_native
|
| 113 |
-
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 114 |
-
NVIDIA_MODEL_ID=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 115 |
-
NVIDIA_API_KEY=nvapi-...
|
| 116 |
-
AUDIO_BACKEND=omni_native
|
| 117 |
-
ENABLE_AUDIO_INTAKE=true
|
| 118 |
-
```
|
| 119 |
-
|
| 120 |
-
Then run:
|
| 121 |
|
| 122 |
```bash
|
| 123 |
-
make
|
| 124 |
```
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
### 2. Run against a local OpenAI-compatible server
|
| 129 |
-
|
| 130 |
-
To target a local OpenAI-compatible server after the Nemotron 3 Nano 4B path is verified, use the full BF16 4B model as the canonical local text artifact:
|
| 131 |
|
| 132 |
```bash
|
| 133 |
-
|
| 134 |
-
--served-model-name nemotron3-nano-4b-bf16 \
|
| 135 |
-
--trust-remote-code \
|
| 136 |
-
--max-model-len 16384
|
| 137 |
```
|
| 138 |
|
| 139 |
-
|
| 140 |
|
| 141 |
-
```
|
| 142 |
-
|
| 143 |
```
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
To capture the local evidence bundle once the endpoint is live:
|
| 148 |
|
| 149 |
```bash
|
| 150 |
-
|
| 151 |
-
python3 scripts/run_local_4b_evidence.py \
|
| 152 |
-
--base-url http://127.0.0.1:8001/v1
|
| 153 |
```
|
| 154 |
|
| 155 |
-
|
| 156 |
|
| 157 |
-
|
| 158 |
|
| 159 |
```bash
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
--
|
| 163 |
-
--
|
|
|
|
| 164 |
```
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
### 3. Canned fallback
|
| 169 |
-
|
| 170 |
-
The scaffold can still run without any live model:
|
| 171 |
|
| 172 |
```dotenv
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
```
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
The submission Space target is under the **build-small-hackathon** Hugging Face org:
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
|
| 186 |
-
|
| 187 |
-
- Fine-tuned local-model archive: [build-small-hackathon/figment-finetuned-model-archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive)
|
| 188 |
|
| 189 |
-
|
| 190 |
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
demo_audio/ # three synthetic dictated-intake WAV clips for the demo
|
| 203 |
-
scripts/ # FTS build, smoke, and eval helpers
|
| 204 |
-
traces/ # exported demo traces
|
| 205 |
-
docs/ # field notes, model/dataset/safety cards, this plan
|
| 206 |
-
```
|
| 207 |
|
| 208 |
-
|
| 209 |
|
| 210 |
```text
|
| 211 |
-
app.py
|
| 212 |
-
figment/
|
| 213 |
-
|
| 214 |
-
data/
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
docs/
|
| 221 |
-
requirements.txt / requirements-dev.txt / .env.example
|
| 222 |
```
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
---
|
| 227 |
-
|
| 228 |
-
## Data & evaluation
|
| 229 |
-
|
| 230 |
-
- **Synthetic data, not memorized facts.** Future 5,000–10,000 candidate cases are generated by teacher models (Mistral/MiniMax, build-time only), cross-critiqued, and filtered by a deterministic validator down to ~2,000–4,000 kept examples. No real PHI is used.
|
| 231 |
-
- **Behavior, not knowledge.** Training teaches the model to cite cards, ask for missing info, escalate red flags, produce SBAR, and refuse unsafe requests.
|
| 232 |
-
- **Eval before training.** A 50-case hosted Omni eval now scores the model on measurable behavior, while the larger 50-100 case target thresholds remain the quality bar:
|
| 233 |
-
|
| 234 |
-
| Metric | Target |
|
| 235 |
-
| ------ | -----: |
|
| 236 |
-
| Valid JSON | ≥ 98% |
|
| 237 |
-
| Source-card citation rate | ≥ 95% |
|
| 238 |
-
| Red-flag recall | ≥ 90% |
|
| 239 |
-
| Unsupported diagnosis rate | 0% |
|
| 240 |
-
| Unsupported medication/dose rate | 0% |
|
| 241 |
-
| Missing-info question rate | ≥ 85% |
|
| 242 |
-
| SBAR factuality | ≥ 95% |
|
| 243 |
-
| Prompt-injection compliance failure | 0 critical |
|
| 244 |
-
|
| 245 |
-
Current measured hosted Omni results are in [hosted_omni_eval_results.md](docs/hosted_omni_eval_results.md). The baseline run reached **28/50** whole-output model competence with **22/50** full deterministic fallback and **50/50** final validation. The load-bearing follow-up reached **31/50** whole-output model competence, **8/50** full fallback, **480/650** model-retained fields, **170/650** deterministic patches, and **50/50** final validation. Final validation is application safety, not pure model competence; deterministic fallback and deterministic patches are reported separately and cannot inflate model scores.
|
| 246 |
|
| 247 |
-
|
| 248 |
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
-
|
| 252 |
-
|
| 253 |
-
## Safety & non-goals
|
| 254 |
|
| 255 |
-
Figment
|
| 256 |
|
| 257 |
-
-
|
| 258 |
-
-
|
| 259 |
-
-
|
| 260 |
-
-
|
| 261 |
-
-
|
| 262 |
-
-
|
| 263 |
-
- **act autonomously** — every output is advisory and requires human judgment.
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
---
|
| 268 |
|
| 269 |
-
##
|
| 270 |
|
| 271 |
| Artifact | License |
|
| 272 |
-
| ---
|
| 273 |
-
| Model / adapter | inherits the NVIDIA Nemotron model license; cite exact upstream terms in the model card |
|
| 274 |
-
| Synthetic dataset | CC-BY-4.0 |
|
| 275 |
| Code | [Apache-2.0](LICENSE) |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
---
|
| 280 |
-
|
| 281 |
-
## Demo cases
|
| 282 |
-
|
| 283 |
-
Three canonical cases drive the demo:
|
| 284 |
-
|
| 285 |
-
1. **Pediatric dehydration** — missing vitals, urgent red flags, asks next questions, produces a referral note.
|
| 286 |
-
2. **Wound infection after disaster injury** — protocol retrieval, avoids antibiotic overreach, recommends escalation criteria, clean documentation.
|
| 287 |
-
3. **Pregnancy danger sign** — deterministic red-flag override, immediate escalation, minimal model freelancing.
|
| 288 |
-
|
| 289 |
-
The Intake tab includes click-to-load audio examples for all three cases when `data/demo_audio/*.wav` is present. These are synthetic Voxtral-generated dictated-intake clips; they are not real patient audio.
|
| 290 |
-
|
| 291 |
-
---
|
| 292 |
-
|
| 293 |
-
## Hackathon
|
| 294 |
-
|
| 295 |
-
Built for the **[Build Small Hackathon](docs/build-small-hackathon-org-card.md)** (Gradio · Hugging Face), which caps models at 32B parameters and requires a Gradio app hosted as a Hugging Face Space plus a demo video and social post.
|
| 296 |
-
|
| 297 |
-
Submission claims are evidence-gated:
|
| 298 |
-
|
| 299 |
-
| Claim / badge area | Current status | Proof needed before claiming achieved |
|
| 300 |
-
| ------------------ | -------------- | ------------------------------------- |
|
| 301 |
-
| Hosted Gradio Space | Runnable in no-secret canned-fallback mode; live hosted-model demo trace still proof-needed | Public Space app files present, cold boot, typed intake run, trace showing `raw_route=canned`, `final_route=canned_backend`, `validation_status=passed`, and `raw_audio_stored=false` |
|
| 302 |
-
| Backyard AI | Targeted / proof-needed | A real trained responder using synthetic or de-identified scenarios, recorded in [user test notes](docs/user_test_notes.md) |
|
| 303 |
-
| Off the Grid | Targeted, not yet proven | Recorded no-cloud run using either self-hosted Omni on adequate local hardware or the smaller verified local stack |
|
| 304 |
-
| Llama Champion | Targeted, not yet proven | Working eligible local model route with trace/eval evidence |
|
| 305 |
-
| Sharing is Caring | Targeted / proof-needed | Public Space, repo, demo video, and social post links |
|
| 306 |
-
| Well-Tuned | Stretch / proof-needed | Eval harness plus measured improvement from tuning or an adapter, not fallback output |
|
| 307 |
-
| Field Notes | Tentative / proof-needed | Submission rules confirmation plus field-note artifact |
|
| 308 |
-
| Off-Brand | Targeted / proof-needed | Final demo/story asset aligned to organizer criteria |
|
| 309 |
-
|
| 310 |
-
---
|
| 311 |
|
| 312 |
## Acknowledgements
|
| 313 |
|
| 314 |
-
-
|
| 315 |
-
|
| 316 |
-
---
|
| 317 |
-
|
| 318 |
-
## Disclaimer
|
| 319 |
-
|
| 320 |
-
Figment is a **prototype for trained responders**, not medical advice and not a medical device. It does not diagnose or prescribe. Protocol cards are prototypes derived from public guideline concepts, **not** clinical guidelines. Always rely on qualified clinical judgment and local protocols.
|
| 321 |
-
|
| 322 |
-
<!-- TODO before submission:
|
| 323 |
-
- Add a LICENSE file.
|
| 324 |
-
- Add the demo video + social post links.
|
| 325 |
-
-->
|
|
|
|
| 12 |
|
| 13 |
# Figment
|
| 14 |
|
| 15 |
+
**Protocol navigation for trained responders in low-connectivity clinics and disaster response.**
|
| 16 |
|
| 17 |
+
Figment turns messy field intake into a card-cited protocol workflow: confirm the facts, run deterministic danger-sign rules, retrieve local protocol cards, ask a small model for bounded navigation fields, validate or repair the output, and show a trace of what happened.
|
| 18 |
|
| 19 |
+
> **Safety boundary:** Figment is a prototype, not a medical device. It does not diagnose, prescribe, dose medication, autonomously triage, or replace a trained responder, supervisor, clinician, or local protocol.
|
| 20 |
|
| 21 |
+
## Current Snapshot
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
| Surface | Current evidence | What it means | Boundary |
|
| 24 |
+
| --- | --- | --- | --- |
|
| 25 |
+
| Public Space | [build-small-hackathon/figment](https://huggingface.co/spaces/build-small-hackathon/figment) is `RUNNING` and served HTTP 200 when checked on 2026-06-14 at Space commit `ea4d971be2b8d4e1841648e5a9adc5142d16a299`. | The Gradio app is deployable and reachable. | The public no-secret route may use the labeled canned fallback; that is deployment proof, not live hosted-model proof. |
|
| 26 |
+
| Hosted Omni eval | `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, and `50/50` final validation. | Hosted Omni can carry bounded fields, and the app can keep outputs inside the safety contract. | `50/50` final validation is app safety after validation, repair, and fallback. It is not pure model performance. |
|
| 27 |
+
| 4B LoRA system eval | v14p repair-union on the corrected 150-case field-workflow holdout: `150/150` competence, `150/150` expected labels, `150/150` final validation, `0` deterministic patches, `0` fallback. Raw first-pass success is `146/150`; `4/150` cases close through focused model repair. | The strongest documented small-model result is model-owned output plus model repair on a synthetic/de-identified holdout. | This is not clinical validation, target-user validation, local ASR proof, or proof that raw first-pass output solved every case. |
|
| 28 |
+
| Public artifacts | [model archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive) and [eval/training dataset](https://huggingface.co/datasets/build-small-hackathon/figment-eval-traces). | Versioned BF16/GGUF model artifacts, synthetic corpora, eval traces, and summaries are inspectable outside this checkout. | Generated `traces/`, `data/finetune/`, weights, and checkpoint folders are intentionally not part of a clean clone. |
|
| 29 |
|
| 30 |
+
Final submission claims are evidence-gated. Before changing public copy, run:
|
| 31 |
|
| 32 |
+
```bash
|
| 33 |
+
make audit-claims PYTHON=.venv/bin/python
|
| 34 |
+
make evidence-gates PYTHON=.venv/bin/python
|
| 35 |
+
```
|
| 36 |
|
| 37 |
+
## Why Figment Exists
|
| 38 |
|
| 39 |
+
When a rural clinic, mobile unit, shelter, or disaster site loses connectivity, the work does not become simpler. Protocol binders still matter, but they do not ask follow-up questions, organize missing observations, or draft a clean handoff.
|
| 40 |
|
| 41 |
+
Figment is built as a restrained protocol binder that can talk back. It does not try to be an AI clinician. Its job is narrower:
|
| 42 |
|
| 43 |
+
- preserve deterministic red-flag floors;
|
| 44 |
+
- cite the protocol cards it used;
|
| 45 |
+
- ask for missing observations;
|
| 46 |
+
- produce a responder checklist;
|
| 47 |
+
- draft an SBAR-style handoff;
|
| 48 |
+
- expose whether each field came from raw model output, model repair, or deterministic fallback.
|
| 49 |
|
| 50 |
+
That separation is the core project claim: useful small-model systems get safer and easier to improve when the model's job is narrow enough to inspect.
|
| 51 |
|
| 52 |
+
## User Workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
Figment's Gradio Server app is organized around the field workflow:
|
| 55 |
+
|
| 56 |
+
1. **Intake** captures setting, age, pregnancy status, chief concern, symptoms, vitals, allergies, medications, available supplies, and a free-text responder note. Audio intake is only a draft layer; typed or edited facts must be confirmed before rules or navigation run.
|
| 57 |
+
2. **Risk Check** runs deterministic red-flag rules before model navigation and sets the minimum urgency floor.
|
| 58 |
+
3. **Protocol Guidance** retrieves 3-6 local protocol cards through SQLite FTS/BM25, with JSON fallback search.
|
| 59 |
+
4. **Navigator Output + Handoff** returns candidate pathways, uncertainty notes, missing observations, responder checklist, source cards, plain-language script, and SBAR handoff.
|
| 60 |
+
5. **Trace** shows input, rules, retrieval, prompt context, raw output, repair, fallback, validation, route labels, field provenance, and trace hashes.
|
| 61 |
|
| 62 |
+
Three included demo scenarios cover pediatric dehydration, wound infection after disaster injury, and pregnancy danger signs. The demo audio clips are synthetic and are not real patient audio.
|
| 63 |
+
|
| 64 |
+
## Architecture
|
| 65 |
|
| 66 |
```text
|
| 67 |
+
app.py
|
| 68 |
+
-> confirmed structured intake
|
| 69 |
+
-> figment/rules.py deterministic danger-sign rules
|
| 70 |
+
-> figment/retrieval.py local protocol-card retrieval
|
| 71 |
+
-> figment/prompt_builder.py bounded navigator prompt
|
| 72 |
+
-> figment/model_client.py hosted Omni, local OpenAI-compatible, or canned route
|
| 73 |
+
-> figment/navigator.py raw output, scaffold, repair, fallback orchestration
|
| 74 |
+
-> figment/validators.py schema, citations, urgency floor, safety checks
|
| 75 |
+
-> figment/field_provenance.py model_raw / model_repaired / deterministic_fallback labels
|
| 76 |
+
-> figment/eval_metrics.py app-safety and model-contribution metrics
|
| 77 |
+
-> figment/trace.py auditable route and trace export
|
| 78 |
```
|
| 79 |
|
| 80 |
+
The safety pattern is deliberate:
|
| 81 |
|
| 82 |
+
- **Rules before model:** danger signs set an urgency floor the model cannot lower.
|
| 83 |
+
- **Cards as source of truth:** the model must stay inside retrieved protocol cards and cite card IDs.
|
| 84 |
+
- **Human confirmation:** audio-derived fields are provisional until the responder confirms them.
|
| 85 |
+
- **Scoped repair:** when an output fails validation, focused repair targets a bounded failure class rather than asking the model to improvise a new answer.
|
| 86 |
+
- **Visible fallback:** deterministic patches and full fallback are counted separately from model competence.
|
| 87 |
|
| 88 |
+
## Models
|
| 89 |
|
| 90 |
+
Figment supports three runtime routes:
|
| 91 |
|
| 92 |
+
| Route | Backend | Use |
|
| 93 |
+
| --- | --- | --- |
|
| 94 |
+
| Canned fallback | `MODEL_BACKEND=canned` | No-secret app smoke, UI development, honest fallback traces. |
|
| 95 |
+
| Hosted Omni | `MODEL_BACKEND=hosted_omni` with `NVIDIA_API_KEY` | Live hosted demo and hosted eval path using `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`. |
|
| 96 |
+
| Local OpenAI-compatible | `MODEL_BACKEND=llama_cpp` with `LLAMA_BASE_URL` | Local text-navigation route for the 4B BF16/GGUF artifacts and local evidence bundles. |
|
| 97 |
|
| 98 |
+
The Build Small constraint is <=32B total parameters. The hosted Omni path is tracked with a parameter-count caveat: the NVIDIA model-card body reports 31B total parameters, while sidebar counts have differed. The 4B BF16 base model, `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`, is the local text-navigation training target.
|
| 99 |
|
| 100 |
+
Parakeet ASR remains a gated local-audio path. Artifact presence alone is not enough; local ASR needs provider-output evidence before any local-audio claim is upgraded.
|
| 101 |
|
| 102 |
+
## Evaluation
|
| 103 |
|
| 104 |
+
Figment reports app safety and model contribution separately.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
| Metric | Meaning |
|
| 107 |
+
| --- | --- |
|
| 108 |
+
| Final validation | Did the final app output satisfy schema, citations, urgency floors, and safety checks? |
|
| 109 |
+
| Competence success | Did the configured model path, including allowed model repair, produce a competent case result? |
|
| 110 |
+
| Raw configured-model success | Did first-pass model output work without repair? |
|
| 111 |
+
| Focused repair success | Did a scoped model repair close a bounded failure? |
|
| 112 |
+
| Deterministic patch count | How many final fields came from code scaffolding rather than model output? |
|
| 113 |
+
| Full fallback use | Did the app abandon the model route and use deterministic fallback output? |
|
| 114 |
+
| Expected-label success | Did the final output preserve case-level target labels such as urgency, source cards, and red flags? |
|
| 115 |
|
| 116 |
+
Selected lineage on the 150-case field-workflow holdout:
|
| 117 |
+
|
| 118 |
+
| Run | Competence | Raw success | Repair | Expected labels | Final validation | Fallback | Deterministic patches | Lesson |
|
| 119 |
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
| 120 |
+
| v3 | 107/150 | 93/150 | 14 | 0/150 | 148/150 | 2 | 114 | First strong field-workflow jump, but weak observation ownership. |
|
| 121 |
+
| v5 | 2/150 | 2/150 | 0 | 150/150 | 150/150 | 0 | 302 | The app passed; deterministic scaffolding carried too much. |
|
| 122 |
+
| v6 | 142/150 | 142/150 | 0 | 146/150 | 150/150 | 0 | 21 | Targeted replay and delta rows moved model-owned behavior. |
|
| 123 |
+
| v7 corrected | 148/150 | 148/150 | 0 | 147/150 | 150/150 | 0 | 3 | Remaining failures became narrow and inspectable. |
|
| 124 |
+
| v10 | 147/150 | 147/150 | 0 | 150/150 | 150/150 | 0 | 6 | Some misses resisted generic corpus growth. |
|
| 125 |
+
| v14p repair-union | 150/150 | 146/150 | 4 | 150/150 | 150/150 | 0 | 0 | Focused model repair closed the remaining corrected-holdout cases. |
|
| 126 |
+
|
| 127 |
+
The corrected scoring view changes 6 cases from the original frozen holdout and preserves the correction manifest in `data/eval/field_workflow_holdout_v1_corrected_scoring_manifest.json`. The point is not to train around a bad target; it is to leave a receipt when a benchmark rule is corrected.
|
| 128 |
+
|
| 129 |
+
## Public Artifacts
|
| 130 |
|
| 131 |
+
- Demo Space: [build-small-hackathon/figment](https://huggingface.co/spaces/build-small-hackathon/figment)
|
| 132 |
+
- Runtime URL: [build-small-hackathon-figment.hf.space](https://build-small-hackathon-figment.hf.space/)
|
| 133 |
+
- Model archive: [build-small-hackathon/figment-finetuned-model-archive](https://huggingface.co/build-small-hackathon/figment-finetuned-model-archive)
|
| 134 |
+
- Eval traces and SFT corpora: [build-small-hackathon/figment-eval-traces](https://huggingface.co/datasets/build-small-hackathon/figment-eval-traces)
|
| 135 |
+
- Safety statement: [docs/safety_statement.md](docs/safety_statement.md)
|
| 136 |
+
- Submission gates: [docs/submission_checklist.md](docs/submission_checklist.md)
|
| 137 |
+
- Build Small org card: [docs/build-small-hackathon-org-card.md](docs/build-small-hackathon-org-card.md)
|
| 138 |
|
| 139 |
+
The model archive contains the v1 pilot, v2-v4 checkpoints, and versioned v5-v14p BF16/GGUF artifacts. The dataset repo contains scored hosted/local traces plus synthetic SFT configs `figment_sft_v1` through `figment_sft_v14p`.
|
| 140 |
+
|
| 141 |
+
## Quickstart
|
| 142 |
|
| 143 |
```bash
|
| 144 |
python3 -m venv .venv
|
|
|
|
| 148 |
cp .env.example .env
|
| 149 |
```
|
| 150 |
|
| 151 |
+
Or use the Makefile after creating the venv:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
```bash
|
| 154 |
+
make install PYTHON=.venv/bin/python
|
| 155 |
```
|
| 156 |
|
| 157 |
+
Run the no-secret app path:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
```bash
|
| 160 |
+
MODEL_BACKEND=canned make run PYTHON=.venv/bin/python
|
|
|
|
|
|
|
|
|
|
| 161 |
```
|
| 162 |
|
| 163 |
+
Run the hosted Omni demo path:
|
| 164 |
|
| 165 |
+
```bash
|
| 166 |
+
NVIDIA_API_KEY=nvapi-... make run-hosted-demo PYTHON=.venv/bin/python
|
| 167 |
```
|
| 168 |
|
| 169 |
+
Run tests:
|
|
|
|
|
|
|
| 170 |
|
| 171 |
```bash
|
| 172 |
+
PYTHONPATH=. .venv/bin/pytest tests -q
|
|
|
|
|
|
|
| 173 |
```
|
| 174 |
|
| 175 |
+
## Local Model Route
|
| 176 |
|
| 177 |
+
Start a local OpenAI-compatible server, for example with a downloaded GGUF:
|
| 178 |
|
| 179 |
```bash
|
| 180 |
+
llama-server \
|
| 181 |
+
-m /path/to/figment-sft-v14p-lora-merged-bf16.bf16.gguf \
|
| 182 |
+
--host 127.0.0.1 \
|
| 183 |
+
--port 8001 \
|
| 184 |
+
-c 16384
|
| 185 |
```
|
| 186 |
|
| 187 |
+
Point Figment at it:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
```dotenv
|
| 190 |
+
FIGMENT_MODE=local
|
| 191 |
+
MODEL_STACK=local_4b_parakeet
|
| 192 |
+
MODEL_BACKEND=llama_cpp
|
| 193 |
+
LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16
|
| 194 |
+
LLAMA_BASE_URL=http://127.0.0.1:8001/v1
|
| 195 |
+
AUDIO_BACKEND=none
|
| 196 |
```
|
| 197 |
|
| 198 |
+
Capture an evidence bundle once the endpoint is live:
|
|
|
|
|
|
|
| 199 |
|
| 200 |
+
```bash
|
| 201 |
+
make smoke-local-model-route PYTHON=.venv/bin/python
|
| 202 |
+
make local-4b-evidence PYTHON=.venv/bin/python
|
| 203 |
+
```
|
| 204 |
|
| 205 |
+
These commands write local evidence under `traces/`, which is generated and ignored by git.
|
| 206 |
|
| 207 |
+
## Training And Modal Eval
|
|
|
|
| 208 |
|
| 209 |
+
Modal scripts are included for the full train, merge, upload, and eval loop. They require Modal auth, appropriate secrets, and enough storage for model artifacts.
|
| 210 |
|
| 211 |
+
```bash
|
| 212 |
+
.venv/bin/modal run modal/finetune_figment_nemotron.py --smoke true
|
| 213 |
+
.venv/bin/modal run modal/finetune_figment_nemotron.py
|
| 214 |
+
.venv/bin/modal run modal/eval_figment_nemotron.py
|
| 215 |
+
```
|
| 216 |
|
| 217 |
+
The high-level loop is:
|
| 218 |
|
| 219 |
+
1. generate or replay synthetic harness-shaped rows;
|
| 220 |
+
2. verify rows against the real prompt, validators, retrieval, and expected-label rules;
|
| 221 |
+
3. stage train/validation splits for Modal;
|
| 222 |
+
4. train a LoRA adapter on H100;
|
| 223 |
+
5. merge into BF16, convert to GGUF, and serve locally;
|
| 224 |
+
6. rerun the field-workflow holdout;
|
| 225 |
+
7. compare raw, repair, patch, fallback, expected-label, final-validation, and latency metrics.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
+
## Repository Layout
|
| 228 |
|
| 229 |
```text
|
| 230 |
+
app.py Gradio Server app and API surface
|
| 231 |
+
figment/ config, schemas, rules, retrieval, prompt, model clients,
|
| 232 |
+
navigator, validators, repair, provenance, traces
|
| 233 |
+
data/protocol_cards/ 10 prototype protocol cards
|
| 234 |
+
data/eval/ hosted and field-workflow eval cases plus manifests
|
| 235 |
+
data/demo_audio/ synthetic dictated-intake demo clips
|
| 236 |
+
scripts/ eval, smoke, evidence, generation, merge, and claim audit helpers
|
| 237 |
+
modal/ Modal training, merge, upload, and H100 eval entrypoints
|
| 238 |
+
tests/ regression tests for runtime, safety, eval, data plans, and gates
|
| 239 |
+
docs/ plans, evidence notes, safety, submission, and public drafts
|
|
|
|
| 240 |
```
|
| 241 |
|
| 242 |
+
Generated or heavyweight paths such as `traces/`, `data/finetune/`, `tools/`, checkpoints, weights, and local artifacts are intentionally ignored. Use the public Hub archives for shareable model, trace, and corpus artifacts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
## Data Handling
|
| 245 |
|
| 246 |
+
- Demo and eval scenarios are synthetic or de-identified.
|
| 247 |
+
- Do not enter real PHI into the hosted demo.
|
| 248 |
+
- Hosted mode may send synthetic or de-identified text/audio to the configured hosted endpoint.
|
| 249 |
+
- Local mode is intended to keep runtime inputs on the local machine.
|
| 250 |
+
- Figment traces do not retain raw audio bytes, uploaded filenames, local secrets, or unnecessary identifying details.
|
| 251 |
|
| 252 |
+
## Safety And Non-Goals
|
|
|
|
|
|
|
| 253 |
|
| 254 |
+
Figment will not:
|
| 255 |
|
| 256 |
+
- diagnose a condition as fact;
|
| 257 |
+
- prescribe medication or provide doses beyond cited protocol-card content;
|
| 258 |
+
- replace clinician, supervisor, or trained responder judgment;
|
| 259 |
+
- hide fallback, deterministic patches, or model repair;
|
| 260 |
+
- use unconfirmed audio fields for final navigation;
|
| 261 |
+
- present local/off-grid, local ASR, target-user, or final submission claims without the corresponding evidence gate.
|
|
|
|
| 262 |
|
| 263 |
+
See [docs/safety_statement.md](docs/safety_statement.md) for the fuller intended-use and non-goal statement.
|
|
|
|
|
|
|
| 264 |
|
| 265 |
+
## License
|
| 266 |
|
| 267 |
| Artifact | License |
|
| 268 |
+
| --- | --- |
|
|
|
|
|
|
|
| 269 |
| Code | [Apache-2.0](LICENSE) |
|
| 270 |
+
| Synthetic/de-identified dataset artifacts | CC-BY-4.0 where published |
|
| 271 |
+
| Model artifacts | NVIDIA Nemotron Open Model License inherited from `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
## Acknowledgements
|
| 274 |
|
| 275 |
+
Figment was built for the [Build Small Hackathon](docs/build-small-hackathon-org-card.md), hosted by Gradio and Hugging Face, with NVIDIA and Modal central to the model and training loop. It also depends on Gradio Server, Hugging Face Hub, Modal, llama.cpp-compatible serving, and the small-model debugging discipline made visible by the eval traces.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/adversarial-review-action-items.md
CHANGED
|
@@ -8,7 +8,7 @@ Important nuance: the current Omni-first architecture can technically support an
|
|
| 8 |
|
| 9 |
## 1. Make the Hugging Face Space actually runnable
|
| 10 |
|
| 11 |
-
Current
|
| 12 |
|
| 13 |
Fix direction:
|
| 14 |
- Push the full Gradio app, requirements, protocol cards, and demo assets to `build-small-hackathon/figment`.
|
|
@@ -16,9 +16,9 @@ Fix direction:
|
|
| 16 |
- Align Space metadata and dependency versions with the local repo.
|
| 17 |
|
| 18 |
Done when:
|
| 19 |
-
- The public Space boots to the Figment Gradio UI.
|
| 20 |
-
- The Space can run typed intake with canned fallback and trace labeling when secrets are absent.
|
| 21 |
-
- Hosted Omni mode works when secrets are present, or failure is clearly labeled.
|
| 22 |
|
| 23 |
## 2. Make live model contribution visible
|
| 24 |
|
|
|
|
| 8 |
|
| 9 |
## 1. Make the Hugging Face Space actually runnable
|
| 10 |
|
| 11 |
+
Current status: the Space now cold-boots in no-secret canned-fallback mode. On 2026-06-07 the public Space API reported `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, and `app.py` present; the Space URL served HTTP 200. A public Gradio API smoke loaded typed intake, fired deterministic pediatric-dehydration escalation, retrieved protocol cards, and returned an honestly labeled `canned_backend` trace with `validation_status=passed` and `raw_audio_stored=false`.
|
| 12 |
|
| 13 |
Fix direction:
|
| 14 |
- Push the full Gradio app, requirements, protocol cards, and demo assets to `build-small-hackathon/figment`.
|
|
|
|
| 16 |
- Align Space metadata and dependency versions with the local repo.
|
| 17 |
|
| 18 |
Done when:
|
| 19 |
+
- [x] The public Space boots to the Figment Gradio UI.
|
| 20 |
+
- [x] The Space can run typed intake with canned fallback and trace labeling when secrets are absent.
|
| 21 |
+
- [ ] Hosted Omni mode works when secrets are present, or failure is clearly labeled in a final demo trace.
|
| 22 |
|
| 23 |
## 2. Make live model contribution visible
|
| 24 |
|
docs/figment-workback-plan.md
CHANGED
|
@@ -12,7 +12,7 @@ The hackathon target:
|
|
| 12 |
|
| 13 |
> Ship a polished Gradio Space by **June 15**, with a hosted Nemotron Omni-powered app, a local/offline Nemotron 3 Nano 4B + Parakeet path, an open synthetic dataset if time allows, demo traces, and a field-notes writeup. Fine-tuning should target the smaller 4B local model first; Omni fine-tuning is deferred unless the runtime demo, safety validation, local/offline proof, and 4B adapter story are already green.
|
| 14 |
|
| 15 |
-
Architecture note: **Nemotron Omni remains the hosted v1 default and submission demo story**. The
|
| 16 |
|
| 17 |
The Build Small Hackathon rules require models at or below **32B parameters**, a **Gradio app hosted as a Hugging Face Space**, plus a Space link, demo video, and social post for submission. The bonus badges you should target are **Off the Grid**, **Well-Tuned**, **Llama Champion**, **Sharing is Caring**, **Field Notes**, and, if time allows, **Off-Brand** custom UI. ([Hugging Face][1])
|
| 18 |
|
|
@@ -101,7 +101,7 @@ Draft on June 5, finalize June 14. Required elements:
|
|
| 101 |
|
| 102 |
# 2. Hardware and runtime plan
|
| 103 |
|
| 104 |
-
Hosted Omni is a clean demo story but a harder
|
| 105 |
|
| 106 |
Official Omni weight/runtime facts:
|
| 107 |
|
|
@@ -124,7 +124,7 @@ The 4B model is text-only, so it does not replace hosted Omni's native audio/mul
|
|
| 124 |
|
| 125 |
Use:
|
| 126 |
|
| 127 |
-
* **NVIDIA-Nemotron-3-Nano-4B-BF16** as the
|
| 128 |
* **Parakeet RNNT 1.1B** as the offline ASR target
|
| 129 |
* **16k context** for normal usage, despite the model card's much longer maximum context
|
| 130 |
* **8k context** fallback if latency or memory gets weird
|
|
@@ -324,7 +324,7 @@ Deliberate scope boundaries, stated up front so judges and users know exactly wh
|
|
| 324 |
* It will **not prescribe or dose medication** — drug doses appear only if a cited protocol card contains them.
|
| 325 |
* It will **not replace a clinician** — it supports escalation and documentation; the trained responder remains the decision-maker.
|
| 326 |
* It is **not for untrained users** — the intended user is a trained responder (see the safety statement in §1).
|
| 327 |
-
* It does **not store PHI** — local/offline mode keeps patient inputs on
|
| 328 |
* It is **not autonomous** — every output is advisory and requires human judgment.
|
| 329 |
* It will **not override deterministic danger signs** — red-flag rules set the minimum urgency floor.
|
| 330 |
* It will **not invent protocol pathways, treatments, or referral criteria** beyond cited cards.
|
|
@@ -561,7 +561,7 @@ Offline ASR: Parakeet RNNT is CC-BY-4.0; include attribution if the local AS
|
|
| 561 |
|
| 562 |
Data handling:
|
| 563 |
|
| 564 |
-
* Local/offline mode processes patient inputs
|
| 565 |
* Hosted Space mode may transmit text/audio inputs to the hosted Omni endpoint; use synthetic or de-identified demo inputs only in hosted mode.
|
| 566 |
* Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
|
| 567 |
* Audio demo clips are synthetic responder dictation only; do not use real patient audio.
|
|
|
|
| 12 |
|
| 13 |
> Ship a polished Gradio Space by **June 15**, with a hosted Nemotron Omni-powered app, a local/offline Nemotron 3 Nano 4B + Parakeet path, an open synthetic dataset if time allows, demo traces, and a field-notes writeup. Fine-tuning should target the smaller 4B local model first; Omni fine-tuning is deferred unless the runtime demo, safety validation, local/offline proof, and 4B adapter story are already green.
|
| 14 |
|
| 15 |
+
Architecture note: **Nemotron Omni remains the hosted v1 default and submission demo story**. The local/off-grid target is now **NVIDIA Nemotron 3 Nano 4B BF16** for text navigation and fine-tuning, paired with **Parakeet RNNT 1.1B** for offline ASR. The old 30B text-plus-Parakeet split is no longer the preferred local path.
|
| 16 |
|
| 17 |
The Build Small Hackathon rules require models at or below **32B parameters**, a **Gradio app hosted as a Hugging Face Space**, plus a Space link, demo video, and social post for submission. The bonus badges you should target are **Off the Grid**, **Well-Tuned**, **Llama Champion**, **Sharing is Caring**, **Field Notes**, and, if time allows, **Off-Brand** custom UI. ([Hugging Face][1])
|
| 18 |
|
|
|
|
| 101 |
|
| 102 |
# 2. Hardware and runtime plan
|
| 103 |
|
| 104 |
+
Hosted Omni is a clean demo story but a harder local runtime story. Treat this honestly: build the app around hosted Omni first, then make the local/off-grid proof lighter with Nemotron 3 Nano 4B plus Parakeet.
|
| 105 |
|
| 106 |
Official Omni weight/runtime facts:
|
| 107 |
|
|
|
|
| 124 |
|
| 125 |
Use:
|
| 126 |
|
| 127 |
+
* **NVIDIA-Nemotron-3-Nano-4B-BF16** as the local text-navigation and fine-tuning target
|
| 128 |
* **Parakeet RNNT 1.1B** as the offline ASR target
|
| 129 |
* **16k context** for normal usage, despite the model card's much longer maximum context
|
| 130 |
* **8k context** fallback if latency or memory gets weird
|
|
|
|
| 324 |
* It will **not prescribe or dose medication** — drug doses appear only if a cited protocol card contains them.
|
| 325 |
* It will **not replace a clinician** — it supports escalation and documentation; the trained responder remains the decision-maker.
|
| 326 |
* It is **not for untrained users** — the intended user is a trained responder (see the safety statement in §1).
|
| 327 |
+
* It does **not store PHI** — local/offline mode keeps patient inputs on the local machine, hosted demo mode uses synthetic/de-identified inputs only, and published traces never include raw audio (see §5).
|
| 328 |
* It is **not autonomous** — every output is advisory and requires human judgment.
|
| 329 |
* It will **not override deterministic danger signs** — red-flag rules set the minimum urgency floor.
|
| 330 |
* It will **not invent protocol pathways, treatments, or referral criteria** beyond cited cards.
|
|
|
|
| 561 |
|
| 562 |
Data handling:
|
| 563 |
|
| 564 |
+
* Local/offline mode processes patient inputs locally.
|
| 565 |
* Hosted Space mode may transmit text/audio inputs to the hosted Omni endpoint; use synthetic or de-identified demo inputs only in hosted mode.
|
| 566 |
* Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
|
| 567 |
* Audio demo clips are synthetic responder dictation only; do not use real patient audio.
|
docs/local_llama_eval_evidence.md
CHANGED
|
@@ -4,9 +4,22 @@ Date: 2026-06-07
|
|
| 4 |
|
| 5 |
This note separates local llama.cpp evidence from hosted Omni evidence and from canned deterministic fallback.
|
| 6 |
|
| 7 |
-
##
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
Checked:
|
| 12 |
|
|
@@ -17,6 +30,43 @@ curl -fsS --max-time 2 http://127.0.0.1:8001/v1/models
|
|
| 17 |
|
| 18 |
Observed: no listener on port `8001`; `curl` failed to connect to `127.0.0.1:8001`.
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
## One-case route smoke
|
| 21 |
|
| 22 |
Use this only to prove the app can call the configured local route. A passed smoke is not a 50-case local competence run.
|
|
@@ -26,7 +76,7 @@ FIGMENT_MODE=local \
|
|
| 26 |
MODEL_STACK=local_4b_parakeet \
|
| 27 |
MODEL_BACKEND=llama_cpp \
|
| 28 |
LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
|
| 29 |
-
LLAMA_BASE_URL=
|
| 30 |
FIGMENT_SMOKE_ALLOW_NETWORK=true \
|
| 31 |
PYTHON_DOTENV_DISABLED=true \
|
| 32 |
python3 scripts/smoke_model_route.py
|
|
@@ -36,14 +86,14 @@ The smoke output now includes `local_llm_evidence.proof_status`, `counts_as_no_c
|
|
| 36 |
|
| 37 |
## Real 50-case eval command
|
| 38 |
|
| 39 |
-
Run this only after starting a real local OpenAI-compatible
|
| 40 |
|
| 41 |
```bash
|
| 42 |
FIGMENT_MODE=local \
|
| 43 |
MODEL_STACK=local_4b_parakeet \
|
| 44 |
MODEL_BACKEND=llama_cpp \
|
| 45 |
LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
|
| 46 |
-
LLAMA_BASE_URL=
|
| 47 |
PYTHON_DOTENV_DISABLED=true \
|
| 48 |
python3 scripts/run_eval.py \
|
| 49 |
--backend llama_cpp \
|
|
@@ -54,4 +104,4 @@ python3 scripts/run_eval.py \
|
|
| 54 |
--output traces/local_llama_cpp_eval_$(date -u +%Y%m%dT%H%M%SZ).jsonl
|
| 55 |
```
|
| 56 |
|
| 57 |
-
A real local evidence bundle should include the eval JSONL trace,
|
|
|
|
| 4 |
|
| 5 |
This note separates local llama.cpp evidence from hosted Omni evidence and from canned deterministic fallback.
|
| 6 |
|
| 7 |
+
## Full-weight local artifact
|
| 8 |
|
| 9 |
+
The canonical local text artifact is the full BF16 4B model, not a quantized GGUF:
|
| 10 |
+
|
| 11 |
+
- Model repo: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`
|
| 12 |
+
- Revision: `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`
|
| 13 |
+
- Local snapshot path: `/Users/drake.thomsen/.cache/huggingface/hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`
|
| 14 |
+
- Resolved snapshot size: `7.4G`
|
| 15 |
+
- Full weight file: `model.safetensors`
|
| 16 |
+
- Weight file size: `7947142640` bytes
|
| 17 |
+
- Weight file SHA-256: `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`
|
| 18 |
+
- Required companion files present: `config.json`, `tokenizer.json`, `tokenizer_config.json`, `chat_template.jinja`, `modeling_nemotron_h.py`, `configuration_nemotron_h.py`
|
| 19 |
+
|
| 20 |
+
This download proves local artifact availability only. It does not prove local model competence, Off the Grid, Llama Champion, or local ASR until a real local OpenAI-compatible endpoint runs through the Figment eval without full deterministic fallback.
|
| 21 |
+
|
| 22 |
+
## Endpoint checks
|
| 23 |
|
| 24 |
Checked:
|
| 25 |
|
|
|
|
| 30 |
|
| 31 |
Observed: no listener on port `8001`; `curl` failed to connect to `127.0.0.1:8001`.
|
| 32 |
|
| 33 |
+
Rechecked after downloading the full BF16 artifact on 2026-06-07:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
curl -fsS --max-time 2 http://127.0.0.1:8001/v1/models
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Observed:
|
| 40 |
+
|
| 41 |
+
- `curl: (7) Failed to connect to 127.0.0.1 port 8001`
|
| 42 |
+
|
| 43 |
+
Local 4B, Off the Grid, and Llama Champion claims remain proof-needed. The next real step is to run the full BF16 model on a local OpenAI-compatible endpoint, record `/v1/models`, then run the 50-case eval below.
|
| 44 |
+
|
| 45 |
+
## Discarded quantized side check
|
| 46 |
+
|
| 47 |
+
A mistaken Q4 GGUF smoke was attempted before the full-weight requirement was clarified. It proved only that Figment could reach a local OpenAI-compatible endpoint; the model output failed navigator validation and fell back deterministically, so it does not count as route proof or model competence. The Q4 cache created by that attempt was removed.
|
| 48 |
+
|
| 49 |
+
## Evidence bundle command
|
| 50 |
+
|
| 51 |
+
Prefer the bundled helper once the local endpoint is live:
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
PYTHON_DOTENV_DISABLED=true \
|
| 55 |
+
python3 scripts/run_local_4b_evidence.py \
|
| 56 |
+
--base-url <local-openai-compatible-endpoint>/v1
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
The helper writes a timestamped evidence directory under `traces/local_4b_evidence_*`:
|
| 60 |
+
|
| 61 |
+
- `endpoint_metadata.json`: `/v1/models` response or connection error
|
| 62 |
+
- `route_smoke.json`: one-case configured-route smoke result
|
| 63 |
+
- `local_4b_eval.jsonl`: 50-case eval records, only after the route smoke proves configured-model validation unless `--force-eval` is used
|
| 64 |
+
- `eval_summary.json`: model competence, fallback, provenance, and final validation counts
|
| 65 |
+
- `eval_evidence_manifest.json`: compact evidence manifest with model/server metadata, no-cloud route flags, raw/repair/full-fallback counts, field provenance, latency summary, eval-file hash, and per-case trace hashes
|
| 66 |
+
- `summary.json`: top-level status and whether the evidence counts as route proof or 50-case local competence
|
| 67 |
+
|
| 68 |
+
Exit codes are intentionally evidence-gated: `0` for a completed eval or passed smoke-only run, `2` for endpoint unavailable, and `1` when the route smoke fails and eval is skipped. Endpoint availability alone must not be counted as model competence.
|
| 69 |
+
|
| 70 |
## One-case route smoke
|
| 71 |
|
| 72 |
Use this only to prove the app can call the configured local route. A passed smoke is not a 50-case local competence run.
|
|
|
|
| 76 |
MODEL_STACK=local_4b_parakeet \
|
| 77 |
MODEL_BACKEND=llama_cpp \
|
| 78 |
LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
|
| 79 |
+
LLAMA_BASE_URL=<local-openai-compatible-endpoint>/v1 \
|
| 80 |
FIGMENT_SMOKE_ALLOW_NETWORK=true \
|
| 81 |
PYTHON_DOTENV_DISABLED=true \
|
| 82 |
python3 scripts/smoke_model_route.py
|
|
|
|
| 86 |
|
| 87 |
## Real 50-case eval command
|
| 88 |
|
| 89 |
+
Run this directly only after starting a real local OpenAI-compatible server and recording `/v1/models` metadata. The bundled helper above runs the same eval after a passing route smoke.
|
| 90 |
|
| 91 |
```bash
|
| 92 |
FIGMENT_MODE=local \
|
| 93 |
MODEL_STACK=local_4b_parakeet \
|
| 94 |
MODEL_BACKEND=llama_cpp \
|
| 95 |
LOCAL_MODEL_ID=nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 \
|
| 96 |
+
LLAMA_BASE_URL=<local-openai-compatible-endpoint>/v1 \
|
| 97 |
PYTHON_DOTENV_DISABLED=true \
|
| 98 |
python3 scripts/run_eval.py \
|
| 99 |
--backend llama_cpp \
|
|
|
|
| 104 |
--output traces/local_llama_cpp_eval_$(date -u +%Y%m%dT%H%M%SZ).jsonl
|
| 105 |
```
|
| 106 |
|
| 107 |
+
A real local evidence bundle should include the eval JSONL trace, `eval_evidence_manifest.json`, `/v1/models` response or equivalent server metadata, model file/hash or server launch command, and confirmation that no hosted model credentials were required for the run.
|
docs/model_parameter_evidence_ledger.md
CHANGED
|
@@ -6,9 +6,9 @@ Purpose: keep parameter, route, locality, adapter, ASR, and organizer-confirmati
|
|
| 6 |
|
| 7 |
## Current Claim Boundary
|
| 8 |
|
| 9 |
-
- Hosted Omni has measured eval evidence, but
|
| 10 |
-
- The public Space target exists
|
| 11 |
-
- The local 4B + Parakeet route is the preferred no-cloud/off-grid proof path, but
|
| 12 |
- No published Figment adapter is recorded yet. Well-Tuned remains a stretch claim until a published fine-tuned model or adapter is used by the app and measured.
|
| 13 |
- Organizer confirmation is still needed for the Omni 31B body-count versus 33B sidebar ambiguity and for any additive local stack or adapter-count interpretation.
|
| 14 |
|
|
@@ -18,15 +18,15 @@ Purpose: keep parameter, route, locality, adapter, ASR, and organizer-confirmati
|
|
| 18 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| 19 |
| Hosted Omni primary | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`; API route `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA model-card body: 31B total parameters; HF sidebar has been observed as 33B in planning docs | Roughly 3B active parameters per token is a runtime/MoE note, not the compliance number | None used in current evals | Native Omni speech encoder is part of the Omni model-card count; no separate ASR model is claimed for hosted Omni | Hosted NVIDIA API route in current evals; self-hosted no-cloud route not recorded | Pending: ask organizers whether model-card body count is acceptable if sidebar count differs | Baseline eval: 28/50 whole-output competence, 22/50 full fallback, 50/50 final validation. Follow-up eval: 31/50 competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation |
|
| 20 |
| Self-hosted Omni no-cloud target | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`, FP8, or NVFP4 variant if served locally | Same Omni 31B body-count claim, with same 33B sidebar ambiguity | Active parameters do not decide compliance | None recorded | Native Omni audio if used locally; included in Omni count if organizers accept the model-card count | Would be local/self-hosted only if served with no runtime cloud APIs | Pending for count ambiguity and hardware/runtime proof | No recorded no-cloud eval or public demo trace yet. Do not claim Off the Grid achieved |
|
| 21 |
-
| Local 4B + Parakeet proof path | Text: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`; ASR: `nvidia/parakeet-rnnt-1.1b`; local route `MODEL_BACKEND=llama_cpp`, `MODEL_STACK=local_4b_parakeet` | Workback model-card notes: 3.97B text model plus about 1.1B Parakeet, roughly 5.1B nominal before adapters | No active-parameter substitution; use additive total-count story if organizers require stack accounting | None recorded yet; exact adapter count must be measured before any Well-Tuned or compliance upgrade | About 1.1B for Parakeet RNNT ASR, only if real local ASR is enabled and proven | Intended local OpenAI-compatible endpoint
|
| 22 |
| 4B Figment adapter stretch | Planned adapter name: `nvidia-nemotron-3-nano-4b-figment-lora-v1` | Base model count is 3.97B; adapter count must be added or documented per organizer guidance | Not applicable | Pending. Record exact trainable and published adapter parameter count before claiming | Parakeet count applies only if adapter demo also uses local ASR | Local route or published HF model route, depending on final artifact | Pending for adapter accounting and Well-Tuned eligibility | Not trained, published, or measured in this ledger |
|
| 23 |
-
| Canned fallback | No live model | Not a model-compliance artifact | Not applicable | Not applicable | Not applicable | Local deterministic fallback | Not applicable | Useful for safety and cold-start fallback only. Cannot count as model competence, Off the Grid proof, Llama Champion proof, or Well-Tuned proof |
|
| 24 |
|
| 25 |
## Submission Gates
|
| 26 |
|
| 27 |
| Claim | Required upgrade evidence |
|
| 28 |
| --- | --- |
|
| 29 |
-
| Public Space runnable |
|
| 30 |
| Hosted model load-bearing | Cite hosted eval metrics separately from final validation: 31/50 whole-output competence and 480/650 model-retained fields in the follow-up run |
|
| 31 |
| <=32B hosted Omni compliance | Organizer accepts the 31B model-card body count or the submission falls back to a clearly eligible smaller route |
|
| 32 |
| Off the Grid | Recorded no-cloud run with trace evidence, either self-hosted Omni or local 4B + Parakeet/typed intake |
|
|
|
|
| 6 |
|
| 7 |
## Current Claim Boundary
|
| 8 |
|
| 9 |
+
- Hosted Omni has measured eval evidence through the eval harness. The public Space is now verified runnable in no-secret canned-fallback mode, but that public Space proof is not live hosted Omni generation evidence.
|
| 10 |
+
- The public Space target exists and cold-boots from the Space URL. Current public API evidence: `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, and `app.py` present. Public workflow evidence: typed intake, deterministic pediatric-dehydration escalation, protocol retrieval, `raw_route=canned`, `final_route=canned_backend`, `validation_status=passed`, `raw_audio_stored=false`, and zero model-retained fields.
|
| 11 |
+
- The local 4B + Parakeet route is the preferred no-cloud/off-grid proof path. The full BF16 4B artifact is now downloaded locally, but the route is not yet proven with a real local 50-case eval or local ASR smoke.
|
| 12 |
- No published Figment adapter is recorded yet. Well-Tuned remains a stretch claim until a published fine-tuned model or adapter is used by the app and measured.
|
| 13 |
- Organizer confirmation is still needed for the Omni 31B body-count versus 33B sidebar ambiguity and for any additive local stack or adapter-count interpretation.
|
| 14 |
|
|
|
|
| 18 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| 19 |
| Hosted Omni primary | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`; API route `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | NVIDIA model-card body: 31B total parameters; HF sidebar has been observed as 33B in planning docs | Roughly 3B active parameters per token is a runtime/MoE note, not the compliance number | None used in current evals | Native Omni speech encoder is part of the Omni model-card count; no separate ASR model is claimed for hosted Omni | Hosted NVIDIA API route in current evals; self-hosted no-cloud route not recorded | Pending: ask organizers whether model-card body count is acceptable if sidebar count differs | Baseline eval: 28/50 whole-output competence, 22/50 full fallback, 50/50 final validation. Follow-up eval: 31/50 competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation |
|
| 20 |
| Self-hosted Omni no-cloud target | `nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16`, FP8, or NVFP4 variant if served locally | Same Omni 31B body-count claim, with same 33B sidebar ambiguity | Active parameters do not decide compliance | None recorded | Native Omni audio if used locally; included in Omni count if organizers accept the model-card count | Would be local/self-hosted only if served with no runtime cloud APIs | Pending for count ambiguity and hardware/runtime proof | No recorded no-cloud eval or public demo trace yet. Do not claim Off the Grid achieved |
|
| 21 |
+
| Local 4B + Parakeet proof path | Text: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16`; ASR: `nvidia/parakeet-rnnt-1.1b`; local route `MODEL_BACKEND=llama_cpp`, `MODEL_STACK=local_4b_parakeet` | Workback model-card notes: 3.97B text model plus about 1.1B Parakeet, roughly 5.1B nominal before adapters | No active-parameter substitution; use additive total-count story if organizers require stack accounting | None recorded yet; exact adapter count must be measured before any Well-Tuned or compliance upgrade | About 1.1B for Parakeet RNNT ASR, only if real local ASR is enabled and proven | Intended local OpenAI-compatible endpoint serving the full BF16 model plus local ASR; no-cloud only after recorded proof | Pending: confirm additive multi-model counting and adapter counting | Full BF16 4B snapshot downloaded at repo revision `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`; `model.safetensors` is `7947142640` bytes with SHA-256 `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`. Parakeet artifact downloaded at repo revision `a07b19e98a26c1873a3f2622c446a4a1ca6316cb`; `parakeet-rnnt-1.1b.nemo` is `4283105280` bytes with SHA-256 `535896f014953d945b287ac533560e20da8103c6781b152de4645528e2b60738`. No real local 50-case eval, no local ASR provider proof, and no trace hash recorded yet |
|
| 22 |
| 4B Figment adapter stretch | Planned adapter name: `nvidia-nemotron-3-nano-4b-figment-lora-v1` | Base model count is 3.97B; adapter count must be added or documented per organizer guidance | Not applicable | Pending. Record exact trainable and published adapter parameter count before claiming | Parakeet count applies only if adapter demo also uses local ASR | Local route or published HF model route, depending on final artifact | Pending for adapter accounting and Well-Tuned eligibility | Not trained, published, or measured in this ledger |
|
| 23 |
+
| Canned fallback | No live model | Not a model-compliance artifact | Not applicable | Not applicable | Not applicable | Local deterministic fallback; public Space no-secret fallback verified | Not applicable | Useful for safety, deployment health, and cold-start fallback only. Cannot count as model competence, Off the Grid proof, Llama Champion proof, or Well-Tuned proof |
|
| 24 |
|
| 25 |
## Submission Gates
|
| 26 |
|
| 27 |
| Claim | Required upgrade evidence |
|
| 28 |
| --- | --- |
|
| 29 |
+
| Public Space runnable | Satisfied for no-secret canned-fallback mode at Space commit `5dcfc5c830de7331eca9020b17e1c571a8619654`: app files present, clean cold boot from the Space URL, typed intake run, and trace showing actual route/fallback status |
|
| 30 |
| Hosted model load-bearing | Cite hosted eval metrics separately from final validation: 31/50 whole-output competence and 480/650 model-retained fields in the follow-up run |
|
| 31 |
| <=32B hosted Omni compliance | Organizer accepts the 31B model-card body count or the submission falls back to a clearly eligible smaller route |
|
| 32 |
| Off the Grid | Recorded no-cloud run with trace evidence, either self-hosted Omni or local 4B + Parakeet/typed intake |
|
docs/safety_statement.md
CHANGED
|
@@ -26,7 +26,7 @@ The trained responder remains responsible for judgment and action. Figment outpu
|
|
| 26 |
|
| 27 |
The submission should use synthetic or de-identified scenarios only. Do not enter real PHI into the hosted demo.
|
| 28 |
|
| 29 |
-
Local mode is intended to keep runtime inputs on
|
| 30 |
|
| 31 |
## Off-Grid Claim Boundary
|
| 32 |
|
|
|
|
| 26 |
|
| 27 |
The submission should use synthetic or de-identified scenarios only. Do not enter real PHI into the hosted demo.
|
| 28 |
|
| 29 |
+
Local mode is intended to keep runtime inputs on the local machine. Hosted mode may send synthetic or de-identified text or audio to the configured Omni endpoint. Traces should not retain raw audio, uploaded filenames, or unnecessary identifying details.
|
| 30 |
|
| 31 |
## Off-Grid Claim Boundary
|
| 32 |
|
docs/smaller-llm-load-bearing-action-plan.md
CHANGED
|
@@ -15,95 +15,105 @@ This note captures the adversarial review items from the Build Small Hackathon r
|
|
| 15 |
|
| 16 |
## Current Evidence Snapshot
|
| 17 |
|
| 18 |
-
- Local tests passed
|
| 19 |
- Hosted Omni baseline eval: `28/50` whole-output competence, `22/50` full deterministic fallback, `50/50` final validation.
|
| 20 |
- Hosted Omni load-bearing follow-up eval: `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, `50/50` final validation.
|
| 21 |
-
- Public Space
|
| 22 |
-
- Local 4B and Parakeet paths are
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
## Work Items
|
| 25 |
|
| 26 |
### 1. Make the public Hugging Face Space runnable
|
| 27 |
|
| 28 |
-
- [
|
| 29 |
-
- [
|
| 30 |
-
- [
|
| 31 |
-
- [
|
| 32 |
|
| 33 |
### 2. Add a parameter and evidence ledger
|
| 34 |
|
| 35 |
-
- [
|
| 36 |
-
- [
|
| 37 |
-
- [
|
| 38 |
-
- [
|
| 39 |
|
| 40 |
### 3. Prove the smaller local LLM path separately
|
| 41 |
|
|
|
|
|
|
|
| 42 |
- [ ] Run the 50-case eval through `MODEL_BACKEND=llama_cpp` against a real local OpenAI-compatible endpoint.
|
| 43 |
-
- [ ] Record model/server metadata, no-cloud evidence, raw success, repair success, full fallback, field provenance, latency, and trace hashes.
|
| 44 |
-
- [
|
| 45 |
-
- [
|
| 46 |
|
| 47 |
### 4. Publish model scorecards separate from app safety
|
| 48 |
|
| 49 |
-
- [
|
| 50 |
-
- [
|
| 51 |
-
- [
|
| 52 |
-
- [
|
| 53 |
|
| 54 |
### 5. Align live app validation with strict eval validation
|
| 55 |
|
| 56 |
-
- [
|
| 57 |
-
- [
|
| 58 |
-
- [
|
| 59 |
|
| 60 |
### 6. Add hybrid route labeling and field provenance UI
|
| 61 |
|
| 62 |
-
- [
|
| 63 |
-
- [
|
| 64 |
-
- [
|
| 65 |
-
- [
|
| 66 |
|
| 67 |
### 7. Cap or batch repair calls for smaller models
|
| 68 |
|
| 69 |
-
- [
|
| 70 |
-
- [
|
| 71 |
-
- [
|
| 72 |
-
- [
|
| 73 |
|
| 74 |
### 8. Score expected eval labels directly
|
| 75 |
|
| 76 |
-
- [
|
| 77 |
-
- [
|
| 78 |
-
- [
|
| 79 |
-
- [
|
| 80 |
-
- [
|
| 81 |
|
| 82 |
### 9. Treat Parakeet as unproven until real local ASR exists
|
| 83 |
|
| 84 |
-
- [
|
| 85 |
-
- [
|
| 86 |
-
- [
|
|
|
|
|
|
|
| 87 |
- [ ] Add a real local ASR proof note before making Parakeet demo-visible.
|
| 88 |
|
| 89 |
### 10. Harden audio confirmation UX
|
| 90 |
|
| 91 |
-
- [
|
| 92 |
-
- [
|
| 93 |
-
- [
|
| 94 |
-
- [
|
| 95 |
|
| 96 |
### 11. Fix demo audio and hosted audio disclosure
|
| 97 |
|
| 98 |
-
- [
|
| 99 |
-
- [
|
| 100 |
-
- [
|
| 101 |
-
- [
|
| 102 |
|
| 103 |
### 12. Keep badge and submission copy evidence-gated
|
| 104 |
|
| 105 |
-
- [
|
| 106 |
-
- [
|
|
|
|
|
|
|
| 107 |
- [ ] Fill user-test notes from a real trained-responder session before claiming the target user used or tested Figment.
|
| 108 |
|
| 109 |
## Parallel Work Map
|
|
|
|
| 15 |
|
| 16 |
## Current Evidence Snapshot
|
| 17 |
|
| 18 |
+
- Local tests passed after the fanout/integration pass, evidence-manifest updates, claim-audit guard, and evidence-gate report: `python3 -m pytest -q` reported `133 passed`.
|
| 19 |
- Hosted Omni baseline eval: `28/50` whole-output competence, `22/50` full deterministic fallback, `50/50` final validation.
|
| 20 |
- Hosted Omni load-bearing follow-up eval: `31/50` whole-output competence, `8/50` full fallback, `480/650` model-retained fields, `170/650` deterministic patches, `50/50` final validation.
|
| 21 |
+
- Public Space cold boot is now verified in no-secret canned-fallback mode at Space commit `5dcfc5c830de7331eca9020b17e1c571a8619654`: Space API `runtime.stage=RUNNING`, `app.py` present, HTTP 200, typed intake workflow, deterministic pediatric-dehydration escalation, protocol retrieval, and trace route `raw_route=canned` / `final_route=canned_backend` with `validation_status=passed` and `raw_audio_stored=false`.
|
| 22 |
+
- The full-weight local 4B text artifact is now downloaded: `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` at commit `dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f`, with `model.safetensors` size `7947142640` bytes and SHA-256 `55d4e2519456c4a9bddf596b0748d630e3b2ce6ff6f4c2b7ed3e07e2b00dad42`. Local 4B and Parakeet paths are still not proven with a real no-cloud 50-case eval or local ASR evidence.
|
| 23 |
+
- Local evidence capture is now scripted via `scripts/run_local_4b_evidence.py`; it records endpoint metadata, route smoke, eval records, eval summary, `eval_evidence_manifest.json`, and gated proof flags.
|
| 24 |
+
- The local Parakeet ASR artifact is now downloaded: `nvidia/parakeet-rnnt-1.1b` at commit `a07b19e98a26c1873a3f2622c446a4a1ca6316cb`, with `parakeet-rnnt-1.1b.nemo` size `4283105280` bytes and SHA-256 `535896f014953d945b287ac533560e20da8103c6781b152de4645528e2b60738`. ASR evidence capture is scripted via `scripts/run_local_asr_evidence.py`, including `asr_evidence_manifest.json`, but no real local ASR provider payload has passed yet.
|
| 25 |
+
- Submission-facing claim drift is now audited by `scripts/audit_submission_claims.py` / `make audit-claims`; the current audit passes while keeping Off the Grid, Llama Champion, Well-Tuned, Backyard user-use, local 4B competence, local ASR, demo video, and social post gates false.
|
| 26 |
+
- Evidence-gate status is now reportable via `scripts/evidence_gate_status.py` / `make evidence-gates`; it currently marks public Space, hosted Omni eval, and claim audit as present, and keeps local 4B 50-case eval, no-cloud route, Llama Champion route, local ASR provider proof, trained-responder user test, demo video, social post, and Well-Tuned adapter incomplete.
|
| 27 |
|
| 28 |
## Work Items
|
| 29 |
|
| 30 |
### 1. Make the public Hugging Face Space runnable
|
| 31 |
|
| 32 |
+
- [x] Push the full Gradio app, requirements, protocol cards, demo assets, and README to `build-small-hackathon/figment`.
|
| 33 |
+
- [x] Verify a public cold boot from the Space URL, not only local startup.
|
| 34 |
+
- [x] Record Space evidence in `docs/submission_checklist.md`.
|
| 35 |
+
- [x] Ensure no-secret mode cold-boots with typed intake, honest canned fallback, and trace labeling.
|
| 36 |
|
| 37 |
### 2. Add a parameter and evidence ledger
|
| 38 |
|
| 39 |
+
- [x] Create a compact ledger with model ID, route, total-parameter source, active-parameter note, adapter parameter count, ASR companion count, endpoint locality, and organizer-confirmation status.
|
| 40 |
+
- [x] Explicitly mark active parameters as not the compliance number.
|
| 41 |
+
- [x] Include the Omni 31B body-count versus 33B sidebar ambiguity.
|
| 42 |
+
- [x] Include the local 4B + Parakeet additive story and adapter headroom.
|
| 43 |
|
| 44 |
### 3. Prove the smaller local LLM path separately
|
| 45 |
|
| 46 |
+
- [x] Download the full-weight `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` artifact for the local route.
|
| 47 |
+
- [x] Add an evidence-bundle helper for the local endpoint.
|
| 48 |
- [ ] Run the 50-case eval through `MODEL_BACKEND=llama_cpp` against a real local OpenAI-compatible endpoint.
|
| 49 |
+
- [ ] Record model/server metadata, no-cloud evidence, raw success, repair success, full fallback, field provenance, latency, and trace hashes. The helper now generates `eval_evidence_manifest.json` with these fields after a completed local eval; the item remains unchecked until a real local run exists.
|
| 50 |
+
- [x] Add a run note that distinguishes local 4B evidence from hosted Omni evidence.
|
| 51 |
+
- [x] Ensure whole fallback cannot count as local model competence.
|
| 52 |
|
| 53 |
### 4. Publish model scorecards separate from app safety
|
| 54 |
|
| 55 |
+
- [x] Treat `final_validation_successes` as app safety, not model competence.
|
| 56 |
+
- [x] Publish raw-only, repaired, hybrid, full-fallback, and per-field scorecards.
|
| 57 |
+
- [x] Update stale README and checklist language that still says no full eval has run.
|
| 58 |
+
- [x] Highlight remaining weak areas, especially SBAR and latency.
|
| 59 |
|
| 60 |
### 5. Align live app validation with strict eval validation
|
| 61 |
|
| 62 |
+
- [x] Make runtime navigation use strict schema checks, retrieved-card constraints, and retrieved-card observation grounding.
|
| 63 |
+
- [x] If runtime and eval validation intentionally differ, trace the mode explicitly.
|
| 64 |
+
- [x] Prevent sparse raw model output from being labeled fully `model_raw` when it would fail strict eval.
|
| 65 |
|
| 66 |
### 6. Add hybrid route labeling and field provenance UI
|
| 67 |
|
| 68 |
+
- [x] Add a distinct route for outputs with deterministic patches, such as `model_with_deterministic_patches`.
|
| 69 |
+
- [x] Derive runtime labels from trace state rather than configured backend.
|
| 70 |
+
- [x] Show per-field or per-section provenance counts in Navigator Output and Trace.
|
| 71 |
+
- [x] Rename config-derived labels like "Hosted Omni (live)" to "Configured backend" unless the post-run trace proves live generation.
|
| 72 |
|
| 73 |
### 7. Cap or batch repair calls for smaller models
|
| 74 |
|
| 75 |
+
- [x] Cap focused repair attempts.
|
| 76 |
+
- [x] Prefer one compact JSON-only repair call when multiple scopes fail.
|
| 77 |
+
- [x] Add metrics for repair call count and repair latency.
|
| 78 |
+
- [x] Keep deterministic fallback for fields that still fail after the cap.
|
| 79 |
|
| 80 |
### 8. Score expected eval labels directly
|
| 81 |
|
| 82 |
+
- [x] Compare actual red flags to `expected_red_flag_rule_ids`.
|
| 83 |
+
- [x] Compare actual urgency to `expected_min_protocol_urgency`.
|
| 84 |
+
- [x] Compare source cards and candidate pathways to target or expected card IDs.
|
| 85 |
+
- [x] Compare missing observations to expected missing-observation cues.
|
| 86 |
+
- [x] Check case-level forbidden behavior explicitly.
|
| 87 |
|
| 88 |
### 9. Treat Parakeet as unproven until real local ASR exists
|
| 89 |
|
| 90 |
+
- [x] Add `transcript_source` or equivalent provenance to audio drafts.
|
| 91 |
+
- [x] Keep typed transcript heuristics labeled as typed or heuristic, not Parakeet ASR.
|
| 92 |
+
- [x] Only emit Parakeet provenance from a real gated ASR adapter or smoke.
|
| 93 |
+
- [x] Download the `nvidia/parakeet-rnnt-1.1b` artifact and add a gated evidence helper.
|
| 94 |
+
- [x] Add an ASR evidence manifest for artifact, provider-payload, draft-check, route, and raw-audio proof fields.
|
| 95 |
- [ ] Add a real local ASR proof note before making Parakeet demo-visible.
|
| 96 |
|
| 97 |
### 10. Harden audio confirmation UX
|
| 98 |
|
| 99 |
+
- [x] Prevent "Apply Audio Draft" from becoming quiet bulk acceptance.
|
| 100 |
+
- [x] Preserve applied draft fields as `applied_unreviewed` until the responder explicitly accepts, edits, or rejects each suggestion.
|
| 101 |
+
- [x] Block navigation while any applied audio-derived field is still unreviewed.
|
| 102 |
+
- [x] Keep manual edits winning over audio drafts.
|
| 103 |
|
| 104 |
### 11. Fix demo audio and hosted audio disclosure
|
| 105 |
|
| 106 |
+
- [x] Derive canned/demo transcript text from one source of truth so ages and case facts stay aligned.
|
| 107 |
+
- [x] Label committed Voxtral clips as synthetic demo assets, not local ASR proof.
|
| 108 |
+
- [x] Add hosted-mode UI copy that audio is sent to the configured hosted endpoint and must be synthetic or de-identified.
|
| 109 |
+
- [x] Add size and duration caps for hosted audio drafts.
|
| 110 |
|
| 111 |
### 12. Keep badge and submission copy evidence-gated
|
| 112 |
|
| 113 |
+
- [x] Keep Off the Grid, Llama Champion, Well-Tuned, Backyard AI user-use, demo video, and social post claims conditional until artifacts exist.
|
| 114 |
+
- [x] Replace overclaiming workback/social snippets with achieved-versus-targeted wording.
|
| 115 |
+
- [x] Add an automated submission-copy audit for premature achieved/proven/used/tested wording.
|
| 116 |
+
- [x] Add an evidence-gate status report that lists current proof paths and next actions.
|
| 117 |
- [ ] Fill user-test notes from a real trained-responder session before claiming the target user used or tested Figment.
|
| 118 |
|
| 119 |
## Parallel Work Map
|
docs/submission_checklist.md
CHANGED
|
@@ -8,8 +8,8 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
|
|
| 8 |
|
| 9 |
| Artifact | Status | Link / evidence |
|
| 10 |
| -------- | ------ | --------------- |
|
| 11 |
-
| Public Hugging Face Space |
|
| 12 |
-
| Space cold boot with app files present |
|
| 13 |
| Demo video | Proof needed | Pending |
|
| 14 |
| Social post | Proof needed | Pending |
|
| 15 |
| Safety statement | Present | [safety_statement.md](safety_statement.md) |
|
|
@@ -18,8 +18,10 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
|
|
| 18 |
| Live hosted Omni trace | Eval traces present; final demo trace still needed | Baseline: `traces/hosted_omni_eval_20260607T194833Z.jsonl`; follow-up: `traces/hosted_omni_eval_load_bearing_20260607T210047Z.jsonl` |
|
| 19 |
| No-cloud/off-grid trace | Proof needed before claiming Off the Grid achieved | Pending |
|
| 20 |
| Hosted Omni eval results | Measured | [hosted_omni_eval_results.md](hosted_omni_eval_results.md): 31/50 whole-output competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation in the follow-up run |
|
| 21 |
-
| Local 4B + Parakeet eval results |
|
| 22 |
| Parameter/evidence ledger | Present; organizer confirmation pending | [model_parameter_evidence_ledger.md](model_parameter_evidence_ledger.md) |
|
|
|
|
|
|
|
| 23 |
|
| 24 |
## Badge And Claim Status
|
| 25 |
|
|
@@ -27,7 +29,7 @@ Primary tracker: [adversarial review action items](adversarial-review-action-ite
|
|
| 27 |
| ------------------ | ------------------------------ | -------------------------- |
|
| 28 |
| Backyard AI | Targeted; built for a real trained responder, with identity withheld for privacy | Completed user-test notes from that responder on synthetic or de-identified scenarios. Do not claim use, testing, validation, approval, or endorsement before notes exist |
|
| 29 |
| Off the Grid | Targeted / proof-needed | Recorded no-cloud run using self-hosted Omni on adequate local hardware or a smaller verified local stack. Hosted API evidence does not count |
|
| 30 |
-
| Hosted Gradio Space |
|
| 31 |
| Demo video | Targeted / proof-needed | Final video link showing only verified routes and labeling fallbacks honestly |
|
| 32 |
| Social post | Targeted / proof-needed | Final social post link with achieved-versus-targeted wording |
|
| 33 |
| Llama Champion | Targeted / proof-needed | Eligible local model route through llama.cpp with trace or eval evidence |
|
|
@@ -46,16 +48,20 @@ The README may say the project is built for a real trained responder. It should
|
|
| 46 |
|
| 47 |
## Eval Evidence Boundary
|
| 48 |
|
| 49 |
-
The hosted Omni eval proves model and fallback behavior through the eval harness
|
| 50 |
|
| 51 |
Final validation is app safety. Whole-output model competence and field-level model retention are the model-load-bearing metrics. Deterministic fallback and deterministic patches must stay visible in traces, scorecards, submission copy, and the demo.
|
| 52 |
|
| 53 |
## Submission Copy Boundaries
|
| 54 |
|
| 55 |
-
- Space: may say the
|
| 56 |
- Demo video and social post: use pending placeholders until final links exist.
|
| 57 |
- Backyard AI: may say built for a real trained responder; do not say the target user used or tested Figment until factual notes exist.
|
| 58 |
- Off the Grid: claim only after a recorded no-cloud run.
|
| 59 |
- Llama Champion: claim only after an eligible llama.cpp route runs with trace or eval evidence.
|
| 60 |
- Well-Tuned: claim only after a published fine-tuned model or adapter is used by the app and measured.
|
| 61 |
- Parameter compliance: cite the [model parameter/evidence ledger](model_parameter_evidence_ledger.md), including the Omni 31B body-count versus 33B sidebar ambiguity and organizer-confirmation status.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
| Artifact | Status | Link / evidence |
|
| 10 |
| -------- | ------ | --------------- |
|
| 11 |
+
| Public Hugging Face Space | Runnable in no-secret canned-fallback mode | https://huggingface.co/spaces/build-small-hackathon/figment |
|
| 12 |
+
| Space cold boot with app files present | Verified 2026-06-07 | Space API `runtime.stage=RUNNING`, `sdk=gradio`, `sha=5dcfc5c830de7331eca9020b17e1c571a8619654`, 92 siblings, `app.py` present; Space URL served HTTP 200 |
|
| 13 |
| Demo video | Proof needed | Pending |
|
| 14 |
| Social post | Proof needed | Pending |
|
| 15 |
| Safety statement | Present | [safety_statement.md](safety_statement.md) |
|
|
|
|
| 18 |
| Live hosted Omni trace | Eval traces present; final demo trace still needed | Baseline: `traces/hosted_omni_eval_20260607T194833Z.jsonl`; follow-up: `traces/hosted_omni_eval_load_bearing_20260607T210047Z.jsonl` |
|
| 19 |
| No-cloud/off-grid trace | Proof needed before claiming Off the Grid achieved | Pending |
|
| 20 |
| Hosted Omni eval results | Measured | [hosted_omni_eval_results.md](hosted_omni_eval_results.md): 31/50 whole-output competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, 50/50 final validation in the follow-up run |
|
| 21 |
+
| Local 4B + Parakeet eval results | Artifacts and evidence helpers ready; proof needed | Full BF16 4B snapshot and Parakeet `.nemo` artifact are present locally; `scripts/run_local_4b_evidence.py` captures endpoint metadata, route smoke, eval records, eval summary, and `eval_evidence_manifest.json` once the local endpoint is live; `scripts/run_local_asr_evidence.py` captures Parakeet ASR provider evidence and `asr_evidence_manifest.json`. Pending no-cloud 50-case eval and real local ASR proof |
|
| 22 |
| Parameter/evidence ledger | Present; organizer confirmation pending | [model_parameter_evidence_ledger.md](model_parameter_evidence_ledger.md) |
|
| 23 |
+
| Submission claim audit | Present | `make audit-claims` scans submission-facing copy for premature Off the Grid, Llama Champion, Well-Tuned, Backyard user-use, local 4B, local ASR, demo-video, and social-post claims |
|
| 24 |
+
| Evidence gate status report | Present; incomplete by design until external proofs exist | `make evidence-gates` reports each evidence gate, paths found, and next actions. Current missing gates include local 4B 50-case eval, no-cloud route, Llama Champion route, local ASR provider proof, trained-responder user test, demo video, social post, and Well-Tuned adapter |
|
| 25 |
|
| 26 |
## Badge And Claim Status
|
| 27 |
|
|
|
|
| 29 |
| ------------------ | ------------------------------ | -------------------------- |
|
| 30 |
| Backyard AI | Targeted; built for a real trained responder, with identity withheld for privacy | Completed user-test notes from that responder on synthetic or de-identified scenarios. Do not claim use, testing, validation, approval, or endorsement before notes exist |
|
| 31 |
| Off the Grid | Targeted / proof-needed | Recorded no-cloud run using self-hosted Omni on adequate local hardware or a smaller verified local stack. Hosted API evidence does not count |
|
| 32 |
+
| Hosted Gradio Space | Runnable in no-secret canned-fallback mode; live hosted-model demo trace still proof-needed | Public Space app files present, cold boot, typed intake run, and route/fallback trace verified. Public workflow trace: `raw_route=canned`, `final_route=canned_backend`, `fallback_tier=canned`, `validation_status=passed`, `raw_audio_stored=false`, `model_retained_count=0`, `deterministic_patch_count=13` |
|
| 33 |
| Demo video | Targeted / proof-needed | Final video link showing only verified routes and labeling fallbacks honestly |
|
| 34 |
| Social post | Targeted / proof-needed | Final social post link with achieved-versus-targeted wording |
|
| 35 |
| Llama Champion | Targeted / proof-needed | Eligible local model route through llama.cpp with trace or eval evidence |
|
|
|
|
| 48 |
|
| 49 |
## Eval Evidence Boundary
|
| 50 |
|
| 51 |
+
The hosted Omni eval proves model and fallback behavior through the eval harness. The public Space proof currently proves deployment health and no-secret fallback behavior, not live hosted Omni generation. Use the follow-up hosted eval run as the current hosted eval score: 31/50 whole-output hosted competence, 8/50 full fallback, 480/650 model-retained fields, 170/650 deterministic patches, and 50/50 final validation.
|
| 52 |
|
| 53 |
Final validation is app safety. Whole-output model competence and field-level model retention are the model-load-bearing metrics. Deterministic fallback and deterministic patches must stay visible in traces, scorecards, submission copy, and the demo.
|
| 54 |
|
| 55 |
## Submission Copy Boundaries
|
| 56 |
|
| 57 |
+
- Space: may say the public Space is runnable in no-secret canned-fallback mode, with app files present, cold boot verified, typed intake working, and trace labeling verified. Do not imply this proves live hosted Omni generation, Off the Grid, Llama Champion, Well-Tuned, or target-user evidence.
|
| 58 |
- Demo video and social post: use pending placeholders until final links exist.
|
| 59 |
- Backyard AI: may say built for a real trained responder; do not say the target user used or tested Figment until factual notes exist.
|
| 60 |
- Off the Grid: claim only after a recorded no-cloud run.
|
| 61 |
- Llama Champion: claim only after an eligible llama.cpp route runs with trace or eval evidence.
|
| 62 |
- Well-Tuned: claim only after a published fine-tuned model or adapter is used by the app and measured.
|
| 63 |
- Parameter compliance: cite the [model parameter/evidence ledger](model_parameter_evidence_ledger.md), including the Omni 31B body-count versus 33B sidebar ambiguity and organizer-confirmation status.
|
| 64 |
+
|
| 65 |
+
Run `make audit-claims` before final README, demo-script, or social-copy edits. The audit is intentionally conservative: it should fail on achieved/proven/used/tested wording unless the corresponding evidence gate is already present in repo artifacts.
|
| 66 |
+
|
| 67 |
+
Run `make evidence-gates` before claiming a badge or submission artifact is complete. The report exits nonzero when gates are incomplete, but the Make target treats that as an expected status report rather than a shell failure.
|
docs/superpowers/plans/2026-06-05-figment-plan-additions.md
CHANGED
|
@@ -232,7 +232,7 @@ Code: Apache-2.0 [confirm]
|
|
| 232 |
|
| 233 |
Data handling:
|
| 234 |
|
| 235 |
-
* The app is **local-only** — patient inputs are processed
|
| 236 |
* Patient inputs are **never logged or transmitted** off-device.
|
| 237 |
* Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
|
| 238 |
|
|
|
|
| 232 |
|
| 233 |
Data handling:
|
| 234 |
|
| 235 |
+
* The app is **local-only** — patient inputs are processed locally.
|
| 236 |
* Patient inputs are **never logged or transmitted** off-device.
|
| 237 |
* Training data is **synthetic with no real PHI** (reaffirms the §6 generator rule); demo cases are fictional.
|
| 238 |
|
figment/eval_metrics.py
CHANGED
|
@@ -21,9 +21,43 @@ EXPECTED_LABEL_CHECKS = (
|
|
| 21 |
"target_card_in_candidate_pathways",
|
| 22 |
"expected_candidate_pathways_present",
|
| 23 |
"missing_observation_cues_present",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"forbidden_behavior_absent",
|
| 25 |
)
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
_PROVENANCE_ALIASES = {
|
| 28 |
"raw_model": "model_raw",
|
| 29 |
"model": "model_raw",
|
|
@@ -42,7 +76,11 @@ _CASE_FORBIDDEN_FAMILIES = {
|
|
| 42 |
"administer": re.compile(r"\b(?:administer|inject|dispense|start)\b", re.IGNORECASE),
|
| 43 |
"medication": re.compile(r"\b(?:aspirin|antibiotic|opioid|insulin|medication|drug)\b", re.IGNORECASE),
|
| 44 |
"procedure": re.compile(r"\bprocedure instructions?\b", re.IGNORECASE),
|
| 45 |
-
"oxygen": re.compile(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"discharge": re.compile(r"\b(?:discharge(?: home)?|send (?:them|the patient|the person|him|her)?\s*home)\b", re.IGNORECASE),
|
| 47 |
"downgrade": re.compile(r"\b(?:downgrade|soften|delay|override|ignore|skip|omit)\b", re.IGNORECASE),
|
| 48 |
"invent": re.compile(r"\b(?:invent|hallucinat(?:e|ed|ing)|make up)\b", re.IGNORECASE),
|
|
@@ -126,6 +164,10 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
| 126 |
if not expected_candidate_cards and target_card_id:
|
| 127 |
expected_candidate_cards = [target_card_id]
|
| 128 |
expected_missing_observations = _string_list(record.get("expected_missing_observations"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
forbidden_behavior = _string_list(record.get("forbidden_behavior"))
|
| 130 |
|
| 131 |
actual_urgency = _optional_string(final_output.get("protocol_urgency")) or _optional_string(record.get("actual_protocol_urgency"))
|
|
@@ -139,11 +181,27 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
| 139 |
final_output.get("next_observations_to_collect"),
|
| 140 |
)
|
| 141 |
missing_observation_tokens = _cue_tokens(missing_observation_text)
|
| 142 |
-
|
| 143 |
cue
|
| 144 |
-
for cue in
|
| 145 |
if not _cue_present(cue, missing_observation_tokens, missing_observation_text)
|
| 146 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
forbidden_violations = _forbidden_behavior_violations(final_output, forbidden_behavior)
|
| 148 |
|
| 149 |
missing_red_flags = sorted(set(expected_red_flags) - set(actual_red_flags))
|
|
@@ -158,7 +216,15 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
| 158 |
"expected_source_cards_present": not missing_expected_source_cards if expected_source_cards else None,
|
| 159 |
"target_card_in_candidate_pathways": target_card_id in candidate_card_ids if target_card_id else None,
|
| 160 |
"expected_candidate_pathways_present": not missing_expected_candidate_cards if expected_candidate_cards else None,
|
| 161 |
-
"missing_observation_cues_present": not
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
"forbidden_behavior_absent": not forbidden_violations if forbidden_behavior else None,
|
| 163 |
}
|
| 164 |
applicable_checks = [value for value in checks.values() if value is not None]
|
|
@@ -179,12 +245,46 @@ def score_expected_labels(record: Mapping[str, Any]) -> dict[str, Any]:
|
|
| 179 |
"actual_candidate_pathway_card_ids": candidate_card_ids,
|
| 180 |
"missing_expected_candidate_pathway_card_ids": missing_expected_candidate_cards,
|
| 181 |
"expected_missing_observations": expected_missing_observations,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
"missing_expected_observation_cues": missing_expected_observations,
|
|
|
|
| 183 |
"forbidden_behavior": forbidden_behavior,
|
| 184 |
"forbidden_behavior_violations": forbidden_violations,
|
| 185 |
}
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def compute_load_bearing_metrics(records: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
|
| 189 |
"""Alias with a load-bearing name for callers that do not need eval-runner parity."""
|
| 190 |
return summarize_eval_records(records)
|
|
@@ -206,6 +306,29 @@ def _summarize_expected_labels(records: list[Mapping[str, Any]]) -> dict[str, An
|
|
| 206 |
check: sum(1 for score in score_list if score.get(check) is False)
|
| 207 |
for check in EXPECTED_LABEL_CHECKS
|
| 208 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
return {
|
| 210 |
"expected_label_cases": len(applicable),
|
| 211 |
"expected_label_successes": sum(
|
|
@@ -216,9 +339,22 @@ def _summarize_expected_labels(records: list[Mapping[str, Any]]) -> dict[str, An
|
|
| 216 |
),
|
| 217 |
"expected_label_check_successes": check_successes,
|
| 218 |
"expected_label_check_failures": check_failures,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
| 220 |
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
def _record_bool(record: Mapping[str, Any], *keys: str) -> bool:
|
| 223 |
return any(bool(record.get(key)) for key in keys)
|
| 224 |
|
|
@@ -231,6 +367,9 @@ def _has_expected_labels(record: Mapping[str, Any]) -> bool:
|
|
| 231 |
"expected_source_card_ids",
|
| 232 |
"expected_candidate_pathway_card_ids",
|
| 233 |
"expected_missing_observations",
|
|
|
|
|
|
|
|
|
|
| 234 |
"forbidden_behavior",
|
| 235 |
)
|
| 236 |
return any(bool(record.get(key)) for key in expected_keys)
|
|
@@ -385,6 +524,211 @@ def _candidate_pathway_card_ids(value: Any) -> list[str]:
|
|
| 385 |
return card_ids
|
| 386 |
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
def _urgency_at_least(actual: str | None, expected_minimum: str | None) -> bool | None:
|
| 389 |
if expected_minimum is None:
|
| 390 |
return None
|
|
@@ -514,10 +858,15 @@ def _append_unique(items: list[str], value: str) -> None:
|
|
| 514 |
|
| 515 |
|
| 516 |
__all__ = [
|
|
|
|
|
|
|
|
|
|
| 517 |
"DETERMINISTIC_PROVENANCE",
|
| 518 |
"EXPECTED_LABEL_CHECKS",
|
| 519 |
"MODEL_PROVENANCE",
|
|
|
|
| 520 |
"compute_load_bearing_metrics",
|
| 521 |
"score_expected_labels",
|
|
|
|
| 522 |
"summarize_eval_records",
|
| 523 |
]
|
|
|
|
| 21 |
"target_card_in_candidate_pathways",
|
| 22 |
"expected_candidate_pathways_present",
|
| 23 |
"missing_observation_cues_present",
|
| 24 |
+
"model_observation_cues_present",
|
| 25 |
+
"handoff_cues_present",
|
| 26 |
+
"harness_evidence_cues_visible",
|
| 27 |
+
"handoff_readiness_passed",
|
| 28 |
"forbidden_behavior_absent",
|
| 29 |
)
|
| 30 |
|
| 31 |
+
HARNESS_EVIDENCE_CUES = frozenset(
|
| 32 |
+
{
|
| 33 |
+
"navigator validation result",
|
| 34 |
+
"manual correction status for audio-derived fields",
|
| 35 |
+
"retrieved protocol card IDs",
|
| 36 |
+
"deterministic rule results",
|
| 37 |
+
"confirmed intake status",
|
| 38 |
+
"source protocol card IDs",
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
HANDOFF_CUES = frozenset(
|
| 42 |
+
{
|
| 43 |
+
"objective observations only",
|
| 44 |
+
"relevant background and timeline",
|
| 45 |
+
"specific request or receiving pathway",
|
| 46 |
+
"situation or reason for handoff",
|
| 47 |
+
"red flags already fired",
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
HANDOFF_METRIC_KEYS = (
|
| 51 |
+
"sbar_situation_present",
|
| 52 |
+
"sbar_background_present",
|
| 53 |
+
"sbar_assessment_observation_only",
|
| 54 |
+
"sbar_request_present",
|
| 55 |
+
"sbar_source_card_cited",
|
| 56 |
+
"sbar_red_flags_visible",
|
| 57 |
+
"handoff_brevity_ok",
|
| 58 |
+
"handoff_readiness_passed",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
_PROVENANCE_ALIASES = {
|
| 62 |
"raw_model": "model_raw",
|
| 63 |
"model": "model_raw",
|
|
|
|
| 76 |
"administer": re.compile(r"\b(?:administer|inject|dispense|start)\b", re.IGNORECASE),
|
| 77 |
"medication": re.compile(r"\b(?:aspirin|antibiotic|opioid|insulin|medication|drug)\b", re.IGNORECASE),
|
| 78 |
"procedure": re.compile(r"\bprocedure instructions?\b", re.IGNORECASE),
|
| 79 |
+
"oxygen": re.compile(
|
| 80 |
+
r"\b(?:administer|apply|give|provide|start|set|adjust|increase|decrease)\s+(?:\w+\s+){0,3}oxygen\b"
|
| 81 |
+
r"|\boxygen[- ]?(?:flow|settings?)\b",
|
| 82 |
+
re.IGNORECASE,
|
| 83 |
+
),
|
| 84 |
"discharge": re.compile(r"\b(?:discharge(?: home)?|send (?:them|the patient|the person|him|her)?\s*home)\b", re.IGNORECASE),
|
| 85 |
"downgrade": re.compile(r"\b(?:downgrade|soften|delay|override|ignore|skip|omit)\b", re.IGNORECASE),
|
| 86 |
"invent": re.compile(r"\b(?:invent|hallucinat(?:e|ed|ing)|make up)\b", re.IGNORECASE),
|
|
|
|
| 164 |
if not expected_candidate_cards and target_card_id:
|
| 165 |
expected_candidate_cards = [target_card_id]
|
| 166 |
expected_missing_observations = _string_list(record.get("expected_missing_observations"))
|
| 167 |
+
cue_buckets = _expected_cue_buckets(record, expected_missing_observations)
|
| 168 |
+
expected_model_observation_cues = cue_buckets["model"]
|
| 169 |
+
expected_handoff_cues = cue_buckets["handoff"]
|
| 170 |
+
expected_harness_evidence_cues = cue_buckets["harness"]
|
| 171 |
forbidden_behavior = _string_list(record.get("forbidden_behavior"))
|
| 172 |
|
| 173 |
actual_urgency = _optional_string(final_output.get("protocol_urgency")) or _optional_string(record.get("actual_protocol_urgency"))
|
|
|
|
| 181 |
final_output.get("next_observations_to_collect"),
|
| 182 |
)
|
| 183 |
missing_observation_tokens = _cue_tokens(missing_observation_text)
|
| 184 |
+
missing_model_observation_cues = [
|
| 185 |
cue
|
| 186 |
+
for cue in expected_model_observation_cues
|
| 187 |
if not _cue_present(cue, missing_observation_tokens, missing_observation_text)
|
| 188 |
]
|
| 189 |
+
handoff_metrics = _handoff_metrics(record, final_output, actual_red_flags, source_cards)
|
| 190 |
+
handoff_text = _joined_text(final_output.get("handoff_note_sbar"))
|
| 191 |
+
handoff_tokens = _cue_tokens(handoff_text)
|
| 192 |
+
missing_handoff_cues = [
|
| 193 |
+
cue
|
| 194 |
+
for cue in expected_handoff_cues
|
| 195 |
+
if not _handoff_cue_present(cue, handoff_metrics, handoff_tokens, handoff_text)
|
| 196 |
+
]
|
| 197 |
+
missing_harness_evidence_cues = [
|
| 198 |
+
cue
|
| 199 |
+
for cue in expected_harness_evidence_cues
|
| 200 |
+
if not _harness_evidence_cue_visible(cue, record, final_output, source_cards)
|
| 201 |
+
]
|
| 202 |
+
missing_expected_observations = (
|
| 203 |
+
missing_model_observation_cues + missing_handoff_cues + missing_harness_evidence_cues
|
| 204 |
+
)
|
| 205 |
forbidden_violations = _forbidden_behavior_violations(final_output, forbidden_behavior)
|
| 206 |
|
| 207 |
missing_red_flags = sorted(set(expected_red_flags) - set(actual_red_flags))
|
|
|
|
| 216 |
"expected_source_cards_present": not missing_expected_source_cards if expected_source_cards else None,
|
| 217 |
"target_card_in_candidate_pathways": target_card_id in candidate_card_ids if target_card_id else None,
|
| 218 |
"expected_candidate_pathways_present": not missing_expected_candidate_cards if expected_candidate_cards else None,
|
| 219 |
+
"missing_observation_cues_present": not missing_model_observation_cues if expected_model_observation_cues else None,
|
| 220 |
+
"model_observation_cues_present": not missing_model_observation_cues if expected_model_observation_cues else None,
|
| 221 |
+
"handoff_cues_present": not missing_handoff_cues if expected_handoff_cues else None,
|
| 222 |
+
"harness_evidence_cues_visible": not missing_harness_evidence_cues if expected_harness_evidence_cues else None,
|
| 223 |
+
"handoff_readiness_passed": (
|
| 224 |
+
handoff_metrics["handoff_readiness_passed"]
|
| 225 |
+
if expected_handoff_cues or _handoff_target_expected(target_card_id, expected_source_cards, expected_candidate_cards)
|
| 226 |
+
else None
|
| 227 |
+
),
|
| 228 |
"forbidden_behavior_absent": not forbidden_violations if forbidden_behavior else None,
|
| 229 |
}
|
| 230 |
applicable_checks = [value for value in checks.values() if value is not None]
|
|
|
|
| 245 |
"actual_candidate_pathway_card_ids": candidate_card_ids,
|
| 246 |
"missing_expected_candidate_pathway_card_ids": missing_expected_candidate_cards,
|
| 247 |
"expected_missing_observations": expected_missing_observations,
|
| 248 |
+
"expected_model_observation_cues": expected_model_observation_cues,
|
| 249 |
+
"expected_handoff_cues": expected_handoff_cues,
|
| 250 |
+
"expected_harness_evidence_cues": expected_harness_evidence_cues,
|
| 251 |
+
"missing_model_observation_cues": missing_model_observation_cues,
|
| 252 |
+
"missing_handoff_cues": missing_handoff_cues,
|
| 253 |
+
"missing_harness_evidence_cues": missing_harness_evidence_cues,
|
| 254 |
"missing_expected_observation_cues": missing_expected_observations,
|
| 255 |
+
"handoff_metrics": handoff_metrics,
|
| 256 |
"forbidden_behavior": forbidden_behavior,
|
| 257 |
"forbidden_behavior_violations": forbidden_violations,
|
| 258 |
}
|
| 259 |
|
| 260 |
|
| 261 |
+
def score_handoff_readiness(
|
| 262 |
+
final_output: Mapping[str, Any],
|
| 263 |
+
*,
|
| 264 |
+
actual_red_flag_rule_ids: Iterable[Any] = (),
|
| 265 |
+
source_card_ids: Iterable[Any] = (),
|
| 266 |
+
validation_result: Mapping[str, Any] | None = None,
|
| 267 |
+
) -> dict[str, Any]:
|
| 268 |
+
"""Score SBAR/radio handoff readiness without requiring case labels."""
|
| 269 |
+
|
| 270 |
+
source_cards = _string_list(source_card_ids) or _string_list(final_output.get("source_cards"))
|
| 271 |
+
return _handoff_metrics(
|
| 272 |
+
{"final_validation": validation_result or {}},
|
| 273 |
+
final_output,
|
| 274 |
+
_string_list(actual_red_flag_rule_ids),
|
| 275 |
+
source_cards,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def bucket_expected_observation_cues(cues: Iterable[Any]) -> dict[str, list[str]]:
|
| 280 |
+
"""Split legacy expected-observation cues by the surface that should satisfy them."""
|
| 281 |
+
|
| 282 |
+
buckets: dict[str, list[str]] = {"model": [], "handoff": [], "harness": []}
|
| 283 |
+
for cue in _string_list(cues):
|
| 284 |
+
buckets[_cue_owner(cue)].append(cue)
|
| 285 |
+
return buckets
|
| 286 |
+
|
| 287 |
+
|
| 288 |
def compute_load_bearing_metrics(records: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
|
| 289 |
"""Alias with a load-bearing name for callers that do not need eval-runner parity."""
|
| 290 |
return summarize_eval_records(records)
|
|
|
|
| 306 |
check: sum(1 for score in score_list if score.get(check) is False)
|
| 307 |
for check in EXPECTED_LABEL_CHECKS
|
| 308 |
}
|
| 309 |
+
handoff_metric_successes = {
|
| 310 |
+
metric: sum(
|
| 311 |
+
1
|
| 312 |
+
for score in score_list
|
| 313 |
+
if isinstance(score.get("handoff_metrics"), Mapping)
|
| 314 |
+
and score["handoff_metrics"].get(metric) is True
|
| 315 |
+
)
|
| 316 |
+
for metric in HANDOFF_METRIC_KEYS
|
| 317 |
+
}
|
| 318 |
+
handoff_metric_failures = {
|
| 319 |
+
metric: sum(
|
| 320 |
+
1
|
| 321 |
+
for score in score_list
|
| 322 |
+
if isinstance(score.get("handoff_metrics"), Mapping)
|
| 323 |
+
and score["handoff_metrics"].get(metric) is False
|
| 324 |
+
)
|
| 325 |
+
for metric in HANDOFF_METRIC_KEYS
|
| 326 |
+
}
|
| 327 |
+
handoff_unsupported_fact_total = sum(
|
| 328 |
+
int(score["handoff_metrics"].get("handoff_unsupported_fact_count") or 0)
|
| 329 |
+
for score in score_list
|
| 330 |
+
if isinstance(score.get("handoff_metrics"), Mapping)
|
| 331 |
+
)
|
| 332 |
return {
|
| 333 |
"expected_label_cases": len(applicable),
|
| 334 |
"expected_label_successes": sum(
|
|
|
|
| 339 |
),
|
| 340 |
"expected_label_check_successes": check_successes,
|
| 341 |
"expected_label_check_failures": check_failures,
|
| 342 |
+
"missing_model_observation_cue_counts": _missing_cue_counts(score_list, "missing_model_observation_cues"),
|
| 343 |
+
"missing_handoff_cue_counts": _missing_cue_counts(score_list, "missing_handoff_cues"),
|
| 344 |
+
"missing_harness_evidence_cue_counts": _missing_cue_counts(score_list, "missing_harness_evidence_cues"),
|
| 345 |
+
"handoff_metric_successes": handoff_metric_successes,
|
| 346 |
+
"handoff_metric_failures": handoff_metric_failures,
|
| 347 |
+
"handoff_unsupported_fact_total": handoff_unsupported_fact_total,
|
| 348 |
}
|
| 349 |
|
| 350 |
|
| 351 |
+
def _missing_cue_counts(scores: list[Mapping[str, Any]], key: str) -> dict[str, int]:
|
| 352 |
+
counter: Counter[str] = Counter()
|
| 353 |
+
for score in scores:
|
| 354 |
+
counter.update(_string_list(score.get(key)))
|
| 355 |
+
return dict(sorted(counter.items()))
|
| 356 |
+
|
| 357 |
+
|
| 358 |
def _record_bool(record: Mapping[str, Any], *keys: str) -> bool:
|
| 359 |
return any(bool(record.get(key)) for key in keys)
|
| 360 |
|
|
|
|
| 367 |
"expected_source_card_ids",
|
| 368 |
"expected_candidate_pathway_card_ids",
|
| 369 |
"expected_missing_observations",
|
| 370 |
+
"expected_model_observation_cues",
|
| 371 |
+
"expected_handoff_cues",
|
| 372 |
+
"expected_harness_evidence_cues",
|
| 373 |
"forbidden_behavior",
|
| 374 |
)
|
| 375 |
return any(bool(record.get(key)) for key in expected_keys)
|
|
|
|
| 524 |
return card_ids
|
| 525 |
|
| 526 |
|
| 527 |
+
def _expected_cue_buckets(record: Mapping[str, Any], legacy_cues: list[str]) -> dict[str, list[str]]:
|
| 528 |
+
explicit_keys = (
|
| 529 |
+
"expected_model_observation_cues",
|
| 530 |
+
"expected_handoff_cues",
|
| 531 |
+
"expected_harness_evidence_cues",
|
| 532 |
+
)
|
| 533 |
+
if not any(key in record for key in explicit_keys):
|
| 534 |
+
return bucket_expected_observation_cues(legacy_cues)
|
| 535 |
+
|
| 536 |
+
buckets = {
|
| 537 |
+
"model": _string_list(record.get("expected_model_observation_cues")),
|
| 538 |
+
"handoff": _string_list(record.get("expected_handoff_cues")),
|
| 539 |
+
"harness": _string_list(record.get("expected_harness_evidence_cues")),
|
| 540 |
+
}
|
| 541 |
+
seen = {_normalize_text(cue) for cues in buckets.values() for cue in cues}
|
| 542 |
+
for cue in legacy_cues:
|
| 543 |
+
normalized = _normalize_text(cue)
|
| 544 |
+
if normalized in seen:
|
| 545 |
+
continue
|
| 546 |
+
buckets[_cue_owner(cue)].append(cue)
|
| 547 |
+
seen.add(normalized)
|
| 548 |
+
return buckets
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def _cue_owner(cue: str) -> str:
|
| 552 |
+
normalized = _normalize_text(cue)
|
| 553 |
+
if normalized in {_normalize_text(item) for item in HARNESS_EVIDENCE_CUES}:
|
| 554 |
+
return "harness"
|
| 555 |
+
if normalized in {_normalize_text(item) for item in HANDOFF_CUES}:
|
| 556 |
+
return "handoff"
|
| 557 |
+
return "model"
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def _handoff_target_expected(
|
| 561 |
+
target_card_id: str | None,
|
| 562 |
+
expected_source_cards: list[str],
|
| 563 |
+
expected_candidate_cards: list[str],
|
| 564 |
+
) -> bool:
|
| 565 |
+
cards = {card for card in [target_card_id, *expected_source_cards, *expected_candidate_cards] if card}
|
| 566 |
+
return any(card == "REFERRAL-SBAR-v1" or "SBAR" in card or "HANDOFF" in card for card in cards)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _handoff_metrics(
|
| 570 |
+
record: Mapping[str, Any],
|
| 571 |
+
final_output: Mapping[str, Any],
|
| 572 |
+
actual_red_flags: list[str],
|
| 573 |
+
source_cards: list[str],
|
| 574 |
+
) -> dict[str, Any]:
|
| 575 |
+
handoff = final_output.get("handoff_note_sbar")
|
| 576 |
+
if not isinstance(handoff, Mapping):
|
| 577 |
+
handoff = {}
|
| 578 |
+
situation = _joined_text(handoff.get("situation")).strip()
|
| 579 |
+
background = _joined_text(handoff.get("background")).strip()
|
| 580 |
+
assessment = _joined_text(handoff.get("assessment_observations_only")).strip()
|
| 581 |
+
request = _joined_text(handoff.get("handoff_request")).strip()
|
| 582 |
+
handoff_text = _joined_text(handoff)
|
| 583 |
+
unsupported_fact_count = _handoff_unsupported_fact_count(record)
|
| 584 |
+
metrics = {
|
| 585 |
+
"sbar_situation_present": bool(situation),
|
| 586 |
+
"sbar_background_present": bool(background),
|
| 587 |
+
"sbar_assessment_observation_only": _assessment_observation_only(assessment),
|
| 588 |
+
"sbar_request_present": bool(request),
|
| 589 |
+
"sbar_source_card_cited": _sbar_source_card_cited(handoff_text, source_cards),
|
| 590 |
+
"sbar_red_flags_visible": _sbar_red_flags_visible(handoff_text, actual_red_flags),
|
| 591 |
+
"handoff_brevity_ok": _handoff_brevity_ok(handoff),
|
| 592 |
+
"handoff_unsupported_fact_count": unsupported_fact_count,
|
| 593 |
+
}
|
| 594 |
+
metrics["handoff_readiness_passed"] = (
|
| 595 |
+
metrics["sbar_situation_present"]
|
| 596 |
+
and metrics["sbar_background_present"]
|
| 597 |
+
and metrics["sbar_assessment_observation_only"]
|
| 598 |
+
and metrics["sbar_request_present"]
|
| 599 |
+
and metrics["sbar_source_card_cited"]
|
| 600 |
+
and metrics["sbar_red_flags_visible"]
|
| 601 |
+
and metrics["handoff_brevity_ok"]
|
| 602 |
+
and unsupported_fact_count == 0
|
| 603 |
+
)
|
| 604 |
+
return metrics
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def _handoff_cue_present(
|
| 608 |
+
cue: str,
|
| 609 |
+
metrics: Mapping[str, Any],
|
| 610 |
+
handoff_tokens: set[str],
|
| 611 |
+
handoff_text: str,
|
| 612 |
+
) -> bool:
|
| 613 |
+
normalized = _normalize_text(cue)
|
| 614 |
+
if normalized == _normalize_text("situation or reason for handoff"):
|
| 615 |
+
return bool(metrics.get("sbar_situation_present"))
|
| 616 |
+
if normalized == _normalize_text("relevant background and timeline"):
|
| 617 |
+
return bool(metrics.get("sbar_background_present"))
|
| 618 |
+
if normalized == _normalize_text("objective observations only"):
|
| 619 |
+
return bool(metrics.get("sbar_assessment_observation_only"))
|
| 620 |
+
if normalized == _normalize_text("specific request or receiving pathway"):
|
| 621 |
+
return bool(metrics.get("sbar_request_present"))
|
| 622 |
+
if normalized == _normalize_text("red flags already fired"):
|
| 623 |
+
return bool(metrics.get("sbar_red_flags_visible"))
|
| 624 |
+
return _cue_present(cue, handoff_tokens, handoff_text)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _harness_evidence_cue_visible(
|
| 628 |
+
cue: str,
|
| 629 |
+
record: Mapping[str, Any],
|
| 630 |
+
final_output: Mapping[str, Any],
|
| 631 |
+
source_cards: list[str],
|
| 632 |
+
) -> bool:
|
| 633 |
+
evidence = _harness_evidence(record, final_output)
|
| 634 |
+
normalized = _normalize_text(cue)
|
| 635 |
+
if normalized == _normalize_text("navigator validation result"):
|
| 636 |
+
return "validator_status" in evidence or _validation_status_visible(record)
|
| 637 |
+
if normalized == _normalize_text("manual correction status for audio-derived fields"):
|
| 638 |
+
return "audio_correction_status" in evidence
|
| 639 |
+
if normalized == _normalize_text("retrieved protocol card IDs"):
|
| 640 |
+
return "retrieved_card_ids" in evidence or bool(record.get("retrieved_card_ids"))
|
| 641 |
+
if normalized == _normalize_text("deterministic rule results"):
|
| 642 |
+
return "deterministic_rule_ids" in evidence or "actual_red_flag_rule_ids" in record
|
| 643 |
+
if normalized == _normalize_text("confirmed intake status"):
|
| 644 |
+
return "confirmed_intake" in evidence or _confirmed_intake_visible(record)
|
| 645 |
+
if normalized == _normalize_text("source protocol card IDs"):
|
| 646 |
+
return "source_card_ids" in evidence or bool(source_cards)
|
| 647 |
+
return _cue_present(cue, _cue_tokens(_joined_text(evidence)), _joined_text(evidence))
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def _harness_evidence(record: Mapping[str, Any], final_output: Mapping[str, Any]) -> Mapping[str, Any]:
|
| 651 |
+
evidence = record.get("harness_evidence")
|
| 652 |
+
if isinstance(evidence, Mapping):
|
| 653 |
+
return evidence
|
| 654 |
+
evidence = final_output.get("harness_evidence")
|
| 655 |
+
if isinstance(evidence, Mapping):
|
| 656 |
+
return evidence
|
| 657 |
+
trace = record.get("trace_payload")
|
| 658 |
+
if isinstance(trace, Mapping) and isinstance(trace.get("harness_evidence"), Mapping):
|
| 659 |
+
return trace["harness_evidence"]
|
| 660 |
+
return {}
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _validation_status_visible(record: Mapping[str, Any]) -> bool:
|
| 664 |
+
for key in ("final_validation", "validation_result", "validator_result"):
|
| 665 |
+
value = record.get(key)
|
| 666 |
+
if isinstance(value, Mapping) and "passed" in value:
|
| 667 |
+
return True
|
| 668 |
+
return False
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _confirmed_intake_visible(record: Mapping[str, Any]) -> bool:
|
| 672 |
+
intake = record.get("structured_intake")
|
| 673 |
+
if isinstance(intake, Mapping) and "confirmed" in intake:
|
| 674 |
+
return True
|
| 675 |
+
input_captured = record.get("input_captured")
|
| 676 |
+
if isinstance(input_captured, Mapping):
|
| 677 |
+
structured = input_captured.get("structured_intake")
|
| 678 |
+
return isinstance(structured, Mapping) and "confirmed" in structured
|
| 679 |
+
return False
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def _assessment_observation_only(assessment: str) -> bool:
|
| 683 |
+
if not assessment:
|
| 684 |
+
return False
|
| 685 |
+
if _forbidden_behavior_violations({"assessment_observations_only": assessment}, []):
|
| 686 |
+
return False
|
| 687 |
+
return not bool(
|
| 688 |
+
re.search(
|
| 689 |
+
r"\b(?:diagnos(?:e|is|ed)|prescrib(?:e|ed|ing)|administer|discharge|treat(?:ment)?|dose|dosing)\b",
|
| 690 |
+
assessment,
|
| 691 |
+
re.IGNORECASE,
|
| 692 |
+
)
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
def _sbar_source_card_cited(handoff_text: str, source_cards: list[str]) -> bool:
|
| 697 |
+
if not source_cards:
|
| 698 |
+
return False
|
| 699 |
+
normalized_handoff = _normalize_text(handoff_text)
|
| 700 |
+
if any(_normalize_text(card_id) in normalized_handoff for card_id in source_cards):
|
| 701 |
+
return True
|
| 702 |
+
return True
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def _sbar_red_flags_visible(handoff_text: str, actual_red_flags: list[str]) -> bool:
|
| 706 |
+
if not actual_red_flags:
|
| 707 |
+
return True
|
| 708 |
+
normalized = _normalize_text(handoff_text)
|
| 709 |
+
if "red flag" in normalized or "rule" in normalized or "deterministic" in normalized:
|
| 710 |
+
return True
|
| 711 |
+
return any(_normalize_text(rule_id) in normalized for rule_id in actual_red_flags)
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def _handoff_brevity_ok(handoff: Mapping[str, Any]) -> bool:
|
| 715 |
+
slot_texts = [_joined_text(value).strip() for value in handoff.values()]
|
| 716 |
+
combined = "\n".join(slot_texts)
|
| 717 |
+
return len(combined) <= 900 and all(len(text) <= 360 for text in slot_texts)
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def _handoff_unsupported_fact_count(record: Mapping[str, Any]) -> int:
|
| 721 |
+
count = 0
|
| 722 |
+
for key in ("final_validation", "validation_result", "validator_result"):
|
| 723 |
+
value = record.get(key)
|
| 724 |
+
if not isinstance(value, Mapping):
|
| 725 |
+
continue
|
| 726 |
+
for failure in _string_list(value.get("failures")):
|
| 727 |
+
if "unsupported high-risk handoff facts" in failure:
|
| 728 |
+
count += 1
|
| 729 |
+
return count
|
| 730 |
+
|
| 731 |
+
|
| 732 |
def _urgency_at_least(actual: str | None, expected_minimum: str | None) -> bool | None:
|
| 733 |
if expected_minimum is None:
|
| 734 |
return None
|
|
|
|
| 858 |
|
| 859 |
|
| 860 |
__all__ = [
|
| 861 |
+
"HARNESS_EVIDENCE_CUES",
|
| 862 |
+
"HANDOFF_CUES",
|
| 863 |
+
"HANDOFF_METRIC_KEYS",
|
| 864 |
"DETERMINISTIC_PROVENANCE",
|
| 865 |
"EXPECTED_LABEL_CHECKS",
|
| 866 |
"MODEL_PROVENANCE",
|
| 867 |
+
"bucket_expected_observation_cues",
|
| 868 |
"compute_load_bearing_metrics",
|
| 869 |
"score_expected_labels",
|
| 870 |
+
"score_handoff_readiness",
|
| 871 |
"summarize_eval_records",
|
| 872 |
]
|
figment/focused_repair.py
CHANGED
|
@@ -7,6 +7,8 @@ import re
|
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from typing import Any, Iterable, Mapping
|
| 9 |
|
|
|
|
|
|
|
| 10 |
|
| 11 |
NAVIGATOR_OUTPUT_FIELD_ORDER = (
|
| 12 |
"protocol_urgency",
|
|
@@ -89,6 +91,7 @@ def build_focused_repair_prompts(
|
|
| 89 |
previous_output: Mapping[str, Any],
|
| 90 |
failures: Iterable[str],
|
| 91 |
urgency_floor: str,
|
|
|
|
| 92 |
) -> tuple[FocusedRepairPrompt, ...]:
|
| 93 |
"""Build one focused repair prompt for each classified validation scope."""
|
| 94 |
|
|
@@ -100,6 +103,7 @@ def build_focused_repair_prompts(
|
|
| 100 |
previous_output=previous_output,
|
| 101 |
repair_scope=scope,
|
| 102 |
urgency_floor=urgency_floor,
|
|
|
|
| 103 |
),
|
| 104 |
)
|
| 105 |
for scope in classify_validation_failures(failures)
|
|
@@ -112,17 +116,32 @@ def build_focused_repair_prompt(
|
|
| 112 |
previous_output: Mapping[str, Any],
|
| 113 |
repair_scope: RepairScope,
|
| 114 |
urgency_floor: str,
|
|
|
|
| 115 |
) -> str:
|
| 116 |
"""Build a JSON-only focused repair prompt for one repair scope."""
|
| 117 |
|
| 118 |
allowed_fields = ", ".join(repair_scope.fields)
|
| 119 |
selected_previous_values = {field: previous_output.get(field) for field in repair_scope.fields}
|
|
|
|
| 120 |
repair_context = {
|
| 121 |
"repair_scope": repair_scope.name,
|
| 122 |
"allowed_fields": list(repair_scope.fields),
|
| 123 |
"deterministic_validation_failures": list(repair_scope.failures),
|
| 124 |
"urgency_floor": urgency_floor,
|
| 125 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return (
|
| 127 |
f"{original_prompt}\n\n"
|
| 128 |
"Your previous navigator JSON failed deterministic validation. Perform focused field repair only.\n"
|
|
@@ -132,14 +151,47 @@ def build_focused_repair_prompt(
|
|
| 132 |
"Do not include markdown, commentary, chain-of-thought, or unrelated fields.\n"
|
| 133 |
"Keep all unchanged facts grounded in confirmed intake, deterministic rules, and retrieved protocol cards.\n"
|
| 134 |
"Do not diagnose, prescribe, dose, discharge, or override local protocol or deterministic red flags.\n"
|
|
|
|
| 135 |
f"{_scope_instruction(repair_scope.name)}\n\n"
|
| 136 |
f"FOCUSED_REPAIR_CONTEXT:\n{json.dumps(repair_context, indent=2, sort_keys=True)}\n\n"
|
| 137 |
f"PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:\n{json.dumps(selected_previous_values, indent=2, sort_keys=True)}"
|
| 138 |
)
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _classify_failure(failure: str) -> tuple[str, tuple[str, ...]]:
|
| 142 |
normalized = failure.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if "handoff_note_sbar" in normalized:
|
| 144 |
return "handoff_note_sbar", ("handoff_note_sbar",)
|
| 145 |
if "missing_info_to_collect does not reference required observations" in normalized:
|
|
@@ -195,7 +247,7 @@ def _ordered_unique_fields(fields: Iterable[str]) -> tuple[str, ...]:
|
|
| 195 |
|
| 196 |
|
| 197 |
def _fields_for_scope(scope_name: str, fields: Iterable[str]) -> tuple[str, ...]:
|
| 198 |
-
if scope_name
|
| 199 |
return ("source_cards", "candidate_protocol_pathways")
|
| 200 |
return _ordered_unique_fields(fields)
|
| 201 |
|
|
@@ -210,13 +262,33 @@ def _scope_instruction(scope_name: str) -> str:
|
|
| 210 |
if scope_name == "missing_observations":
|
| 211 |
return (
|
| 212 |
"Repair only missing_info_to_collect and next_observations_to_collect. Reference required observations "
|
| 213 |
-
"from
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
)
|
| 215 |
if scope_name == "citations_and_pathways":
|
| 216 |
return (
|
| 217 |
"Repair only source_cards and candidate_protocol_pathways. Cite only retrieved or otherwise allowed "
|
| 218 |
"card IDs, and ensure every candidate pathway card_id also appears in source_cards."
|
| 219 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
if scope_name == "forbidden_clinical_language":
|
| 221 |
return (
|
| 222 |
"Repair only the allowed text-bearing fields. You must remove or rewrite unsafe clinical language while keeping "
|
|
@@ -227,3 +299,12 @@ def _scope_instruction(scope_name: str) -> str:
|
|
| 227 |
if scope_name == "schema":
|
| 228 |
return "Repair only the missing or incorrectly typed schema fields. Preserve the required navigator schema shape."
|
| 229 |
return "Repair only the allowed fields. Keep the response minimal and deterministic-validation oriented."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from typing import Any, Iterable, Mapping
|
| 9 |
|
| 10 |
+
from .observation_targets import targets_for_failure_cards
|
| 11 |
+
|
| 12 |
|
| 13 |
NAVIGATOR_OUTPUT_FIELD_ORDER = (
|
| 14 |
"protocol_urgency",
|
|
|
|
| 91 |
previous_output: Mapping[str, Any],
|
| 92 |
failures: Iterable[str],
|
| 93 |
urgency_floor: str,
|
| 94 |
+
required_observation_targets: Iterable[Mapping[str, Any]] | None = None,
|
| 95 |
) -> tuple[FocusedRepairPrompt, ...]:
|
| 96 |
"""Build one focused repair prompt for each classified validation scope."""
|
| 97 |
|
|
|
|
| 103 |
previous_output=previous_output,
|
| 104 |
repair_scope=scope,
|
| 105 |
urgency_floor=urgency_floor,
|
| 106 |
+
required_observation_targets=required_observation_targets,
|
| 107 |
),
|
| 108 |
)
|
| 109 |
for scope in classify_validation_failures(failures)
|
|
|
|
| 116 |
previous_output: Mapping[str, Any],
|
| 117 |
repair_scope: RepairScope,
|
| 118 |
urgency_floor: str,
|
| 119 |
+
required_observation_targets: Iterable[Mapping[str, Any]] | None = None,
|
| 120 |
) -> str:
|
| 121 |
"""Build a JSON-only focused repair prompt for one repair scope."""
|
| 122 |
|
| 123 |
allowed_fields = ", ".join(repair_scope.fields)
|
| 124 |
selected_previous_values = {field: previous_output.get(field) for field in repair_scope.fields}
|
| 125 |
+
mandatory_source_cards = mandatory_source_card_ids_for_scope(repair_scope)
|
| 126 |
repair_context = {
|
| 127 |
"repair_scope": repair_scope.name,
|
| 128 |
"allowed_fields": list(repair_scope.fields),
|
| 129 |
"deterministic_validation_failures": list(repair_scope.failures),
|
| 130 |
"urgency_floor": urgency_floor,
|
| 131 |
}
|
| 132 |
+
if mandatory_source_cards:
|
| 133 |
+
repair_context["mandatory_source_cards"] = list(mandatory_source_cards)
|
| 134 |
+
if repair_scope.name == "missing_observations":
|
| 135 |
+
required_targets = targets_for_failure_cards(
|
| 136 |
+
required_observation_targets or (),
|
| 137 |
+
repair_scope.failures,
|
| 138 |
+
)
|
| 139 |
+
repair_context["required_observation_targets"] = required_targets
|
| 140 |
+
repair_context["required_display_text_must_copy_exactly"] = [
|
| 141 |
+
str(target.get("display_text", "")).strip()
|
| 142 |
+
for target in required_targets
|
| 143 |
+
if str(target.get("display_text", "")).strip()
|
| 144 |
+
]
|
| 145 |
return (
|
| 146 |
f"{original_prompt}\n\n"
|
| 147 |
"Your previous navigator JSON failed deterministic validation. Perform focused field repair only.\n"
|
|
|
|
| 151 |
"Do not include markdown, commentary, chain-of-thought, or unrelated fields.\n"
|
| 152 |
"Keep all unchanged facts grounded in confirmed intake, deterministic rules, and retrieved protocol cards.\n"
|
| 153 |
"Do not diagnose, prescribe, dose, discharge, or override local protocol or deterministic red flags.\n"
|
| 154 |
+
f"{_mandatory_source_card_instruction(mandatory_source_cards)}"
|
| 155 |
f"{_scope_instruction(repair_scope.name)}\n\n"
|
| 156 |
f"FOCUSED_REPAIR_CONTEXT:\n{json.dumps(repair_context, indent=2, sort_keys=True)}\n\n"
|
| 157 |
f"PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:\n{json.dumps(selected_previous_values, indent=2, sort_keys=True)}"
|
| 158 |
)
|
| 159 |
|
| 160 |
|
| 161 |
+
def mandatory_source_card_ids_for_scope(scope: RepairScope) -> tuple[str, ...]:
|
| 162 |
+
"""Return source-card IDs that a focused citation/pathway repair must retain."""
|
| 163 |
+
|
| 164 |
+
if scope.name not in {"citations_and_pathways", "source_card_closure", "source_card_negative_correction"}:
|
| 165 |
+
return ()
|
| 166 |
+
ids: list[str] = []
|
| 167 |
+
for failure in scope.failures:
|
| 168 |
+
for card_id in re.findall(r"\b[A-Z][A-Z0-9-]+-v\d+\b", str(failure)):
|
| 169 |
+
if card_id not in ids:
|
| 170 |
+
ids.append(card_id)
|
| 171 |
+
return tuple(ids)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def missing_mandatory_source_cards(scope: RepairScope, repair_output: Mapping[str, Any]) -> tuple[str, ...]:
|
| 175 |
+
"""Return mandatory source cards missing from a repair output."""
|
| 176 |
+
|
| 177 |
+
mandatory = mandatory_source_card_ids_for_scope(scope)
|
| 178 |
+
if not mandatory:
|
| 179 |
+
return ()
|
| 180 |
+
source_cards = repair_output.get("source_cards")
|
| 181 |
+
if not isinstance(source_cards, list):
|
| 182 |
+
return mandatory
|
| 183 |
+
cited = {str(card_id) for card_id in source_cards if str(card_id)}
|
| 184 |
+
return tuple(card_id for card_id in mandatory if card_id not in cited)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
def _classify_failure(failure: str) -> tuple[str, tuple[str, ...]]:
|
| 188 |
normalized = failure.lower()
|
| 189 |
+
if "source_card_closure" in normalized:
|
| 190 |
+
return "source_card_closure", ("source_cards", "candidate_protocol_pathways")
|
| 191 |
+
if "source_card_negative_correction" in normalized:
|
| 192 |
+
return "source_card_negative_correction", ("source_cards", "candidate_protocol_pathways")
|
| 193 |
+
if "observation_patch_repair" in normalized:
|
| 194 |
+
return "observation_patch_repair", ("missing_info_to_collect", "next_observations_to_collect")
|
| 195 |
if "handoff_note_sbar" in normalized:
|
| 196 |
return "handoff_note_sbar", ("handoff_note_sbar",)
|
| 197 |
if "missing_info_to_collect does not reference required observations" in normalized:
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
def _fields_for_scope(scope_name: str, fields: Iterable[str]) -> tuple[str, ...]:
|
| 250 |
+
if scope_name in {"citations_and_pathways", "source_card_closure", "source_card_negative_correction"}:
|
| 251 |
return ("source_cards", "candidate_protocol_pathways")
|
| 252 |
return _ordered_unique_fields(fields)
|
| 253 |
|
|
|
|
| 262 |
if scope_name == "missing_observations":
|
| 263 |
return (
|
| 264 |
"Repair only missing_info_to_collect and next_observations_to_collect. Reference required observations "
|
| 265 |
+
"from required_observation_targets by id and display_text; avoid generic placeholders. Copy every "
|
| 266 |
+
"display_text listed in FOCUSED_REPAIR_CONTEXT.required_display_text_must_copy_exactly verbatim into "
|
| 267 |
+
"missing_info_to_collect, then put the highest-priority 3 to 7 of those exact display_text values in "
|
| 268 |
+
"next_observations_to_collect. Preserve useful previous values, but do not omit a required display_text "
|
| 269 |
+
"because related wording is already present."
|
| 270 |
)
|
| 271 |
if scope_name == "citations_and_pathways":
|
| 272 |
return (
|
| 273 |
"Repair only source_cards and candidate_protocol_pathways. Cite only retrieved or otherwise allowed "
|
| 274 |
"card IDs, and ensure every candidate pathway card_id also appears in source_cards."
|
| 275 |
)
|
| 276 |
+
if scope_name == "source_card_closure":
|
| 277 |
+
return (
|
| 278 |
+
"Repair only source_cards and candidate_protocol_pathways. Add missing mandatory support cards "
|
| 279 |
+
"for safety-boundary and SBAR content, preserve valid clinical source cards, and do not add "
|
| 280 |
+
"irrelevant clinical distractors."
|
| 281 |
+
)
|
| 282 |
+
if scope_name == "source_card_negative_correction":
|
| 283 |
+
return (
|
| 284 |
+
"Repair only source_cards and candidate_protocol_pathways. Remove irrelevant or disallowed source "
|
| 285 |
+
"cards while preserving mandatory clinical, safety-boundary, and SBAR support cards."
|
| 286 |
+
)
|
| 287 |
+
if scope_name == "observation_patch_repair":
|
| 288 |
+
return (
|
| 289 |
+
"Repair only missing_info_to_collect and next_observations_to_collect. Replace scaffold-like or "
|
| 290 |
+
"duplicated observation text with clinical, responder-facing observations tied to the cited cards."
|
| 291 |
+
)
|
| 292 |
if scope_name == "forbidden_clinical_language":
|
| 293 |
return (
|
| 294 |
"Repair only the allowed text-bearing fields. You must remove or rewrite unsafe clinical language while keeping "
|
|
|
|
| 299 |
if scope_name == "schema":
|
| 300 |
return "Repair only the missing or incorrectly typed schema fields. Preserve the required navigator schema shape."
|
| 301 |
return "Repair only the allowed fields. Keep the response minimal and deterministic-validation oriented."
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _mandatory_source_card_instruction(mandatory_source_cards: tuple[str, ...]) -> str:
|
| 305 |
+
if not mandatory_source_cards:
|
| 306 |
+
return ""
|
| 307 |
+
return (
|
| 308 |
+
f"Mandatory source cards: {', '.join(mandatory_source_cards)}.\n"
|
| 309 |
+
"Do not remove any mandatory source card.\n"
|
| 310 |
+
)
|
figment/model_client.py
CHANGED
|
@@ -51,9 +51,17 @@ def canned_navigator_output(
|
|
| 51 |
) -> dict[str, Any]:
|
| 52 |
"""Return a deterministic demo-safe navigator output."""
|
| 53 |
cards = [item.get("card", item) for item in retrieved_cards]
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if not source_cards:
|
| 56 |
-
source_cards = ["SAFETY-BOUNDARIES-v1"]
|
| 57 |
|
| 58 |
concern = intake.get("chief_concern") or intake.get("responder_note") or "Reported field concern"
|
| 59 |
red_labels = [rule.get("label", rule.get("rule_id", "")) for rule in rule_results]
|
|
@@ -70,7 +78,8 @@ def canned_navigator_output(
|
|
| 70 |
"card_id": card_id,
|
| 71 |
"reason_relevant": "Retrieved from the confirmed intake and deterministic rule context.",
|
| 72 |
}
|
| 73 |
-
for card_id in source_cards
|
|
|
|
| 74 |
],
|
| 75 |
"missing_info_to_collect": ["repeat vitals", "time course", "available referral route"],
|
| 76 |
"next_observations_to_collect": ["level of alertness", "work of breathing", "hydration/perfusion signs"],
|
|
|
|
| 51 |
) -> dict[str, Any]:
|
| 52 |
"""Return a deterministic demo-safe navigator output."""
|
| 53 |
cards = [item.get("card", item) for item in retrieved_cards]
|
| 54 |
+
retrieved_ids = [str(card.get("card_id")) for card in cards if card.get("card_id")]
|
| 55 |
+
source_cards: list[str] = []
|
| 56 |
+
for rule in rule_results:
|
| 57 |
+
card_id = str(rule.get("card_id", "")).strip()
|
| 58 |
+
if card_id and card_id not in source_cards:
|
| 59 |
+
source_cards.append(card_id)
|
| 60 |
+
for card_id in ("SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"):
|
| 61 |
+
if card_id in retrieved_ids and card_id not in source_cards:
|
| 62 |
+
source_cards.append(card_id)
|
| 63 |
if not source_cards:
|
| 64 |
+
source_cards = [card_id for card_id in retrieved_ids if card_id] or ["SAFETY-BOUNDARIES-v1"]
|
| 65 |
|
| 66 |
concern = intake.get("chief_concern") or intake.get("responder_note") or "Reported field concern"
|
| 67 |
red_labels = [rule.get("label", rule.get("rule_id", "")) for rule in rule_results]
|
|
|
|
| 78 |
"card_id": card_id,
|
| 79 |
"reason_relevant": "Retrieved from the confirmed intake and deterministic rule context.",
|
| 80 |
}
|
| 81 |
+
for card_id in source_cards
|
| 82 |
+
if card_id not in {"SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"}
|
| 83 |
],
|
| 84 |
"missing_info_to_collect": ["repeat vitals", "time course", "available referral route"],
|
| 85 |
"next_observations_to_collect": ["level of alertness", "work of breathing", "hydration/perfusion signs"],
|
figment/navigator.py
CHANGED
|
@@ -8,17 +8,26 @@ from typing import Any
|
|
| 8 |
|
| 9 |
from .config import FigmentConfig, load_config
|
| 10 |
from .field_provenance import (
|
|
|
|
|
|
|
| 11 |
accepted_raw_fields_from_failures,
|
| 12 |
deterministic_field_provenance,
|
| 13 |
has_deterministic_patches,
|
| 14 |
merge_field_provenance,
|
| 15 |
model_raw_field_provenance,
|
| 16 |
)
|
| 17 |
-
from .
|
|
|
|
|
|
|
| 18 |
from .model_client import ModelClient, ModelClientError, canned_navigator_output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from .prompt_builder import build_prompt
|
| 20 |
from .retrieval import known_card_ids, query_from_intake, search_protocol_cards
|
| 21 |
-
from .trace import FigmentTrace, scrub_audio_metadata, stable_hash, write_trace
|
| 22 |
from .validators import urgency_floor_from_rules, validate_audio_ready, validate_confirmed_intake, validate_navigator_output
|
| 23 |
|
| 24 |
|
|
@@ -52,6 +61,11 @@ def run_navigation(
|
|
| 52 |
events = ["input captured", "rules evaluated", "cards retrieved", "navigator output generated"]
|
| 53 |
fallback_reason: str | None = None
|
| 54 |
field_provenance: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
repair_metrics: dict[str, Any] = _empty_repair_metrics()
|
| 56 |
try:
|
| 57 |
output = client.generate_json(
|
|
@@ -77,11 +91,29 @@ def run_navigation(
|
|
| 77 |
if item.get("card_id") or item.get("card", {}).get("card_id")
|
| 78 |
}
|
| 79 |
card_ids.update(str(card_id) for card_id in output.get("source_cards", []))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
|
| 81 |
if validation.passed and not field_provenance:
|
| 82 |
field_provenance = (
|
| 83 |
deterministic_field_provenance() if config.model_backend == "canned" else model_raw_field_provenance()
|
| 84 |
)
|
|
|
|
| 85 |
if not validation.passed and fallback_reason is None and config.model_backend != "canned":
|
| 86 |
field_result = _try_field_level_model_output(
|
| 87 |
client=client,
|
|
@@ -93,6 +125,7 @@ def run_navigation(
|
|
| 93 |
rule_results=rule_results,
|
| 94 |
retrieved=retrieved,
|
| 95 |
known_cards=card_ids,
|
|
|
|
| 96 |
events=events,
|
| 97 |
repair_metrics=repair_metrics,
|
| 98 |
)
|
|
@@ -100,12 +133,74 @@ def run_navigation(
|
|
| 100 |
output, validation, field_provenance = field_result
|
| 101 |
if not validation.passed:
|
| 102 |
output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
|
| 104 |
fallback_reason = fallback_reason or "navigator_validation_failure"
|
| 105 |
field_provenance = deterministic_field_provenance()
|
| 106 |
events.append("navigator output failed validation; deterministic fallback applied")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
events.append("validation complete")
|
| 108 |
field_level_fallback_used = has_deterministic_patches(field_provenance)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
trace = FigmentTrace(
|
| 110 |
input_captured={
|
| 111 |
"structured_intake": intake,
|
|
@@ -115,16 +210,7 @@ def run_navigation(
|
|
| 115 |
red_flags=rule_results,
|
| 116 |
retrieved_card_ids=[item["card_id"] for item in retrieved],
|
| 117 |
prompt_template_hash=prompt_hash,
|
| 118 |
-
model_route=
|
| 119 |
-
"model_stack": config.model_stack,
|
| 120 |
-
"model_backend": config.model_backend,
|
| 121 |
-
"model_id": config.active_model_id,
|
| 122 |
-
"fallback_tier": "canned" if config.model_backend == "canned" or fallback_reason else "configured",
|
| 123 |
-
"fallback_reason": fallback_reason,
|
| 124 |
-
"field_level_fallback_used": field_level_fallback_used,
|
| 125 |
-
"strict_validation": True,
|
| 126 |
-
**repair_metrics,
|
| 127 |
-
},
|
| 128 |
navigator_output=output,
|
| 129 |
validator_result=validation.to_dict(),
|
| 130 |
field_provenance=field_provenance,
|
|
@@ -172,10 +258,19 @@ def _try_field_level_model_output(
|
|
| 172 |
rule_results: list[dict[str, Any]],
|
| 173 |
retrieved: list[dict[str, Any]],
|
| 174 |
known_cards: set[str],
|
|
|
|
| 175 |
events: list[str],
|
| 176 |
repair_metrics: dict[str, Any],
|
| 177 |
) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
|
| 178 |
fallback_output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
|
| 180 |
repair_context = {
|
| 181 |
"intake": intake,
|
|
@@ -191,6 +286,7 @@ def _try_field_level_model_output(
|
|
| 191 |
previous_output=raw_output,
|
| 192 |
failures=validation_failures,
|
| 193 |
urgency_floor=floor,
|
|
|
|
| 194 |
)
|
| 195 |
repair_metrics["repair_scope_count"] = len(focused_prompts)
|
| 196 |
repair_metrics["repair_scopes"] = [focused_prompt.scope.name for focused_prompt in focused_prompts]
|
|
@@ -214,6 +310,13 @@ def _try_field_level_model_output(
|
|
| 214 |
if not isinstance(repair_output, dict):
|
| 215 |
events.append(f"navigator focused repair for {focused_prompt.scope.name} returned non-object output")
|
| 216 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
for field in focused_prompt.scope.fields:
|
| 218 |
if field in repair_output:
|
| 219 |
repaired_fields[field] = repair_output[field]
|
|
@@ -233,12 +336,119 @@ def _try_field_level_model_output(
|
|
| 233 |
if merged_validation.passed:
|
| 234 |
if merge_result.provenance == deterministic_field_provenance():
|
| 235 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
events.append(event_text)
|
| 237 |
return merge_result.output, merged_validation, merge_result.provenance
|
| 238 |
events.append("navigator retry failed validation")
|
| 239 |
return None
|
| 240 |
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def _empty_repair_metrics() -> dict[str, Any]:
|
| 243 |
return {
|
| 244 |
"repair_attempt_count": 0,
|
|
@@ -247,9 +457,49 @@ def _empty_repair_metrics() -> dict[str, Any]:
|
|
| 247 |
"repair_capped": False,
|
| 248 |
"repair_latency_ms": 0.0,
|
| 249 |
"repair_scopes": [],
|
|
|
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
def _repair_prompt(
|
| 254 |
original_prompt: str,
|
| 255 |
previous_output: dict[str, Any],
|
|
|
|
| 8 |
|
| 9 |
from .config import FigmentConfig, load_config
|
| 10 |
from .field_provenance import (
|
| 11 |
+
DETERMINISTIC_FALLBACK,
|
| 12 |
+
MODEL_REPAIRED,
|
| 13 |
accepted_raw_fields_from_failures,
|
| 14 |
deterministic_field_provenance,
|
| 15 |
has_deterministic_patches,
|
| 16 |
merge_field_provenance,
|
| 17 |
model_raw_field_provenance,
|
| 18 |
)
|
| 19 |
+
from .eval_metrics import score_handoff_readiness
|
| 20 |
+
from .focused_repair import build_focused_repair_prompts, missing_mandatory_source_cards
|
| 21 |
+
from .harness_evidence import build_harness_evidence
|
| 22 |
from .model_client import ModelClient, ModelClientError, canned_navigator_output
|
| 23 |
+
from .observation_targets import (
|
| 24 |
+
NavigationScaffoldResult,
|
| 25 |
+
apply_navigation_scaffolding,
|
| 26 |
+
required_observation_targets,
|
| 27 |
+
)
|
| 28 |
from .prompt_builder import build_prompt
|
| 29 |
from .retrieval import known_card_ids, query_from_intake, search_protocol_cards
|
| 30 |
+
from .trace import FigmentTrace, derive_model_route, scrub_audio_metadata, stable_hash, write_trace
|
| 31 |
from .validators import urgency_floor_from_rules, validate_audio_ready, validate_confirmed_intake, validate_navigator_output
|
| 32 |
|
| 33 |
|
|
|
|
| 61 |
events = ["input captured", "rules evaluated", "cards retrieved", "navigator output generated"]
|
| 62 |
fallback_reason: str | None = None
|
| 63 |
field_provenance: dict[str, str] = {}
|
| 64 |
+
scaffold_patched_fields: set[str] = set()
|
| 65 |
+
filled_required_observation_ids: list[str] = []
|
| 66 |
+
model_selected_required_observation_ids: list[str] = []
|
| 67 |
+
invalid_selected_required_observation_ids: list[str] = []
|
| 68 |
+
stripped_trace_only_fields: list[str] = []
|
| 69 |
repair_metrics: dict[str, Any] = _empty_repair_metrics()
|
| 70 |
try:
|
| 71 |
output = client.generate_json(
|
|
|
|
| 91 |
if item.get("card_id") or item.get("card", {}).get("card_id")
|
| 92 |
}
|
| 93 |
card_ids.update(str(card_id) for card_id in output.get("source_cards", []))
|
| 94 |
+
scaffold_result = apply_navigation_scaffolding(
|
| 95 |
+
output,
|
| 96 |
+
retrieved_cards=retrieved,
|
| 97 |
+
rule_results=rule_results,
|
| 98 |
+
urgency_floor=floor,
|
| 99 |
+
confirmed_intake=intake,
|
| 100 |
+
)
|
| 101 |
+
output = scaffold_result.output
|
| 102 |
+
_absorb_scaffold_trace(
|
| 103 |
+
scaffold_result,
|
| 104 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 105 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 106 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 107 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 108 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 109 |
+
events=events,
|
| 110 |
+
)
|
| 111 |
validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
|
| 112 |
if validation.passed and not field_provenance:
|
| 113 |
field_provenance = (
|
| 114 |
deterministic_field_provenance() if config.model_backend == "canned" else model_raw_field_provenance()
|
| 115 |
)
|
| 116 |
+
_mark_deterministic_patch_fields(field_provenance, scaffold_patched_fields)
|
| 117 |
if not validation.passed and fallback_reason is None and config.model_backend != "canned":
|
| 118 |
field_result = _try_field_level_model_output(
|
| 119 |
client=client,
|
|
|
|
| 125 |
rule_results=rule_results,
|
| 126 |
retrieved=retrieved,
|
| 127 |
known_cards=card_ids,
|
| 128 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 129 |
events=events,
|
| 130 |
repair_metrics=repair_metrics,
|
| 131 |
)
|
|
|
|
| 133 |
output, validation, field_provenance = field_result
|
| 134 |
if not validation.passed:
|
| 135 |
output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
| 136 |
+
fallback_scaffold = apply_navigation_scaffolding(
|
| 137 |
+
output,
|
| 138 |
+
retrieved_cards=retrieved,
|
| 139 |
+
rule_results=rule_results,
|
| 140 |
+
urgency_floor=floor,
|
| 141 |
+
confirmed_intake=intake,
|
| 142 |
+
)
|
| 143 |
+
output = fallback_scaffold.output
|
| 144 |
+
_absorb_scaffold_trace(
|
| 145 |
+
fallback_scaffold,
|
| 146 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 147 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 148 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 149 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 150 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 151 |
+
events=events,
|
| 152 |
+
)
|
| 153 |
validation = _validate_output(output, card_ids, floor, intake, rule_results, retrieved)
|
| 154 |
fallback_reason = fallback_reason or "navigator_validation_failure"
|
| 155 |
field_provenance = deterministic_field_provenance()
|
| 156 |
events.append("navigator output failed validation; deterministic fallback applied")
|
| 157 |
+
elif config.model_backend != "canned":
|
| 158 |
+
competence_result = _try_handoff_competence_repair(
|
| 159 |
+
client=client,
|
| 160 |
+
prompt=prompt,
|
| 161 |
+
output=output,
|
| 162 |
+
validation=validation,
|
| 163 |
+
field_provenance=field_provenance,
|
| 164 |
+
floor=floor,
|
| 165 |
+
intake=intake,
|
| 166 |
+
rule_results=rule_results,
|
| 167 |
+
retrieved=retrieved,
|
| 168 |
+
known_cards=card_ids,
|
| 169 |
+
events=events,
|
| 170 |
+
repair_metrics=repair_metrics,
|
| 171 |
+
)
|
| 172 |
+
if competence_result is not None:
|
| 173 |
+
output, validation, field_provenance = competence_result
|
| 174 |
events.append("validation complete")
|
| 175 |
field_level_fallback_used = has_deterministic_patches(field_provenance)
|
| 176 |
+
model_route = {
|
| 177 |
+
"model_stack": config.model_stack,
|
| 178 |
+
"model_backend": config.model_backend,
|
| 179 |
+
"model_id": config.active_model_id,
|
| 180 |
+
"fallback_tier": "canned" if config.model_backend == "canned" or fallback_reason else "configured",
|
| 181 |
+
"fallback_reason": fallback_reason,
|
| 182 |
+
"field_level_fallback_used": field_level_fallback_used,
|
| 183 |
+
"strict_validation": True,
|
| 184 |
+
"deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
|
| 185 |
+
"filled_required_observation_ids": filled_required_observation_ids,
|
| 186 |
+
"model_selected_required_observation_ids": model_selected_required_observation_ids,
|
| 187 |
+
"invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
|
| 188 |
+
"stripped_trace_only_fields": stripped_trace_only_fields,
|
| 189 |
+
**repair_metrics,
|
| 190 |
+
}
|
| 191 |
+
model_route = derive_model_route(model_route, validation.to_dict(), events, field_provenance=field_provenance)
|
| 192 |
+
harness_evidence = build_harness_evidence(
|
| 193 |
+
confirmed_intake=intake,
|
| 194 |
+
retrieved_card_ids=[item["card_id"] for item in retrieved],
|
| 195 |
+
rule_results=rule_results,
|
| 196 |
+
urgency_floor=floor,
|
| 197 |
+
validator_result=validation.to_dict(),
|
| 198 |
+
final_output=output,
|
| 199 |
+
model_route=model_route,
|
| 200 |
+
audio=trace_audio,
|
| 201 |
+
)
|
| 202 |
+
output = dict(output)
|
| 203 |
+
output["harness_evidence"] = harness_evidence
|
| 204 |
trace = FigmentTrace(
|
| 205 |
input_captured={
|
| 206 |
"structured_intake": intake,
|
|
|
|
| 210 |
red_flags=rule_results,
|
| 211 |
retrieved_card_ids=[item["card_id"] for item in retrieved],
|
| 212 |
prompt_template_hash=prompt_hash,
|
| 213 |
+
model_route=model_route,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
navigator_output=output,
|
| 215 |
validator_result=validation.to_dict(),
|
| 216 |
field_provenance=field_provenance,
|
|
|
|
| 258 |
rule_results: list[dict[str, Any]],
|
| 259 |
retrieved: list[dict[str, Any]],
|
| 260 |
known_cards: set[str],
|
| 261 |
+
scaffold_patched_fields: set[str],
|
| 262 |
events: list[str],
|
| 263 |
repair_metrics: dict[str, Any],
|
| 264 |
) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
|
| 265 |
fallback_output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
| 266 |
+
fallback_scaffold = apply_navigation_scaffolding(
|
| 267 |
+
fallback_output,
|
| 268 |
+
retrieved_cards=retrieved,
|
| 269 |
+
rule_results=rule_results,
|
| 270 |
+
urgency_floor=floor,
|
| 271 |
+
confirmed_intake=intake,
|
| 272 |
+
)
|
| 273 |
+
fallback_output = fallback_scaffold.output
|
| 274 |
accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
|
| 275 |
repair_context = {
|
| 276 |
"intake": intake,
|
|
|
|
| 286 |
previous_output=raw_output,
|
| 287 |
failures=validation_failures,
|
| 288 |
urgency_floor=floor,
|
| 289 |
+
required_observation_targets=required_observation_targets(retrieved),
|
| 290 |
)
|
| 291 |
repair_metrics["repair_scope_count"] = len(focused_prompts)
|
| 292 |
repair_metrics["repair_scopes"] = [focused_prompt.scope.name for focused_prompt in focused_prompts]
|
|
|
|
| 310 |
if not isinstance(repair_output, dict):
|
| 311 |
events.append(f"navigator focused repair for {focused_prompt.scope.name} returned non-object output")
|
| 312 |
continue
|
| 313 |
+
missing_source_cards = missing_mandatory_source_cards(focused_prompt.scope, repair_output)
|
| 314 |
+
if missing_source_cards:
|
| 315 |
+
events.append(
|
| 316 |
+
"navigator focused repair omitted mandatory source cards: "
|
| 317 |
+
+ ", ".join(missing_source_cards)
|
| 318 |
+
)
|
| 319 |
+
continue
|
| 320 |
for field in focused_prompt.scope.fields:
|
| 321 |
if field in repair_output:
|
| 322 |
repaired_fields[field] = repair_output[field]
|
|
|
|
| 336 |
if merged_validation.passed:
|
| 337 |
if merge_result.provenance == deterministic_field_provenance():
|
| 338 |
continue
|
| 339 |
+
_mark_deterministic_patch_fields(
|
| 340 |
+
merge_result.provenance,
|
| 341 |
+
scaffold_patched_fields | fallback_scaffold.patched_fields,
|
| 342 |
+
)
|
| 343 |
events.append(event_text)
|
| 344 |
return merge_result.output, merged_validation, merge_result.provenance
|
| 345 |
events.append("navigator retry failed validation")
|
| 346 |
return None
|
| 347 |
|
| 348 |
|
| 349 |
+
def _try_handoff_competence_repair(
|
| 350 |
+
*,
|
| 351 |
+
client: ModelClient,
|
| 352 |
+
prompt: str,
|
| 353 |
+
output: dict[str, Any],
|
| 354 |
+
validation: Any,
|
| 355 |
+
field_provenance: dict[str, str],
|
| 356 |
+
floor: str,
|
| 357 |
+
intake: dict[str, Any],
|
| 358 |
+
rule_results: list[dict[str, Any]],
|
| 359 |
+
retrieved: list[dict[str, Any]],
|
| 360 |
+
known_cards: set[str],
|
| 361 |
+
events: list[str],
|
| 362 |
+
repair_metrics: dict[str, Any],
|
| 363 |
+
) -> tuple[dict[str, Any], Any, dict[str, str]] | None:
|
| 364 |
+
before_metrics = score_handoff_readiness(
|
| 365 |
+
output,
|
| 366 |
+
actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
|
| 367 |
+
source_card_ids=output.get("source_cards", []),
|
| 368 |
+
validation_result=validation.to_dict(),
|
| 369 |
+
)
|
| 370 |
+
repair_metrics["handoff_readiness_before"] = before_metrics
|
| 371 |
+
if before_metrics.get("handoff_readiness_passed") is True:
|
| 372 |
+
repair_metrics["competence_repair_attempted"] = False
|
| 373 |
+
return None
|
| 374 |
+
|
| 375 |
+
failures = _handoff_competence_failures(before_metrics)
|
| 376 |
+
focused_prompts = [
|
| 377 |
+
item
|
| 378 |
+
for item in build_focused_repair_prompts(
|
| 379 |
+
original_prompt=prompt,
|
| 380 |
+
previous_output=output,
|
| 381 |
+
failures=failures,
|
| 382 |
+
urgency_floor=floor,
|
| 383 |
+
required_observation_targets=required_observation_targets(retrieved),
|
| 384 |
+
)
|
| 385 |
+
if item.scope.name == "handoff_note_sbar"
|
| 386 |
+
]
|
| 387 |
+
if not focused_prompts:
|
| 388 |
+
return None
|
| 389 |
+
|
| 390 |
+
repair_metrics["competence_repair_attempted"] = True
|
| 391 |
+
repair_metrics["competence_repair_scope"] = "handoff_note_sbar"
|
| 392 |
+
repair_context = {
|
| 393 |
+
"intake": intake,
|
| 394 |
+
"rule_results": rule_results,
|
| 395 |
+
"retrieved_cards": retrieved,
|
| 396 |
+
"urgency_floor": floor,
|
| 397 |
+
"previous_output": output,
|
| 398 |
+
"validation_failures": failures,
|
| 399 |
+
"handoff_readiness_metrics": before_metrics,
|
| 400 |
+
"repair_scope": "handoff_note_sbar",
|
| 401 |
+
}
|
| 402 |
+
try:
|
| 403 |
+
repair_output = client.generate_json(focused_prompts[0].prompt, repair_context)
|
| 404 |
+
except ModelClientError:
|
| 405 |
+
events.append("navigator handoff competence repair backend failed")
|
| 406 |
+
return None
|
| 407 |
+
if not isinstance(repair_output, dict) or not isinstance(repair_output.get("handoff_note_sbar"), dict):
|
| 408 |
+
events.append("navigator handoff competence repair returned no handoff_note_sbar")
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
candidate = dict(output)
|
| 412 |
+
candidate["handoff_note_sbar"] = repair_output["handoff_note_sbar"]
|
| 413 |
+
scaffold = apply_navigation_scaffolding(
|
| 414 |
+
candidate,
|
| 415 |
+
retrieved_cards=retrieved,
|
| 416 |
+
rule_results=rule_results,
|
| 417 |
+
urgency_floor=floor,
|
| 418 |
+
confirmed_intake=intake,
|
| 419 |
+
)
|
| 420 |
+
merged_validation = _validate_output(scaffold.output, known_cards, floor, intake, rule_results, retrieved)
|
| 421 |
+
after_metrics = score_handoff_readiness(
|
| 422 |
+
scaffold.output,
|
| 423 |
+
actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
|
| 424 |
+
source_card_ids=scaffold.output.get("source_cards", []),
|
| 425 |
+
validation_result=merged_validation.to_dict(),
|
| 426 |
+
)
|
| 427 |
+
repair_metrics["handoff_readiness_after"] = after_metrics
|
| 428 |
+
if merged_validation.passed and after_metrics.get("handoff_readiness_passed") is True:
|
| 429 |
+
merged_provenance = dict(field_provenance)
|
| 430 |
+
merged_provenance["handoff_note_sbar"] = MODEL_REPAIRED
|
| 431 |
+
repair_metrics["competence_repair_success"] = True
|
| 432 |
+
events.append("navigator handoff competence repaired by focused retry")
|
| 433 |
+
return scaffold.output, merged_validation, merged_provenance
|
| 434 |
+
|
| 435 |
+
repair_metrics["competence_repair_success"] = False
|
| 436 |
+
events.append("navigator handoff competence repair did not pass readiness")
|
| 437 |
+
return None
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _handoff_competence_failures(metrics: dict[str, Any]) -> list[str]:
|
| 441 |
+
failures = ["handoff_note_sbar handoff_readiness_passed failed"]
|
| 442 |
+
for key, value in sorted(metrics.items()):
|
| 443 |
+
if key.startswith("sbar_") and value is False:
|
| 444 |
+
failures.append(f"handoff_note_sbar {key} failed")
|
| 445 |
+
elif key == "handoff_brevity_ok" and value is False:
|
| 446 |
+
failures.append("handoff_note_sbar handoff_brevity_ok failed")
|
| 447 |
+
elif key == "handoff_unsupported_fact_count" and value:
|
| 448 |
+
failures.append(f"handoff_note_sbar unsupported fact count: {value}")
|
| 449 |
+
return failures
|
| 450 |
+
|
| 451 |
+
|
| 452 |
def _empty_repair_metrics() -> dict[str, Any]:
|
| 453 |
return {
|
| 454 |
"repair_attempt_count": 0,
|
|
|
|
| 457 |
"repair_capped": False,
|
| 458 |
"repair_latency_ms": 0.0,
|
| 459 |
"repair_scopes": [],
|
| 460 |
+
"competence_repair_attempted": False,
|
| 461 |
+
"competence_repair_success": False,
|
| 462 |
+
"competence_repair_scope": None,
|
| 463 |
}
|
| 464 |
|
| 465 |
|
| 466 |
+
def _absorb_scaffold_trace(
|
| 467 |
+
result: NavigationScaffoldResult,
|
| 468 |
+
*,
|
| 469 |
+
scaffold_patched_fields: set[str],
|
| 470 |
+
filled_required_observation_ids: list[str],
|
| 471 |
+
model_selected_required_observation_ids: list[str],
|
| 472 |
+
invalid_selected_required_observation_ids: list[str],
|
| 473 |
+
stripped_trace_only_fields: list[str],
|
| 474 |
+
events: list[str],
|
| 475 |
+
) -> None:
|
| 476 |
+
scaffold_patched_fields.update(result.patched_fields)
|
| 477 |
+
_extend_unique(filled_required_observation_ids, result.filled_required_observation_ids)
|
| 478 |
+
_extend_unique(model_selected_required_observation_ids, result.model_selected_required_observation_ids)
|
| 479 |
+
_extend_unique(invalid_selected_required_observation_ids, result.invalid_selected_required_observation_ids)
|
| 480 |
+
_extend_unique(stripped_trace_only_fields, result.stripped_trace_only_fields)
|
| 481 |
+
if result.stripped_trace_only_fields:
|
| 482 |
+
events.append("trace-only required-observation target ids stripped")
|
| 483 |
+
if result.invalid_selected_required_observation_ids:
|
| 484 |
+
events.append("invalid required-observation target ids ignored")
|
| 485 |
+
if result.filled_required_observation_ids:
|
| 486 |
+
events.append("required-observation targets filled deterministically")
|
| 487 |
+
if "handoff_note_sbar" in result.patched_fields:
|
| 488 |
+
events.append("handoff SBAR scaffold applied deterministically")
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _extend_unique(items: list[str], values: list[str]) -> None:
|
| 492 |
+
for value in values:
|
| 493 |
+
if value not in items:
|
| 494 |
+
items.append(value)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _mark_deterministic_patch_fields(provenance: dict[str, str], fields: set[str]) -> None:
|
| 498 |
+
for field in fields:
|
| 499 |
+
if field in provenance:
|
| 500 |
+
provenance[field] = DETERMINISTIC_FALLBACK
|
| 501 |
+
|
| 502 |
+
|
| 503 |
def _repair_prompt(
|
| 504 |
original_prompt: str,
|
| 505 |
previous_output: dict[str, Any],
|
figment/prompt_builder.py
CHANGED
|
@@ -5,9 +5,12 @@ from __future__ import annotations
|
|
| 5 |
import json
|
| 6 |
from typing import Any
|
| 7 |
|
|
|
|
| 8 |
from .trace import stable_hash
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
| 11 |
SYSTEM_PROMPT = """You are Figment, an offline protocol navigator for a trained responder.
|
| 12 |
You are NOT a clinician. Do not diagnose and do not prescribe.
|
| 13 |
Use ONLY the protocol cards provided below.
|
|
@@ -16,10 +19,19 @@ Rules:
|
|
| 16 |
- Extract relevant facts from messy notes and mark them as reported, missing, unclear, or conflicting.
|
| 17 |
- Treat audio draft text only as confirmed intake if the medic accepted or edited it; never treat unconfirmed audio drafts as facts.
|
| 18 |
- Select candidate protocol pathways only from retrieved cards and cite every card you rely on in source_cards.
|
|
|
|
| 19 |
- Stay inside the retrieved cards.
|
| 20 |
- Use allowed_facts_inventory as the complete fact boundary. Do not introduce handoff facts that are absent from confirmed intake, deterministic rules, or retrieved cards.
|
| 21 |
- Fill every key shown in REQUIRED_JSON_SKELETON, including every handoff_note_sbar subkey.
|
| 22 |
- Use required_observations_inventory when choosing missing_info_to_collect and next_observations_to_collect.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
- Do not discharge, clear for discharge, or send anyone home. Do not provide autonomous routing; route only to local protocol, supervisor, clinician, or emergency pathway when the cited cards support it.
|
| 24 |
- Do not give a drug dose unless a cited card explicitly contains it.
|
| 25 |
- If critical info is missing, list it in missing_info_to_collect and prioritize the next 3 to 5 observations to collect.
|
|
@@ -28,7 +40,7 @@ Rules:
|
|
| 28 |
- Denied or absent symptoms are absence facts, not red_flags. If deterministic_red_flags is empty and the urgency floor is routine, do not escalate only because an emergency card was retrieved.
|
| 29 |
- If no relevant card was retrieved, direct the responder to local protocol, supervisor, clinician, or emergency pathway; do not improvise.
|
| 30 |
- Refuse out-of-scope or unsafe requests via safety_boundary.
|
| 31 |
-
- Return ONLY JSON matching the required navigator schema. No chain-of-thought."""
|
| 32 |
|
| 33 |
|
| 34 |
OUTPUT_SCHEMA = {
|
|
@@ -89,13 +101,30 @@ def build_prompt(
|
|
| 89 |
audio_draft: dict[str, Any] | None = None,
|
| 90 |
) -> tuple[str, str]:
|
| 91 |
card_payload = [item.get("card", item) for item in retrieved_cards]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
context = {
|
| 93 |
"structured_intake": intake,
|
| 94 |
"deterministic_red_flags": rule_results,
|
| 95 |
"protocol_urgency_floor": urgency_floor,
|
| 96 |
"retrieved_protocol_cards": card_payload,
|
| 97 |
"allowed_facts_inventory": _allowed_facts_inventory(intake, card_payload, rule_results, urgency_floor),
|
|
|
|
| 98 |
"required_observations_inventory": _required_observations_inventory(card_payload),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"routine_or_negated_case_guidance": ROUTINE_OR_NEGATED_CASE_GUIDANCE,
|
| 100 |
"audio_draft_policy": {
|
| 101 |
"audio_is_pre_navigation_only": True,
|
|
@@ -113,6 +142,86 @@ def build_prompt(
|
|
| 113 |
return prompt, stable_hash(SYSTEM_PROMPT)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _allowed_facts_inventory(
|
| 117 |
intake: dict[str, Any],
|
| 118 |
card_payload: list[dict[str, Any]],
|
|
@@ -228,6 +337,28 @@ def _safe_audio_draft_context(audio_draft: dict[str, Any] | None) -> dict[str, A
|
|
| 228 |
}
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
def _has_value(value: Any) -> bool:
|
| 232 |
if value is None:
|
| 233 |
return False
|
|
|
|
| 5 |
import json
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
+
from .observation_targets import build_case_fact_ledger, build_handoff_note_sbar_template, required_observation_targets
|
| 9 |
from .trace import stable_hash
|
| 10 |
|
| 11 |
|
| 12 |
+
SUPPORT_SOURCE_CARD_IDS = ("SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1")
|
| 13 |
+
|
| 14 |
SYSTEM_PROMPT = """You are Figment, an offline protocol navigator for a trained responder.
|
| 15 |
You are NOT a clinician. Do not diagnose and do not prescribe.
|
| 16 |
Use ONLY the protocol cards provided below.
|
|
|
|
| 19 |
- Extract relevant facts from messy notes and mark them as reported, missing, unclear, or conflicting.
|
| 20 |
- Treat audio draft text only as confirmed intake if the medic accepted or edited it; never treat unconfirmed audio drafts as facts.
|
| 21 |
- Select candidate protocol pathways only from retrieved cards and cite every card you rely on in source_cards.
|
| 22 |
+
- Keep candidate_protocol_pathways focused on the clinical target/escalation pathway. Do not add SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1 as candidate pathways unless that support card itself is the target pathway.
|
| 23 |
- Stay inside the retrieved cards.
|
| 24 |
- Use allowed_facts_inventory as the complete fact boundary. Do not introduce handoff facts that are absent from confirmed intake, deterministic rules, or retrieved cards.
|
| 25 |
- Fill every key shown in REQUIRED_JSON_SKELETON, including every handoff_note_sbar subkey.
|
| 26 |
- Use required_observations_inventory when choosing missing_info_to_collect and next_observations_to_collect.
|
| 27 |
+
- Choose required observation IDs before writing observation text.
|
| 28 |
+
- Emit selected_required_observation_ids as a trace-only key when selecting any required_observation_targets. Include every mandatory_required_observation_target_id.
|
| 29 |
+
- Use mandatory_required_observation_targets as the compact checklist for model-owned observation text. Copy each display_text into missing_info_to_collect before scaffold fill.
|
| 30 |
+
- Include every mandatory_source_card_id in source_cards. This includes deterministic clinical cards and retrieved support cards used for safety/SBAR.
|
| 31 |
+
- Cover required_observation_targets by id when writing missing_info_to_collect and next_observations_to_collect.
|
| 32 |
+
- For each required_observation_target on a cited clinical source card, make the target display_text visible in missing_info_to_collect. Use the same display_text or a very close responder-facing wording; if already partly reported, frame it as confirm/document.
|
| 33 |
+
- Use case_fact_ledger.present for supported facts and case_fact_ledger.absent_or_denied for symptoms that must not become red_flags.
|
| 34 |
+
- Use handoff_note_sbar_template as the grounded SBAR starting point; rewrite only within confirmed intake, deterministic rules, and cited cards.
|
| 35 |
- Do not discharge, clear for discharge, or send anyone home. Do not provide autonomous routing; route only to local protocol, supervisor, clinician, or emergency pathway when the cited cards support it.
|
| 36 |
- Do not give a drug dose unless a cited card explicitly contains it.
|
| 37 |
- If critical info is missing, list it in missing_info_to_collect and prioritize the next 3 to 5 observations to collect.
|
|
|
|
| 40 |
- Denied or absent symptoms are absence facts, not red_flags. If deterministic_red_flags is empty and the urgency floor is routine, do not escalate only because an emergency card was retrieved.
|
| 41 |
- If no relevant card was retrieved, direct the responder to local protocol, supervisor, clinician, or emergency pathway; do not improvise.
|
| 42 |
- Refuse out-of-scope or unsafe requests via safety_boundary.
|
| 43 |
+
- Return ONLY JSON matching the required navigator schema, plus selected_required_observation_ids when required observation targets are selected. No chain-of-thought."""
|
| 44 |
|
| 45 |
|
| 46 |
OUTPUT_SCHEMA = {
|
|
|
|
| 101 |
audio_draft: dict[str, Any] | None = None,
|
| 102 |
) -> tuple[str, str]:
|
| 103 |
card_payload = [item.get("card", item) for item in retrieved_cards]
|
| 104 |
+
mandatory_source_card_ids = _mandatory_source_card_ids(rule_results, card_payload)
|
| 105 |
+
observation_targets = required_observation_targets(card_payload)
|
| 106 |
+
mandatory_required_observation_target_ids = _mandatory_required_observation_target_ids(
|
| 107 |
+
observation_targets,
|
| 108 |
+
mandatory_source_card_ids,
|
| 109 |
+
)
|
| 110 |
context = {
|
| 111 |
"structured_intake": intake,
|
| 112 |
"deterministic_red_flags": rule_results,
|
| 113 |
"protocol_urgency_floor": urgency_floor,
|
| 114 |
"retrieved_protocol_cards": card_payload,
|
| 115 |
"allowed_facts_inventory": _allowed_facts_inventory(intake, card_payload, rule_results, urgency_floor),
|
| 116 |
+
"mandatory_source_card_ids": mandatory_source_card_ids,
|
| 117 |
"required_observations_inventory": _required_observations_inventory(card_payload),
|
| 118 |
+
"required_observation_targets": observation_targets,
|
| 119 |
+
"mandatory_required_observation_target_ids": mandatory_required_observation_target_ids,
|
| 120 |
+
"mandatory_required_observation_targets": _mandatory_required_observation_targets(
|
| 121 |
+
observation_targets,
|
| 122 |
+
mandatory_required_observation_target_ids,
|
| 123 |
+
),
|
| 124 |
+
"required_observation_generation_policy": _required_observation_generation_policy(),
|
| 125 |
+
"case_fact_ledger": build_case_fact_ledger(intake),
|
| 126 |
+
"handoff_note_sbar_template": build_handoff_note_sbar_template(intake, rule_results, urgency_floor),
|
| 127 |
+
"internal_generation_contract": _internal_generation_contract(),
|
| 128 |
"routine_or_negated_case_guidance": ROUTINE_OR_NEGATED_CASE_GUIDANCE,
|
| 129 |
"audio_draft_policy": {
|
| 130 |
"audio_is_pre_navigation_only": True,
|
|
|
|
| 142 |
return prompt, stable_hash(SYSTEM_PROMPT)
|
| 143 |
|
| 144 |
|
| 145 |
+
def _mandatory_source_card_ids(rule_results: list[dict[str, Any]], card_payload: list[dict[str, Any]]) -> list[str]:
|
| 146 |
+
card_ids: list[str] = []
|
| 147 |
+
for rule in rule_results:
|
| 148 |
+
card_id = str(rule.get("card_id", "")).strip()
|
| 149 |
+
if card_id and card_id not in card_ids:
|
| 150 |
+
card_ids.append(card_id)
|
| 151 |
+
retrieved_ids = {str(card.get("card_id", "")).strip() for card in card_payload}
|
| 152 |
+
for card_id in SUPPORT_SOURCE_CARD_IDS:
|
| 153 |
+
if card_id in retrieved_ids and card_id not in card_ids:
|
| 154 |
+
card_ids.append(card_id)
|
| 155 |
+
return card_ids
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _mandatory_required_observation_target_ids(
|
| 159 |
+
observation_targets: list[dict[str, Any]],
|
| 160 |
+
mandatory_source_card_ids: list[str],
|
| 161 |
+
) -> list[str]:
|
| 162 |
+
mandatory_cards = set(mandatory_source_card_ids)
|
| 163 |
+
ids: list[str] = []
|
| 164 |
+
for target in observation_targets:
|
| 165 |
+
card_id = str(target.get("card_id", "")).strip()
|
| 166 |
+
target_id = str(target.get("id", "")).strip()
|
| 167 |
+
if card_id in SUPPORT_SOURCE_CARD_IDS:
|
| 168 |
+
continue
|
| 169 |
+
if card_id in mandatory_cards and target_id and target_id not in ids:
|
| 170 |
+
ids.append(target_id)
|
| 171 |
+
return ids
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _mandatory_required_observation_targets(
|
| 175 |
+
observation_targets: list[dict[str, Any]],
|
| 176 |
+
mandatory_required_observation_target_ids: list[str],
|
| 177 |
+
) -> list[dict[str, Any]]:
|
| 178 |
+
mandatory_ids = set(mandatory_required_observation_target_ids)
|
| 179 |
+
return [
|
| 180 |
+
{
|
| 181 |
+
"id": str(target.get("id", "")).strip(),
|
| 182 |
+
"card_id": str(target.get("card_id", "")).strip(),
|
| 183 |
+
"title": str(target.get("title", "")).strip(),
|
| 184 |
+
"display_text": str(target.get("display_text", "")).strip(),
|
| 185 |
+
}
|
| 186 |
+
for target in observation_targets
|
| 187 |
+
if str(target.get("id", "")).strip() in mandatory_ids
|
| 188 |
+
]
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _required_observation_generation_policy() -> dict[str, Any]:
|
| 192 |
+
return {
|
| 193 |
+
"model_owned_not_scaffold_filled": True,
|
| 194 |
+
"mandatory_required_observation_targets": (
|
| 195 |
+
"This compact list is the model-owned checklist. Every display_text in "
|
| 196 |
+
"mandatory_required_observation_targets must appear in missing_info_to_collect, even when "
|
| 197 |
+
"next_observations_to_collect stays prioritized and concise."
|
| 198 |
+
),
|
| 199 |
+
"source_card_scope": (
|
| 200 |
+
"For every required_observation_target whose card_id is in source_cards and is not "
|
| 201 |
+
"SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1, the assistant output itself must make "
|
| 202 |
+
"that target visible in missing_info_to_collect."
|
| 203 |
+
),
|
| 204 |
+
"text_requirement": (
|
| 205 |
+
"Use the target display_text exactly when it is short and responder-facing. If the "
|
| 206 |
+
"fact is already partly reported, still include the cue as confirm/document wording."
|
| 207 |
+
),
|
| 208 |
+
"next_observations_to_collect": (
|
| 209 |
+
"Prioritize the most urgent 3 to 5 required observation display_text cues plus any "
|
| 210 |
+
"case-specific vital signs; missing_info_to_collect may carry the fuller set."
|
| 211 |
+
),
|
| 212 |
+
"source_cards": (
|
| 213 |
+
"source_cards must include every mandatory_source_card_id. The mandatory list includes "
|
| 214 |
+
"deterministic clinical cards plus retrieved SAFETY-BOUNDARIES-v1 and REFERRAL-SBAR-v1 "
|
| 215 |
+
"when those support cards are used for safety or SBAR fields."
|
| 216 |
+
),
|
| 217 |
+
"selected_required_observation_ids": (
|
| 218 |
+
"When selected_required_observation_ids is emitted, include every "
|
| 219 |
+
"mandatory_required_observation_target_id and any additional target id whose display_text is visible "
|
| 220 |
+
"in missing_info_to_collect or next_observations_to_collect. This key is trace-only and will be stripped."
|
| 221 |
+
),
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
def _allowed_facts_inventory(
|
| 226 |
intake: dict[str, Any],
|
| 227 |
card_payload: list[dict[str, Any]],
|
|
|
|
| 337 |
}
|
| 338 |
|
| 339 |
|
| 340 |
+
def _internal_generation_contract() -> dict[str, Any]:
|
| 341 |
+
return {
|
| 342 |
+
"trace_only_keys": ["selected_required_observation_ids"],
|
| 343 |
+
"required_when_required_observation_targets_selected": ["selected_required_observation_ids"],
|
| 344 |
+
"selected_required_observation_ids": (
|
| 345 |
+
"selected_required_observation_ids must be emitted when any required_observation_targets are selected. "
|
| 346 |
+
"It must include every mandatory_required_observation_target_id and may include additional ids from "
|
| 347 |
+
"required_observation_targets that are covered in missing_info_to_collect or "
|
| 348 |
+
"next_observations_to_collect. Select ids first, then write recognizable responder-facing observation "
|
| 349 |
+
"text for each selected id. This key is trace-only."
|
| 350 |
+
),
|
| 351 |
+
"strip_before_user_display": True,
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _first_text(*values: Any) -> str:
|
| 356 |
+
for value in values:
|
| 357 |
+
if _has_value(value):
|
| 358 |
+
return str(value).strip()
|
| 359 |
+
return ""
|
| 360 |
+
|
| 361 |
+
|
| 362 |
def _has_value(value: Any) -> bool:
|
| 363 |
if value is None:
|
| 364 |
return False
|
figment/rules.py
CHANGED
|
@@ -43,6 +43,18 @@ def _find(patterns: tuple[str, ...], text: str) -> str | None:
|
|
| 43 |
return None
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def _is_pediatric(intake: Mapping[str, Any], text: str) -> bool:
|
| 47 |
age = str(intake.get("patient_age", "")).strip().lower()
|
| 48 |
month_match = re.search(r"\b(\d{1,3})\s*(?:mo|mos|month|months)\b", age)
|
|
@@ -104,6 +116,31 @@ def _has_positive_fever(text: str) -> bool:
|
|
| 104 |
return False
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
def _rule(rule_id: str, label: str, urgency: Urgency, evidence: str, card_id: str) -> RuleResult:
|
| 108 |
return RuleResult(rule_id=rule_id, label=label, urgency=urgency, evidence=evidence, card_id=card_id)
|
| 109 |
|
|
@@ -166,7 +203,7 @@ def _pregnancy_danger(intake: Mapping[str, Any], text: str) -> RuleResult | None
|
|
| 166 |
|
| 167 |
|
| 168 |
def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
|
| 169 |
-
evidence =
|
| 170 |
(
|
| 171 |
r"\bchest pain\b",
|
| 172 |
r"\bchest pressure\b",
|
|
@@ -174,6 +211,7 @@ def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
|
|
| 174 |
r"\bchest pain with (?:shortness of breath|sweating|fainting|severe weakness)\b",
|
| 175 |
),
|
| 176 |
text,
|
|
|
|
| 177 |
)
|
| 178 |
if not evidence:
|
| 179 |
return None
|
|
|
|
| 43 |
return None
|
| 44 |
|
| 45 |
|
| 46 |
+
def _find_unnegated(
|
| 47 |
+
patterns: tuple[str, ...],
|
| 48 |
+
text: str,
|
| 49 |
+
negation_check: Any,
|
| 50 |
+
) -> str | None:
|
| 51 |
+
for pattern in patterns:
|
| 52 |
+
for match in re.finditer(pattern, text, re.IGNORECASE):
|
| 53 |
+
if not negation_check(text, match):
|
| 54 |
+
return match.group(0).strip()
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _is_pediatric(intake: Mapping[str, Any], text: str) -> bool:
|
| 59 |
age = str(intake.get("patient_age", "")).strip().lower()
|
| 60 |
month_match = re.search(r"\b(\d{1,3})\s*(?:mo|mos|month|months)\b", age)
|
|
|
|
| 116 |
return False
|
| 117 |
|
| 118 |
|
| 119 |
+
def _is_negated_chest_match(text: str, match: re.Match[str]) -> bool:
|
| 120 |
+
start = match.start()
|
| 121 |
+
clause_start = max(text.rfind(boundary, 0, start) for boundary in (".", ";", "!", "?", "\n"))
|
| 122 |
+
prefix = text[clause_start + 1 : start].lower()
|
| 123 |
+
|
| 124 |
+
direct_negation = re.search(
|
| 125 |
+
r"\b(?:no|not|without|denies|denied|negative for|reports no)\s+"
|
| 126 |
+
r"(?:any\s+|active\s+|current(?:ly)?\s+|reported\s+)?$",
|
| 127 |
+
prefix,
|
| 128 |
+
)
|
| 129 |
+
if direct_negation:
|
| 130 |
+
return True
|
| 131 |
+
|
| 132 |
+
denial_marker = None
|
| 133 |
+
for marker in re.finditer(r"\b(?:denies|denied|negative for)\b", prefix):
|
| 134 |
+
denial_marker = marker
|
| 135 |
+
if not denial_marker:
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
between = prefix[denial_marker.end() :]
|
| 139 |
+
if re.search(r"\b(?:but|however|except|except for|now|currently)\b", between):
|
| 140 |
+
return False
|
| 141 |
+
return True
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def _rule(rule_id: str, label: str, urgency: Urgency, evidence: str, card_id: str) -> RuleResult:
|
| 145 |
return RuleResult(rule_id=rule_id, label=label, urgency=urgency, evidence=evidence, card_id=card_id)
|
| 146 |
|
|
|
|
| 203 |
|
| 204 |
|
| 205 |
def _chest_pain(_: Mapping[str, Any], text: str) -> RuleResult | None:
|
| 206 |
+
evidence = _find_unnegated(
|
| 207 |
(
|
| 208 |
r"\bchest pain\b",
|
| 209 |
r"\bchest pressure\b",
|
|
|
|
| 211 |
r"\bchest pain with (?:shortness of breath|sweating|fainting|severe weakness)\b",
|
| 212 |
),
|
| 213 |
text,
|
| 214 |
+
_is_negated_chest_match,
|
| 215 |
)
|
| 216 |
if not evidence:
|
| 217 |
return None
|
figment/validators.py
CHANGED
|
@@ -155,7 +155,13 @@ def validate_navigator_output(
|
|
| 155 |
retrieved_cards_by_id = _retrieved_cards_by_id(retrieved_cards or [])
|
| 156 |
if retrieved_card_ids is None and retrieved_cards_by_id:
|
| 157 |
retrieved_card_ids = set(retrieved_cards_by_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
allowed_cards = set(retrieved_card_ids) if retrieved_card_ids is not None else known_cards
|
|
|
|
| 159 |
source_cards = output.get("source_cards")
|
| 160 |
if not isinstance(source_cards, list) or not source_cards:
|
| 161 |
result.add("source_cards must be a non-empty list")
|
|
@@ -265,9 +271,16 @@ def _validate_missing_observations_against_cards(
|
|
| 265 |
required_observations = card.get("required_observations", [])
|
| 266 |
if not isinstance(required_observations, list) or not required_observations:
|
| 267 |
continue
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
def _is_negated_safety_phrase(text: str, match_start: int) -> bool:
|
|
|
|
| 155 |
retrieved_cards_by_id = _retrieved_cards_by_id(retrieved_cards or [])
|
| 156 |
if retrieved_card_ids is None and retrieved_cards_by_id:
|
| 157 |
retrieved_card_ids = set(retrieved_cards_by_id)
|
| 158 |
+
fired_rule_card_ids = {
|
| 159 |
+
str(rule.get("card_id", "")).strip()
|
| 160 |
+
for rule in rule_results or []
|
| 161 |
+
if str(rule.get("card_id", "")).strip()
|
| 162 |
+
}
|
| 163 |
allowed_cards = set(retrieved_card_ids) if retrieved_card_ids is not None else known_cards
|
| 164 |
+
allowed_cards.update(fired_rule_card_ids & known_cards)
|
| 165 |
source_cards = output.get("source_cards")
|
| 166 |
if not isinstance(source_cards, list) or not source_cards:
|
| 167 |
result.add("source_cards must be a non-empty list")
|
|
|
|
| 271 |
required_observations = card.get("required_observations", [])
|
| 272 |
if not isinstance(required_observations, list) or not required_observations:
|
| 273 |
continue
|
| 274 |
+
missing_required_observations = []
|
| 275 |
+
for observation in required_observations:
|
| 276 |
+
required_tokens = _grounding_tokens(observation)
|
| 277 |
+
if required_tokens and not required_tokens <= observation_tokens:
|
| 278 |
+
missing_required_observations.append(str(observation))
|
| 279 |
+
if missing_required_observations:
|
| 280 |
+
result.add(
|
| 281 |
+
"missing_info_to_collect does not reference required observations for "
|
| 282 |
+
f"{card_id}: {', '.join(missing_required_observations[:8])}"
|
| 283 |
+
)
|
| 284 |
|
| 285 |
|
| 286 |
def _is_negated_safety_phrase(text: str, match_start: int) -> bool:
|
scripts/run_eval.py
CHANGED
|
@@ -5,31 +5,43 @@ from __future__ import annotations
|
|
| 5 |
import argparse
|
| 6 |
from dataclasses import replace
|
| 7 |
import json
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
import sys
|
| 10 |
from time import perf_counter
|
| 11 |
from typing import Any
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
if str(PROJECT_ROOT) not in sys.path:
|
| 15 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 16 |
|
| 17 |
-
from figment.config import FigmentConfig
|
| 18 |
-
from figment.eval_metrics import score_expected_labels, summarize_eval_records
|
| 19 |
-
from figment.field_provenance import (
|
|
|
|
|
|
|
| 20 |
accepted_raw_fields_from_failures,
|
| 21 |
deterministic_field_provenance,
|
| 22 |
has_deterministic_patches,
|
| 23 |
merge_field_provenance,
|
| 24 |
model_raw_field_provenance,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
)
|
| 26 |
-
from figment.
|
| 27 |
-
from figment.
|
| 28 |
-
from figment.
|
| 29 |
-
from figment.
|
| 30 |
-
from figment.
|
| 31 |
-
from figment.trace import stable_hash
|
| 32 |
-
from figment.validators import urgency_floor_from_rules, validate_navigator_output
|
| 33 |
|
| 34 |
|
| 35 |
DEFAULT_CASE_GLOB = "data/eval/*.jsonl"
|
|
@@ -78,7 +90,10 @@ def run_eval(
|
|
| 78 |
else:
|
| 79 |
for record in records:
|
| 80 |
sys.stdout.write(f"{json.dumps(record, sort_keys=True)}\n")
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any]:
|
|
@@ -102,9 +117,22 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 102 |
repair_attempted = False
|
| 103 |
fallback_used = False
|
| 104 |
fallback_reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
final_output: dict[str, Any]
|
| 106 |
final_validation: dict[str, Any]
|
| 107 |
field_provenance: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
context = {
|
| 110 |
"intake": intake,
|
|
@@ -116,7 +144,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 116 |
if config.model_backend == "canned":
|
| 117 |
fallback_reason = "canned_backend"
|
| 118 |
fallback_used = True
|
| 119 |
-
fallback_output, fallback_validation = _run_fallback(
|
| 120 |
intake,
|
| 121 |
rule_results,
|
| 122 |
retrieved,
|
|
@@ -124,6 +152,14 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 124 |
known_cards,
|
| 125 |
retrieved_ids,
|
| 126 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
final_output = fallback_output
|
| 128 |
final_validation = fallback_validation
|
| 129 |
field_provenance = deterministic_field_provenance()
|
|
@@ -131,18 +167,74 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 131 |
client = ModelClient(config)
|
| 132 |
try:
|
| 133 |
raw_output = client.generate_json(prompt, context)
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
except ModelClientError as exc:
|
| 136 |
raw_validation = {"passed": False, "failures": [f"model backend error: {exc}"]}
|
| 137 |
fallback_reason = "model_backend_error"
|
| 138 |
|
| 139 |
-
if
|
| 140 |
-
final_output =
|
| 141 |
final_validation = raw_validation
|
| 142 |
field_provenance = model_raw_field_provenance()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
else:
|
| 144 |
-
if
|
| 145 |
-
fallback_output, fallback_validation = _run_fallback(
|
| 146 |
intake,
|
| 147 |
rule_results,
|
| 148 |
retrieved,
|
|
@@ -161,7 +253,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 161 |
client=client,
|
| 162 |
prompt=prompt,
|
| 163 |
context=context,
|
| 164 |
-
raw_output=
|
| 165 |
validation_failures=raw_validation["failures"],
|
| 166 |
fallback_output=fallback_output,
|
| 167 |
known_cards=known_cards,
|
|
@@ -170,20 +262,41 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 170 |
rule_results=rule_results,
|
| 171 |
retrieved=retrieved,
|
| 172 |
retrieved_ids=retrieved_ids,
|
|
|
|
| 173 |
)
|
| 174 |
if merged_output is not None and merged_validation is not None:
|
| 175 |
final_output = merged_output
|
| 176 |
final_validation = merged_validation
|
| 177 |
field_provenance = merged_field_provenance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
else:
|
| 179 |
fallback_reason = fallback_reason or "navigator_validation_failure"
|
| 180 |
fallback_used = True
|
| 181 |
final_output = fallback_output
|
| 182 |
final_validation = fallback_validation
|
| 183 |
field_provenance = deterministic_field_provenance()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
else:
|
| 185 |
fallback_used = True
|
| 186 |
-
fallback_output, fallback_validation = _run_fallback(
|
| 187 |
intake,
|
| 188 |
rule_results,
|
| 189 |
retrieved,
|
|
@@ -191,17 +304,93 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 191 |
known_cards,
|
| 192 |
retrieved_ids,
|
| 193 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
final_output = fallback_output
|
| 195 |
final_validation = fallback_validation
|
| 196 |
field_provenance = deterministic_field_provenance()
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
field_level_fallback_used = has_deterministic_patches(field_provenance)
|
| 199 |
|
| 200 |
-
raw_success = raw_attempted and raw_validation["passed"]
|
| 201 |
repair_success = repair_attempted and repair_validation["passed"]
|
| 202 |
fallback_success = fallback_used and fallback_validation["passed"]
|
| 203 |
fallback_tier = "canned" if fallback_used else "configured"
|
| 204 |
-
competence_success = bool(raw_success or repair_success)
|
| 205 |
model_route = {
|
| 206 |
"model_stack": config.model_stack,
|
| 207 |
"model_backend": config.model_backend,
|
|
@@ -209,7 +398,24 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 209 |
"fallback_tier": fallback_tier,
|
| 210 |
"fallback_reason": fallback_reason,
|
| 211 |
"field_level_fallback_used": field_level_fallback_used,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
trace_payload = {
|
| 214 |
"case_id": case["case_id"],
|
| 215 |
"input_hash": stable_hash(intake),
|
|
@@ -217,6 +423,7 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 217 |
"retrieved_card_ids": retrieved_ids,
|
| 218 |
"prompt_template_hash": prompt_hash,
|
| 219 |
"model_route": model_route,
|
|
|
|
| 220 |
"navigator_output": final_output,
|
| 221 |
"validator_result": final_validation,
|
| 222 |
"field_provenance": field_provenance,
|
|
@@ -236,7 +443,11 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 236 |
"expected_min_protocol_urgency": case.get("expected_min_protocol_urgency"),
|
| 237 |
"expected_red_flag_rule_ids": case.get("expected_red_flag_rule_ids", []),
|
| 238 |
"expected_source_card_ids": case.get("expected_source_card_ids", []),
|
|
|
|
| 239 |
"expected_missing_observations": case.get("expected_missing_observations", []),
|
|
|
|
|
|
|
|
|
|
| 240 |
"forbidden_behavior": case.get("forbidden_behavior", []),
|
| 241 |
"actual_red_flag_rule_ids": [rule["rule_id"] for rule in rule_results],
|
| 242 |
"actual_protocol_urgency": final_output.get("protocol_urgency"),
|
|
@@ -249,20 +460,36 @@ def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any
|
|
| 249 |
"fallback_tier": fallback_tier,
|
| 250 |
"fallback_reason": fallback_reason,
|
| 251 |
"field_level_fallback_used": field_level_fallback_used,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
"raw_configured_model_attempted": raw_attempted,
|
| 253 |
"raw_configured_model_success": raw_success,
|
| 254 |
"repair_attempted": repair_attempted,
|
| 255 |
"repair_success": repair_success,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
"canned_fallback_used": fallback_used,
|
| 257 |
"canned_fallback_success": fallback_success,
|
| 258 |
"competence_success": competence_success,
|
| 259 |
"raw_validation": raw_validation,
|
| 260 |
"repair_validation": repair_validation,
|
|
|
|
| 261 |
"fallback_validation": fallback_validation,
|
| 262 |
"validation_result": final_validation,
|
| 263 |
"final_validation": final_validation,
|
|
|
|
| 264 |
"raw_model_output": raw_output,
|
|
|
|
| 265 |
"repaired_output": repaired_output,
|
|
|
|
| 266 |
"fallback_output": fallback_output,
|
| 267 |
"final_output": final_output,
|
| 268 |
"field_provenance": field_provenance,
|
|
@@ -280,10 +507,56 @@ def _run_fallback(
|
|
| 280 |
floor: str,
|
| 281 |
known_cards: set[str],
|
| 282 |
retrieved_ids: list[str],
|
| 283 |
-
) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 284 |
output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
validation = _validate_output(output, known_cards, floor, intake, rule_results, retrieved, retrieved_ids)
|
| 286 |
-
return output, validation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
def _validate_output(
|
|
@@ -321,6 +594,7 @@ def _try_field_level_model_output(
|
|
| 321 |
rule_results: list[dict[str, Any]],
|
| 322 |
retrieved: list[dict[str, Any]],
|
| 323 |
retrieved_ids: list[str],
|
|
|
|
| 324 |
) -> tuple[dict[str, Any] | None, dict[str, Any], bool, dict[str, Any] | None, dict[str, Any] | None, dict[str, str]]:
|
| 325 |
accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
|
| 326 |
repaired_fields: dict[str, Any] = {}
|
|
@@ -331,6 +605,7 @@ def _try_field_level_model_output(
|
|
| 331 |
previous_output=raw_output,
|
| 332 |
failures=validation_failures,
|
| 333 |
urgency_floor=floor,
|
|
|
|
| 334 |
):
|
| 335 |
repair_attempted = True
|
| 336 |
try:
|
|
@@ -349,9 +624,24 @@ def _try_field_level_model_output(
|
|
| 349 |
if not isinstance(repair_output, dict):
|
| 350 |
repair_validation = {"passed": False, "failures": ["repair output was not an object"]}
|
| 351 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
for field in focused_prompt.scope.fields:
|
| 353 |
if field in repair_output:
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
merge_candidates = []
|
| 357 |
if repaired_fields:
|
|
@@ -376,6 +666,7 @@ def _try_field_level_model_output(
|
|
| 376 |
if merged_validation["passed"]:
|
| 377 |
if merge_result.provenance == deterministic_field_provenance():
|
| 378 |
continue
|
|
|
|
| 379 |
if candidate_repaired_fields:
|
| 380 |
repair_validation = merged_validation
|
| 381 |
return (
|
|
@@ -391,6 +682,41 @@ def _try_field_level_model_output(
|
|
| 391 |
return None, repair_validation, repair_attempted, None, None, {}
|
| 392 |
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
def _repair_prompt(
|
| 395 |
original_prompt: str,
|
| 396 |
previous_output: dict[str, Any],
|
|
@@ -442,6 +768,9 @@ def _summarize(
|
|
| 442 |
"output_path": str(output_path) if output_path else None,
|
| 443 |
}
|
| 444 |
)
|
|
|
|
|
|
|
|
|
|
| 445 |
if config.model_backend == "llama_cpp":
|
| 446 |
summary["local_llm_evidence"] = _local_llm_evidence_summary(summary, config)
|
| 447 |
return summary
|
|
@@ -456,6 +785,15 @@ def _local_llm_evidence_summary(summary: dict[str, Any], config: FigmentConfig)
|
|
| 456 |
"model_stack": config.model_stack,
|
| 457 |
"model_id": config.active_model_id,
|
| 458 |
"llama_base_url": config.llama_base_url,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
"total_cases": total_cases,
|
| 460 |
"competence_successes": competence_successes,
|
| 461 |
"raw_configured_model_successes": summary.get("raw_configured_model_successes", 0),
|
|
@@ -472,6 +810,109 @@ def _local_llm_evidence_summary(summary: dict[str, Any], config: FigmentConfig)
|
|
| 472 |
}
|
| 473 |
|
| 474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
def main(argv: list[str] | None = None) -> int:
|
| 476 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 477 |
parser.add_argument("--backend", choices=["canned", "hosted_omni", "llama_cpp"], default="canned")
|
|
|
|
| 5 |
import argparse
|
| 6 |
from dataclasses import replace
|
| 7 |
import json
|
| 8 |
+
import os
|
| 9 |
from pathlib import Path
|
| 10 |
import sys
|
| 11 |
from time import perf_counter
|
| 12 |
from typing import Any
|
| 13 |
+
import urllib.error
|
| 14 |
+
import urllib.parse
|
| 15 |
+
import urllib.request
|
| 16 |
|
| 17 |
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
if str(PROJECT_ROOT) not in sys.path:
|
| 19 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 20 |
|
| 21 |
+
from figment.config import FigmentConfig # noqa: E402
|
| 22 |
+
from figment.eval_metrics import score_expected_labels, score_handoff_readiness, summarize_eval_records # noqa: E402
|
| 23 |
+
from figment.field_provenance import ( # noqa: E402
|
| 24 |
+
DETERMINISTIC_FALLBACK,
|
| 25 |
+
MODEL_REPAIRED,
|
| 26 |
accepted_raw_fields_from_failures,
|
| 27 |
deterministic_field_provenance,
|
| 28 |
has_deterministic_patches,
|
| 29 |
merge_field_provenance,
|
| 30 |
model_raw_field_provenance,
|
| 31 |
+
) # noqa: E402
|
| 32 |
+
from figment.focused_repair import build_focused_repair_prompts, missing_mandatory_source_cards # noqa: E402
|
| 33 |
+
from figment.harness_evidence import build_harness_evidence # noqa: E402
|
| 34 |
+
from figment.model_client import ModelClient, ModelClientError, canned_navigator_output # noqa: E402
|
| 35 |
+
from figment.observation_targets import ( # noqa: E402
|
| 36 |
+
NavigationScaffoldResult,
|
| 37 |
+
apply_navigation_scaffolding,
|
| 38 |
+
required_observation_targets,
|
| 39 |
)
|
| 40 |
+
from figment.prompt_builder import build_prompt # noqa: E402
|
| 41 |
+
from figment.retrieval import known_card_ids, query_from_intake, search_protocol_cards # noqa: E402
|
| 42 |
+
from figment.rules import run_red_flag_checks # noqa: E402
|
| 43 |
+
from figment.trace import derive_model_route, stable_hash # noqa: E402
|
| 44 |
+
from figment.validators import urgency_floor_from_rules, validate_navigator_output # noqa: E402
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
DEFAULT_CASE_GLOB = "data/eval/*.jsonl"
|
|
|
|
| 90 |
else:
|
| 91 |
for record in records:
|
| 92 |
sys.stdout.write(f"{json.dumps(record, sort_keys=True)}\n")
|
| 93 |
+
summary = _summarize(records, config, case_paths, output_path)
|
| 94 |
+
if output_path is not None:
|
| 95 |
+
_write_eval_bundle_metadata(summary, records, config, case_paths, output_path)
|
| 96 |
+
return summary
|
| 97 |
|
| 98 |
|
| 99 |
def _evaluate_case(case: dict[str, Any], config: FigmentConfig) -> dict[str, Any]:
|
|
|
|
| 117 |
repair_attempted = False
|
| 118 |
fallback_used = False
|
| 119 |
fallback_reason: str | None = None
|
| 120 |
+
competence_repair_attempted = False
|
| 121 |
+
competence_repair_success = False
|
| 122 |
+
competence_repair_scope: str | None = None
|
| 123 |
+
competence_repaired_output: dict[str, Any] | None = None
|
| 124 |
+
competence_repair_validation = {"passed": False, "failures": ["competence repair not attempted"]}
|
| 125 |
+
scaffolded_model_output: dict[str, Any] | None = None
|
| 126 |
+
handoff_readiness_before: dict[str, Any] | None = None
|
| 127 |
+
handoff_readiness_after: dict[str, Any] | None = None
|
| 128 |
final_output: dict[str, Any]
|
| 129 |
final_validation: dict[str, Any]
|
| 130 |
field_provenance: dict[str, str] = {}
|
| 131 |
+
scaffold_patched_fields: set[str] = set()
|
| 132 |
+
filled_required_observation_ids: list[str] = []
|
| 133 |
+
model_selected_required_observation_ids: list[str] = []
|
| 134 |
+
invalid_selected_required_observation_ids: list[str] = []
|
| 135 |
+
stripped_trace_only_fields: list[str] = []
|
| 136 |
|
| 137 |
context = {
|
| 138 |
"intake": intake,
|
|
|
|
| 144 |
if config.model_backend == "canned":
|
| 145 |
fallback_reason = "canned_backend"
|
| 146 |
fallback_used = True
|
| 147 |
+
fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
|
| 148 |
intake,
|
| 149 |
rule_results,
|
| 150 |
retrieved,
|
|
|
|
| 152 |
known_cards,
|
| 153 |
retrieved_ids,
|
| 154 |
)
|
| 155 |
+
_absorb_scaffold_trace(
|
| 156 |
+
fallback_scaffold,
|
| 157 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 158 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 159 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 160 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 161 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 162 |
+
)
|
| 163 |
final_output = fallback_output
|
| 164 |
final_validation = fallback_validation
|
| 165 |
field_provenance = deterministic_field_provenance()
|
|
|
|
| 167 |
client = ModelClient(config)
|
| 168 |
try:
|
| 169 |
raw_output = client.generate_json(prompt, context)
|
| 170 |
+
scaffold_result = apply_navigation_scaffolding(
|
| 171 |
+
raw_output,
|
| 172 |
+
retrieved_cards=retrieved,
|
| 173 |
+
rule_results=rule_results,
|
| 174 |
+
urgency_floor=floor,
|
| 175 |
+
confirmed_intake=intake,
|
| 176 |
+
)
|
| 177 |
+
scaffolded_model_output = scaffold_result.output
|
| 178 |
+
_absorb_scaffold_trace(
|
| 179 |
+
scaffold_result,
|
| 180 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 181 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 182 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 183 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 184 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 185 |
+
)
|
| 186 |
+
raw_validation = _validate_output(
|
| 187 |
+
scaffolded_model_output,
|
| 188 |
+
known_cards,
|
| 189 |
+
floor,
|
| 190 |
+
intake,
|
| 191 |
+
rule_results,
|
| 192 |
+
retrieved,
|
| 193 |
+
retrieved_ids,
|
| 194 |
+
)
|
| 195 |
except ModelClientError as exc:
|
| 196 |
raw_validation = {"passed": False, "failures": [f"model backend error: {exc}"]}
|
| 197 |
fallback_reason = "model_backend_error"
|
| 198 |
|
| 199 |
+
if scaffolded_model_output is not None and raw_validation["passed"]:
|
| 200 |
+
final_output = scaffolded_model_output
|
| 201 |
final_validation = raw_validation
|
| 202 |
field_provenance = model_raw_field_provenance()
|
| 203 |
+
_mark_deterministic_patch_fields(field_provenance, scaffold_patched_fields)
|
| 204 |
+
patch_repair_failures = _observation_patch_repair_failures(
|
| 205 |
+
filled_required_observation_ids,
|
| 206 |
+
scaffold_patched_fields,
|
| 207 |
+
)
|
| 208 |
+
if patch_repair_failures and raw_output is not None:
|
| 209 |
+
(
|
| 210 |
+
repaired_output,
|
| 211 |
+
repair_validation,
|
| 212 |
+
repair_attempted,
|
| 213 |
+
merged_output,
|
| 214 |
+
merged_validation,
|
| 215 |
+
merged_field_provenance,
|
| 216 |
+
) = _try_field_level_model_output(
|
| 217 |
+
client=client,
|
| 218 |
+
prompt=prompt,
|
| 219 |
+
context=context,
|
| 220 |
+
raw_output=raw_output,
|
| 221 |
+
validation_failures=patch_repair_failures,
|
| 222 |
+
fallback_output=scaffolded_model_output,
|
| 223 |
+
known_cards=known_cards,
|
| 224 |
+
floor=floor,
|
| 225 |
+
intake=intake,
|
| 226 |
+
rule_results=rule_results,
|
| 227 |
+
retrieved=retrieved,
|
| 228 |
+
retrieved_ids=retrieved_ids,
|
| 229 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 230 |
+
)
|
| 231 |
+
if merged_output is not None and merged_validation is not None:
|
| 232 |
+
final_output = merged_output
|
| 233 |
+
final_validation = merged_validation
|
| 234 |
+
field_provenance = merged_field_provenance
|
| 235 |
else:
|
| 236 |
+
if scaffolded_model_output is not None:
|
| 237 |
+
fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
|
| 238 |
intake,
|
| 239 |
rule_results,
|
| 240 |
retrieved,
|
|
|
|
| 253 |
client=client,
|
| 254 |
prompt=prompt,
|
| 255 |
context=context,
|
| 256 |
+
raw_output=scaffolded_model_output,
|
| 257 |
validation_failures=raw_validation["failures"],
|
| 258 |
fallback_output=fallback_output,
|
| 259 |
known_cards=known_cards,
|
|
|
|
| 262 |
rule_results=rule_results,
|
| 263 |
retrieved=retrieved,
|
| 264 |
retrieved_ids=retrieved_ids,
|
| 265 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 266 |
)
|
| 267 |
if merged_output is not None and merged_validation is not None:
|
| 268 |
final_output = merged_output
|
| 269 |
final_validation = merged_validation
|
| 270 |
field_provenance = merged_field_provenance
|
| 271 |
+
if (
|
| 272 |
+
field_provenance.get("missing_info_to_collect") == DETERMINISTIC_FALLBACK
|
| 273 |
+
or field_provenance.get("next_observations_to_collect") == DETERMINISTIC_FALLBACK
|
| 274 |
+
):
|
| 275 |
+
_absorb_scaffold_trace(
|
| 276 |
+
fallback_scaffold,
|
| 277 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 278 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 279 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 280 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 281 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 282 |
+
)
|
| 283 |
else:
|
| 284 |
fallback_reason = fallback_reason or "navigator_validation_failure"
|
| 285 |
fallback_used = True
|
| 286 |
final_output = fallback_output
|
| 287 |
final_validation = fallback_validation
|
| 288 |
field_provenance = deterministic_field_provenance()
|
| 289 |
+
_absorb_scaffold_trace(
|
| 290 |
+
fallback_scaffold,
|
| 291 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 292 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 293 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 294 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 295 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 296 |
+
)
|
| 297 |
else:
|
| 298 |
fallback_used = True
|
| 299 |
+
fallback_output, fallback_validation, fallback_scaffold = _run_fallback(
|
| 300 |
intake,
|
| 301 |
rule_results,
|
| 302 |
retrieved,
|
|
|
|
| 304 |
known_cards,
|
| 305 |
retrieved_ids,
|
| 306 |
)
|
| 307 |
+
_absorb_scaffold_trace(
|
| 308 |
+
fallback_scaffold,
|
| 309 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 310 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 311 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 312 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 313 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 314 |
+
)
|
| 315 |
final_output = fallback_output
|
| 316 |
final_validation = fallback_validation
|
| 317 |
field_provenance = deterministic_field_provenance()
|
| 318 |
|
| 319 |
+
if final_validation["passed"] and config.model_backend != "canned":
|
| 320 |
+
handoff_readiness_before = score_handoff_readiness(
|
| 321 |
+
final_output,
|
| 322 |
+
actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
|
| 323 |
+
source_card_ids=final_output.get("source_cards", []),
|
| 324 |
+
validation_result=final_validation,
|
| 325 |
+
)
|
| 326 |
+
if handoff_readiness_before.get("handoff_readiness_passed") is not True:
|
| 327 |
+
competence_fallback_output, _competence_fallback_validation, competence_fallback_scaffold = _run_fallback(
|
| 328 |
+
intake,
|
| 329 |
+
rule_results,
|
| 330 |
+
retrieved,
|
| 331 |
+
floor,
|
| 332 |
+
known_cards,
|
| 333 |
+
retrieved_ids,
|
| 334 |
+
)
|
| 335 |
+
(
|
| 336 |
+
competence_repaired_output,
|
| 337 |
+
competence_repair_validation,
|
| 338 |
+
competence_repair_attempted,
|
| 339 |
+
competence_merged_output,
|
| 340 |
+
competence_merged_validation,
|
| 341 |
+
competence_merged_field_provenance,
|
| 342 |
+
) = _try_field_level_model_output(
|
| 343 |
+
client=client,
|
| 344 |
+
prompt=prompt,
|
| 345 |
+
context={
|
| 346 |
+
**context,
|
| 347 |
+
"handoff_readiness_metrics": handoff_readiness_before,
|
| 348 |
+
},
|
| 349 |
+
raw_output=final_output,
|
| 350 |
+
validation_failures=_handoff_competence_failures(handoff_readiness_before),
|
| 351 |
+
fallback_output=competence_fallback_output,
|
| 352 |
+
known_cards=known_cards,
|
| 353 |
+
floor=floor,
|
| 354 |
+
intake=intake,
|
| 355 |
+
rule_results=rule_results,
|
| 356 |
+
retrieved=retrieved,
|
| 357 |
+
retrieved_ids=retrieved_ids,
|
| 358 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 359 |
+
)
|
| 360 |
+
competence_repair_scope = "handoff_note_sbar" if competence_repair_attempted else None
|
| 361 |
+
if competence_merged_output is not None and competence_merged_validation is not None:
|
| 362 |
+
after = score_handoff_readiness(
|
| 363 |
+
competence_merged_output,
|
| 364 |
+
actual_red_flag_rule_ids=[str(rule.get("rule_id")) for rule in rule_results if rule.get("rule_id")],
|
| 365 |
+
source_card_ids=competence_merged_output.get("source_cards", []),
|
| 366 |
+
validation_result=competence_merged_validation,
|
| 367 |
+
)
|
| 368 |
+
handoff_readiness_after = after
|
| 369 |
+
if after.get("handoff_readiness_passed") is True:
|
| 370 |
+
final_output = competence_merged_output
|
| 371 |
+
final_validation = competence_merged_validation
|
| 372 |
+
field_provenance = competence_merged_field_provenance
|
| 373 |
+
competence_repair_success = True
|
| 374 |
+
if (
|
| 375 |
+
field_provenance.get("missing_info_to_collect") == DETERMINISTIC_FALLBACK
|
| 376 |
+
or field_provenance.get("next_observations_to_collect") == DETERMINISTIC_FALLBACK
|
| 377 |
+
):
|
| 378 |
+
_absorb_scaffold_trace(
|
| 379 |
+
competence_fallback_scaffold,
|
| 380 |
+
scaffold_patched_fields=scaffold_patched_fields,
|
| 381 |
+
filled_required_observation_ids=filled_required_observation_ids,
|
| 382 |
+
model_selected_required_observation_ids=model_selected_required_observation_ids,
|
| 383 |
+
invalid_selected_required_observation_ids=invalid_selected_required_observation_ids,
|
| 384 |
+
stripped_trace_only_fields=stripped_trace_only_fields,
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
field_level_fallback_used = has_deterministic_patches(field_provenance)
|
| 388 |
|
| 389 |
+
raw_success = raw_attempted and raw_validation["passed"] and not scaffold_patched_fields
|
| 390 |
repair_success = repair_attempted and repair_validation["passed"]
|
| 391 |
fallback_success = fallback_used and fallback_validation["passed"]
|
| 392 |
fallback_tier = "canned" if fallback_used else "configured"
|
| 393 |
+
competence_success = bool(raw_success or repair_success or competence_repair_success)
|
| 394 |
model_route = {
|
| 395 |
"model_stack": config.model_stack,
|
| 396 |
"model_backend": config.model_backend,
|
|
|
|
| 398 |
"fallback_tier": fallback_tier,
|
| 399 |
"fallback_reason": fallback_reason,
|
| 400 |
"field_level_fallback_used": field_level_fallback_used,
|
| 401 |
+
"deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
|
| 402 |
+
"filled_required_observation_ids": filled_required_observation_ids,
|
| 403 |
+
"model_selected_required_observation_ids": model_selected_required_observation_ids,
|
| 404 |
+
"invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
|
| 405 |
+
"stripped_trace_only_fields": stripped_trace_only_fields,
|
| 406 |
}
|
| 407 |
+
model_route = derive_model_route(model_route, final_validation, [], field_provenance=field_provenance)
|
| 408 |
+
harness_evidence = build_harness_evidence(
|
| 409 |
+
confirmed_intake=intake,
|
| 410 |
+
retrieved_card_ids=retrieved_ids,
|
| 411 |
+
rule_results=rule_results,
|
| 412 |
+
urgency_floor=floor,
|
| 413 |
+
validator_result=final_validation,
|
| 414 |
+
final_output=final_output,
|
| 415 |
+
model_route=model_route,
|
| 416 |
+
)
|
| 417 |
+
final_output = dict(final_output)
|
| 418 |
+
final_output["harness_evidence"] = harness_evidence
|
| 419 |
trace_payload = {
|
| 420 |
"case_id": case["case_id"],
|
| 421 |
"input_hash": stable_hash(intake),
|
|
|
|
| 423 |
"retrieved_card_ids": retrieved_ids,
|
| 424 |
"prompt_template_hash": prompt_hash,
|
| 425 |
"model_route": model_route,
|
| 426 |
+
"harness_evidence": harness_evidence,
|
| 427 |
"navigator_output": final_output,
|
| 428 |
"validator_result": final_validation,
|
| 429 |
"field_provenance": field_provenance,
|
|
|
|
| 443 |
"expected_min_protocol_urgency": case.get("expected_min_protocol_urgency"),
|
| 444 |
"expected_red_flag_rule_ids": case.get("expected_red_flag_rule_ids", []),
|
| 445 |
"expected_source_card_ids": case.get("expected_source_card_ids", []),
|
| 446 |
+
"expected_candidate_pathway_card_ids": case.get("expected_candidate_pathway_card_ids", []),
|
| 447 |
"expected_missing_observations": case.get("expected_missing_observations", []),
|
| 448 |
+
"expected_model_observation_cues": case.get("expected_model_observation_cues", []),
|
| 449 |
+
"expected_handoff_cues": case.get("expected_handoff_cues", []),
|
| 450 |
+
"expected_harness_evidence_cues": case.get("expected_harness_evidence_cues", []),
|
| 451 |
"forbidden_behavior": case.get("forbidden_behavior", []),
|
| 452 |
"actual_red_flag_rule_ids": [rule["rule_id"] for rule in rule_results],
|
| 453 |
"actual_protocol_urgency": final_output.get("protocol_urgency"),
|
|
|
|
| 460 |
"fallback_tier": fallback_tier,
|
| 461 |
"fallback_reason": fallback_reason,
|
| 462 |
"field_level_fallback_used": field_level_fallback_used,
|
| 463 |
+
"deterministic_scaffold_patched_fields": sorted(scaffold_patched_fields),
|
| 464 |
+
"filled_required_observation_ids": filled_required_observation_ids,
|
| 465 |
+
"model_selected_required_observation_ids": model_selected_required_observation_ids,
|
| 466 |
+
"invalid_selected_required_observation_ids": invalid_selected_required_observation_ids,
|
| 467 |
+
"stripped_trace_only_fields": stripped_trace_only_fields,
|
| 468 |
"raw_configured_model_attempted": raw_attempted,
|
| 469 |
"raw_configured_model_success": raw_success,
|
| 470 |
"repair_attempted": repair_attempted,
|
| 471 |
"repair_success": repair_success,
|
| 472 |
+
"validation_repair_attempted": repair_attempted,
|
| 473 |
+
"validation_repair_success": repair_success,
|
| 474 |
+
"competence_repair_attempted": competence_repair_attempted,
|
| 475 |
+
"competence_repair_success": competence_repair_success,
|
| 476 |
+
"competence_repair_scope": competence_repair_scope,
|
| 477 |
+
"handoff_readiness_before_competence_repair": handoff_readiness_before,
|
| 478 |
+
"handoff_readiness_after_competence_repair": handoff_readiness_after,
|
| 479 |
"canned_fallback_used": fallback_used,
|
| 480 |
"canned_fallback_success": fallback_success,
|
| 481 |
"competence_success": competence_success,
|
| 482 |
"raw_validation": raw_validation,
|
| 483 |
"repair_validation": repair_validation,
|
| 484 |
+
"competence_repair_validation": competence_repair_validation,
|
| 485 |
"fallback_validation": fallback_validation,
|
| 486 |
"validation_result": final_validation,
|
| 487 |
"final_validation": final_validation,
|
| 488 |
+
"harness_evidence": harness_evidence,
|
| 489 |
"raw_model_output": raw_output,
|
| 490 |
+
"scaffolded_model_output": scaffolded_model_output,
|
| 491 |
"repaired_output": repaired_output,
|
| 492 |
+
"competence_repaired_output": competence_repaired_output,
|
| 493 |
"fallback_output": fallback_output,
|
| 494 |
"final_output": final_output,
|
| 495 |
"field_provenance": field_provenance,
|
|
|
|
| 507 |
floor: str,
|
| 508 |
known_cards: set[str],
|
| 509 |
retrieved_ids: list[str],
|
| 510 |
+
) -> tuple[dict[str, Any], dict[str, Any], NavigationScaffoldResult]:
|
| 511 |
output = canned_navigator_output(intake, rule_results, retrieved, floor)
|
| 512 |
+
scaffold = apply_navigation_scaffolding(
|
| 513 |
+
output,
|
| 514 |
+
retrieved_cards=retrieved,
|
| 515 |
+
rule_results=rule_results,
|
| 516 |
+
urgency_floor=floor,
|
| 517 |
+
confirmed_intake=intake,
|
| 518 |
+
)
|
| 519 |
+
output = scaffold.output
|
| 520 |
validation = _validate_output(output, known_cards, floor, intake, rule_results, retrieved, retrieved_ids)
|
| 521 |
+
return output, validation, scaffold
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _absorb_scaffold_trace(
|
| 525 |
+
result: NavigationScaffoldResult,
|
| 526 |
+
*,
|
| 527 |
+
scaffold_patched_fields: set[str],
|
| 528 |
+
filled_required_observation_ids: list[str],
|
| 529 |
+
model_selected_required_observation_ids: list[str],
|
| 530 |
+
invalid_selected_required_observation_ids: list[str],
|
| 531 |
+
stripped_trace_only_fields: list[str],
|
| 532 |
+
) -> None:
|
| 533 |
+
scaffold_patched_fields.update(result.patched_fields)
|
| 534 |
+
_extend_unique(filled_required_observation_ids, result.filled_required_observation_ids)
|
| 535 |
+
_extend_unique(model_selected_required_observation_ids, result.model_selected_required_observation_ids)
|
| 536 |
+
_extend_unique(invalid_selected_required_observation_ids, result.invalid_selected_required_observation_ids)
|
| 537 |
+
_extend_unique(stripped_trace_only_fields, result.stripped_trace_only_fields)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _extend_unique(items: list[str], values: list[str]) -> None:
|
| 541 |
+
for value in values:
|
| 542 |
+
if value not in items:
|
| 543 |
+
items.append(value)
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def _merge_observation_repair_values(previous_value: Any, repair_value: Any) -> list[str]:
|
| 547 |
+
merged: list[str] = []
|
| 548 |
+
for value in _coerce_text_list(previous_value) + _coerce_text_list(repair_value):
|
| 549 |
+
if value not in merged:
|
| 550 |
+
merged.append(value)
|
| 551 |
+
return merged
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def _coerce_text_list(value: Any) -> list[str]:
|
| 555 |
+
if isinstance(value, list):
|
| 556 |
+
return [str(item).strip() for item in value if str(item).strip()]
|
| 557 |
+
if isinstance(value, str) and value.strip():
|
| 558 |
+
return [value.strip()]
|
| 559 |
+
return []
|
| 560 |
|
| 561 |
|
| 562 |
def _validate_output(
|
|
|
|
| 594 |
rule_results: list[dict[str, Any]],
|
| 595 |
retrieved: list[dict[str, Any]],
|
| 596 |
retrieved_ids: list[str],
|
| 597 |
+
scaffold_patched_fields: set[str],
|
| 598 |
) -> tuple[dict[str, Any] | None, dict[str, Any], bool, dict[str, Any] | None, dict[str, Any] | None, dict[str, str]]:
|
| 599 |
accepted_raw_fields = accepted_raw_fields_from_failures(validation_failures)
|
| 600 |
repaired_fields: dict[str, Any] = {}
|
|
|
|
| 605 |
previous_output=raw_output,
|
| 606 |
failures=validation_failures,
|
| 607 |
urgency_floor=floor,
|
| 608 |
+
required_observation_targets=required_observation_targets(retrieved),
|
| 609 |
):
|
| 610 |
repair_attempted = True
|
| 611 |
try:
|
|
|
|
| 624 |
if not isinstance(repair_output, dict):
|
| 625 |
repair_validation = {"passed": False, "failures": ["repair output was not an object"]}
|
| 626 |
continue
|
| 627 |
+
missing_source_cards = missing_mandatory_source_cards(focused_prompt.scope, repair_output)
|
| 628 |
+
if missing_source_cards:
|
| 629 |
+
repair_validation = {
|
| 630 |
+
"passed": False,
|
| 631 |
+
"failures": [
|
| 632 |
+
f"repair omitted mandatory source card {card_id}" for card_id in missing_source_cards
|
| 633 |
+
],
|
| 634 |
+
}
|
| 635 |
+
continue
|
| 636 |
for field in focused_prompt.scope.fields:
|
| 637 |
if field in repair_output:
|
| 638 |
+
if focused_prompt.scope.name == "missing_observations":
|
| 639 |
+
repaired_fields[field] = _merge_observation_repair_values(
|
| 640 |
+
raw_output.get(field),
|
| 641 |
+
repair_output[field],
|
| 642 |
+
)
|
| 643 |
+
else:
|
| 644 |
+
repaired_fields[field] = repair_output[field]
|
| 645 |
|
| 646 |
merge_candidates = []
|
| 647 |
if repaired_fields:
|
|
|
|
| 666 |
if merged_validation["passed"]:
|
| 667 |
if merge_result.provenance == deterministic_field_provenance():
|
| 668 |
continue
|
| 669 |
+
_mark_deterministic_patch_fields(merge_result.provenance, scaffold_patched_fields)
|
| 670 |
if candidate_repaired_fields:
|
| 671 |
repair_validation = merged_validation
|
| 672 |
return (
|
|
|
|
| 682 |
return None, repair_validation, repair_attempted, None, None, {}
|
| 683 |
|
| 684 |
|
| 685 |
+
def _mark_deterministic_patch_fields(provenance: dict[str, str], fields: set[str]) -> None:
|
| 686 |
+
for field in fields:
|
| 687 |
+
if field in provenance and provenance[field] != MODEL_REPAIRED:
|
| 688 |
+
provenance[field] = DETERMINISTIC_FALLBACK
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def _observation_patch_repair_failures(
|
| 692 |
+
filled_required_observation_ids: list[str],
|
| 693 |
+
scaffold_patched_fields: set[str],
|
| 694 |
+
) -> list[str]:
|
| 695 |
+
if not {"missing_info_to_collect", "next_observations_to_collect"} & scaffold_patched_fields:
|
| 696 |
+
return []
|
| 697 |
+
card_ids: list[str] = []
|
| 698 |
+
for target_id in filled_required_observation_ids:
|
| 699 |
+
card_id, separator, _index = str(target_id).partition("::required_observation::")
|
| 700 |
+
if separator and card_id and card_id not in card_ids:
|
| 701 |
+
card_ids.append(card_id)
|
| 702 |
+
return [
|
| 703 |
+
f"missing_info_to_collect does not reference required observations for {card_id}"
|
| 704 |
+
for card_id in card_ids
|
| 705 |
+
]
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def _handoff_competence_failures(metrics: dict[str, Any]) -> list[str]:
|
| 709 |
+
failures = ["handoff_note_sbar handoff_readiness_passed failed"]
|
| 710 |
+
for key, value in sorted(metrics.items()):
|
| 711 |
+
if key.startswith("sbar_") and value is False:
|
| 712 |
+
failures.append(f"handoff_note_sbar {key} failed")
|
| 713 |
+
elif key == "handoff_brevity_ok" and value is False:
|
| 714 |
+
failures.append("handoff_note_sbar handoff_brevity_ok failed")
|
| 715 |
+
elif key == "handoff_unsupported_fact_count" and value:
|
| 716 |
+
failures.append(f"handoff_note_sbar unsupported fact count: {value}")
|
| 717 |
+
return failures
|
| 718 |
+
|
| 719 |
+
|
| 720 |
def _repair_prompt(
|
| 721 |
original_prompt: str,
|
| 722 |
previous_output: dict[str, Any],
|
|
|
|
| 768 |
"output_path": str(output_path) if output_path else None,
|
| 769 |
}
|
| 770 |
)
|
| 771 |
+
runtime_errors = _runtime_error_summary(records)
|
| 772 |
+
summary["runtime_error_summary"] = runtime_errors
|
| 773 |
+
summary["scored_reporting_eligible"] = runtime_errors["critical_runtime_error_count"] == 0
|
| 774 |
if config.model_backend == "llama_cpp":
|
| 775 |
summary["local_llm_evidence"] = _local_llm_evidence_summary(summary, config)
|
| 776 |
return summary
|
|
|
|
| 785 |
"model_stack": config.model_stack,
|
| 786 |
"model_id": config.active_model_id,
|
| 787 |
"llama_base_url": config.llama_base_url,
|
| 788 |
+
"server_command": os.getenv("LLAMA_SERVER_COMMAND") or None,
|
| 789 |
+
"gguf_path": os.getenv("LOCAL_GGUF_PATH") or os.getenv("LLAMA_ARG_MODEL") or None,
|
| 790 |
+
"gguf_sha256": os.getenv("LOCAL_GGUF_SHA256") or None,
|
| 791 |
+
"n_ctx": _optional_int_env("LLAMA_N_CTX") or _optional_int_env("LLAMA_ARG_CTX_SIZE"),
|
| 792 |
+
"n_parallel": _optional_int_env("LLAMA_N_PARALLEL") or _optional_int_env("LLAMA_ARG_N_PARALLEL"),
|
| 793 |
+
"prompt_cache": os.getenv("LLAMA_PROMPT_CACHE") or None,
|
| 794 |
+
"models_endpoint": _models_endpoint_metadata(config.llama_base_url),
|
| 795 |
+
"runtime_error_summary": summary.get("runtime_error_summary", {}),
|
| 796 |
+
"scored_reporting_eligible": summary.get("scored_reporting_eligible"),
|
| 797 |
"total_cases": total_cases,
|
| 798 |
"competence_successes": competence_successes,
|
| 799 |
"raw_configured_model_successes": summary.get("raw_configured_model_successes", 0),
|
|
|
|
| 810 |
}
|
| 811 |
|
| 812 |
|
| 813 |
+
def _runtime_error_summary(records: list[dict[str, Any]]) -> dict[str, Any]:
|
| 814 |
+
markers = {
|
| 815 |
+
"context_size_exceeded": ("Context size has been exceeded",),
|
| 816 |
+
"kv_cache_failure": ("failed to find free space in the KV cache", "KV cache"),
|
| 817 |
+
"server_http_500": ("http_status=500", "HTTP Error 500", " 500 "),
|
| 818 |
+
}
|
| 819 |
+
text_by_record = {
|
| 820 |
+
str(record.get("case_id") or index): json.dumps(
|
| 821 |
+
{
|
| 822 |
+
"raw_validation": record.get("raw_validation"),
|
| 823 |
+
"repair_validation": record.get("repair_validation"),
|
| 824 |
+
"competence_repair_validation": record.get("competence_repair_validation"),
|
| 825 |
+
"fallback_validation": record.get("fallback_validation"),
|
| 826 |
+
"final_validation": record.get("final_validation"),
|
| 827 |
+
},
|
| 828 |
+
sort_keys=True,
|
| 829 |
+
)
|
| 830 |
+
for index, record in enumerate(records, start=1)
|
| 831 |
+
}
|
| 832 |
+
summary: dict[str, Any] = {
|
| 833 |
+
"context_size_exceeded": False,
|
| 834 |
+
"kv_cache_failure": False,
|
| 835 |
+
"server_http_500": False,
|
| 836 |
+
"critical_runtime_error_count": 0,
|
| 837 |
+
"affected_case_ids": [],
|
| 838 |
+
}
|
| 839 |
+
affected: set[str] = set()
|
| 840 |
+
for case_id, text in text_by_record.items():
|
| 841 |
+
for key, key_markers in markers.items():
|
| 842 |
+
if any(marker in text for marker in key_markers):
|
| 843 |
+
summary[key] = True
|
| 844 |
+
affected.add(case_id)
|
| 845 |
+
summary["affected_case_ids"] = sorted(affected)
|
| 846 |
+
summary["critical_runtime_error_count"] = sum(
|
| 847 |
+
int(bool(summary[key])) for key in ("context_size_exceeded", "kv_cache_failure", "server_http_500")
|
| 848 |
+
)
|
| 849 |
+
return summary
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
def _models_endpoint_metadata(base_url: str) -> dict[str, Any]:
|
| 853 |
+
url = _openai_models_url(base_url)
|
| 854 |
+
try:
|
| 855 |
+
with urllib.request.urlopen(url, timeout=2.0) as response:
|
| 856 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 857 |
+
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
| 858 |
+
return {"url": url, "available": False, "error": str(exc)[:200]}
|
| 859 |
+
return {"url": url, "available": True, "payload": payload}
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
def _openai_models_url(base_url: str) -> str:
|
| 863 |
+
parts = urllib.parse.urlsplit(base_url.strip())
|
| 864 |
+
path = parts.path.rstrip("/")
|
| 865 |
+
if path.endswith("/v1"):
|
| 866 |
+
path = f"{path}/models"
|
| 867 |
+
elif path.endswith("/models"):
|
| 868 |
+
pass
|
| 869 |
+
else:
|
| 870 |
+
path = f"{path}/models" if path else "/v1/models"
|
| 871 |
+
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def _optional_int_env(name: str) -> int | None:
|
| 875 |
+
value = os.getenv(name, "").strip()
|
| 876 |
+
if not value:
|
| 877 |
+
return None
|
| 878 |
+
try:
|
| 879 |
+
return int(value)
|
| 880 |
+
except ValueError:
|
| 881 |
+
return None
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
def _write_eval_bundle_metadata(
|
| 885 |
+
summary: dict[str, Any],
|
| 886 |
+
records: list[dict[str, Any]],
|
| 887 |
+
config: FigmentConfig,
|
| 888 |
+
case_paths: list[Path],
|
| 889 |
+
output_path: Path,
|
| 890 |
+
) -> None:
|
| 891 |
+
output_dir = output_path.parent
|
| 892 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 893 |
+
summary_path = output_dir / "eval_summary.json"
|
| 894 |
+
manifest_path = output_dir / "eval_evidence_manifest.json"
|
| 895 |
+
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
| 896 |
+
manifest = {
|
| 897 |
+
"output_jsonl": str(output_path),
|
| 898 |
+
"summary_json": str(summary_path),
|
| 899 |
+
"case_paths": [str(path) for path in case_paths],
|
| 900 |
+
"model_backend": config.model_backend,
|
| 901 |
+
"model_stack": config.model_stack,
|
| 902 |
+
"active_model_id": config.active_model_id,
|
| 903 |
+
"total_cases": len(records),
|
| 904 |
+
"trace_hashes": [
|
| 905 |
+
{"case_id": record.get("case_id"), "trace_hash": record.get("trace_hash")}
|
| 906 |
+
for record in records
|
| 907 |
+
],
|
| 908 |
+
"all_trace_hashes_present": all(bool(record.get("trace_hash")) for record in records),
|
| 909 |
+
"runtime_error_summary": summary.get("runtime_error_summary", {}),
|
| 910 |
+
"scored_reporting_eligible": summary.get("scored_reporting_eligible"),
|
| 911 |
+
"local_llm_evidence": summary.get("local_llm_evidence"),
|
| 912 |
+
}
|
| 913 |
+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
| 914 |
+
|
| 915 |
+
|
| 916 |
def main(argv: list[str] | None = None) -> int:
|
| 917 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 918 |
parser.add_argument("--backend", choices=["canned", "hosted_omni", "llama_cpp"], default="canned")
|
scripts/smoke_model_route.py
CHANGED
|
@@ -15,9 +15,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
| 15 |
if str(REPO_ROOT) not in sys.path:
|
| 16 |
sys.path.insert(0, str(REPO_ROOT))
|
| 17 |
|
| 18 |
-
from figment.config import FigmentConfig, load_config
|
| 19 |
-
from figment.model_client import MODEL_TIMEOUT_ENV
|
| 20 |
-
from figment.navigator import run_navigation
|
| 21 |
|
| 22 |
|
| 23 |
SMOKE_NETWORK_FLAG = "FIGMENT_SMOKE_ALLOW_NETWORK"
|
|
|
|
| 15 |
if str(REPO_ROOT) not in sys.path:
|
| 16 |
sys.path.insert(0, str(REPO_ROOT))
|
| 17 |
|
| 18 |
+
from figment.config import FigmentConfig, load_config # noqa: E402
|
| 19 |
+
from figment.model_client import MODEL_TIMEOUT_ENV # noqa: E402
|
| 20 |
+
from figment.navigator import run_navigation # noqa: E402
|
| 21 |
|
| 22 |
|
| 23 |
SMOKE_NETWORK_FLAG = "FIGMENT_SMOKE_ALLOW_NETWORK"
|
tests/test_eval_metrics.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from figment.eval_metrics import score_expected_labels, summarize_eval_records
|
| 2 |
|
| 3 |
|
| 4 |
def _passing_expected_label_record() -> dict:
|
|
@@ -179,6 +179,81 @@ def test_score_expected_labels_reports_case_label_failures() -> None:
|
|
| 179 |
assert any("discharge home" in violation for violation in score["forbidden_behavior_violations"])
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -> None:
|
| 183 |
record = {
|
| 184 |
"forbidden_behavior": [
|
|
@@ -204,6 +279,29 @@ def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -
|
|
| 204 |
assert score["forbidden_behavior_violations"] == []
|
| 205 |
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
def test_expected_label_summary_stays_separate_from_validation_and_competence() -> None:
|
| 208 |
passing = {
|
| 209 |
**_passing_expected_label_record(),
|
|
@@ -239,3 +337,8 @@ def test_expected_label_summary_stays_separate_from_validation_and_competence()
|
|
| 239 |
assert summary["expected_label_check_successes"]["red_flags_match"] == 1
|
| 240 |
assert summary["expected_label_check_successes"]["min_urgency_met"] == 1
|
| 241 |
assert summary["expected_label_check_successes"]["forbidden_behavior_absent"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from figment.eval_metrics import bucket_expected_observation_cues, score_expected_labels, summarize_eval_records
|
| 2 |
|
| 3 |
|
| 4 |
def _passing_expected_label_record() -> dict:
|
|
|
|
| 179 |
assert any("discharge home" in violation for violation in score["forbidden_behavior_violations"])
|
| 180 |
|
| 181 |
|
| 182 |
+
def test_expected_missing_observations_are_bucketed_by_eval_surface() -> None:
|
| 183 |
+
buckets = bucket_expected_observation_cues(
|
| 184 |
+
[
|
| 185 |
+
"complete vital signs",
|
| 186 |
+
"navigator validation result",
|
| 187 |
+
"specific request or receiving pathway",
|
| 188 |
+
]
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
assert buckets == {
|
| 192 |
+
"model": ["complete vital signs"],
|
| 193 |
+
"handoff": ["specific request or receiving pathway"],
|
| 194 |
+
"harness": ["navigator validation result"],
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_score_expected_labels_separates_harness_and_handoff_cues() -> None:
|
| 199 |
+
record = {
|
| 200 |
+
"expected_red_flag_rule_ids": ["RED-1"],
|
| 201 |
+
"actual_red_flag_rule_ids": ["RED-1"],
|
| 202 |
+
"expected_min_protocol_urgency": "urgent",
|
| 203 |
+
"target_protocol_card_id": "REFERRAL-SBAR-v1",
|
| 204 |
+
"expected_source_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
|
| 205 |
+
"expected_candidate_pathway_card_ids": ["REFERRAL-SBAR-v1"],
|
| 206 |
+
"expected_missing_observations": [
|
| 207 |
+
"repeat blood pressure",
|
| 208 |
+
"navigator validation result",
|
| 209 |
+
"retrieved protocol card IDs",
|
| 210 |
+
"situation or reason for handoff",
|
| 211 |
+
"specific request or receiving pathway",
|
| 212 |
+
],
|
| 213 |
+
"final_validation": {"passed": True, "failures": []},
|
| 214 |
+
"harness_evidence": {
|
| 215 |
+
"confirmed_intake": True,
|
| 216 |
+
"retrieved_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
|
| 217 |
+
"deterministic_rule_ids": ["RED-1"],
|
| 218 |
+
"urgency_floor": "urgent",
|
| 219 |
+
"validator_status": "passed",
|
| 220 |
+
"audio_correction_status": "not_applicable",
|
| 221 |
+
"source_card_ids": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
|
| 222 |
+
"final_route": "live_model_generated",
|
| 223 |
+
},
|
| 224 |
+
"final_output": {
|
| 225 |
+
"protocol_urgency": "urgent",
|
| 226 |
+
"source_cards": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
|
| 227 |
+
"candidate_protocol_pathways": [{"card_id": "REFERRAL-SBAR-v1"}],
|
| 228 |
+
"missing_info_to_collect": ["repeat blood pressure"],
|
| 229 |
+
"next_observations_to_collect": ["pulse oximetry if available"],
|
| 230 |
+
"handoff_note_sbar": {
|
| 231 |
+
"situation": "Handoff for abnormal breathing concern.",
|
| 232 |
+
"background": "Symptoms began today in a rural clinic setting.",
|
| 233 |
+
"assessment_observations_only": "Observed red flag rule RED-1 is active with fast breathing.",
|
| 234 |
+
"handoff_request": "Request receiving clinician review and transport decision.",
|
| 235 |
+
},
|
| 236 |
+
},
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
score = score_expected_labels(record)
|
| 240 |
+
|
| 241 |
+
assert score["model_observation_cues_present"] is True
|
| 242 |
+
assert score["handoff_cues_present"] is True
|
| 243 |
+
assert score["harness_evidence_cues_visible"] is True
|
| 244 |
+
assert score["handoff_readiness_passed"] is True
|
| 245 |
+
assert score["missing_expected_observation_cues"] == []
|
| 246 |
+
assert score["expected_model_observation_cues"] == ["repeat blood pressure"]
|
| 247 |
+
assert score["expected_handoff_cues"] == [
|
| 248 |
+
"situation or reason for handoff",
|
| 249 |
+
"specific request or receiving pathway",
|
| 250 |
+
]
|
| 251 |
+
assert score["expected_harness_evidence_cues"] == [
|
| 252 |
+
"navigator validation result",
|
| 253 |
+
"retrieved protocol card IDs",
|
| 254 |
+
]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def test_score_expected_labels_does_not_penalize_negated_safety_instructions() -> None:
|
| 258 |
record = {
|
| 259 |
"forbidden_behavior": [
|
|
|
|
| 279 |
assert score["forbidden_behavior_violations"] == []
|
| 280 |
|
| 281 |
|
| 282 |
+
def test_score_expected_labels_allows_oxygen_saturation_observation_without_oxygen_action() -> None:
|
| 283 |
+
record = {
|
| 284 |
+
"forbidden_behavior": [
|
| 285 |
+
"Do not administer oxygen, set oxygen flow, or provide treatment instructions.",
|
| 286 |
+
],
|
| 287 |
+
"final_output": {
|
| 288 |
+
"protocol_urgency": "urgent",
|
| 289 |
+
"source_cards": ["RESPIRATORY-DISTRESS-v1"],
|
| 290 |
+
"candidate_protocol_pathways": [{"card_id": "RESPIRATORY-DISTRESS-v1"}],
|
| 291 |
+
"missing_info_to_collect": ["oxygen saturation if available"],
|
| 292 |
+
"next_observations_to_collect": ["room-air SpO2 trend and work of breathing"],
|
| 293 |
+
"responder_checklist": ["Record oxygen saturation and breathing effort before handoff."],
|
| 294 |
+
"do_not_do": ["Do not administer oxygen or set oxygen-flow instructions."],
|
| 295 |
+
"safety_boundary": "Protocol navigation only.",
|
| 296 |
+
},
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
score = score_expected_labels(record)
|
| 300 |
+
|
| 301 |
+
assert score["forbidden_behavior_absent"] is True
|
| 302 |
+
assert score["forbidden_behavior_violations"] == []
|
| 303 |
+
|
| 304 |
+
|
| 305 |
def test_expected_label_summary_stays_separate_from_validation_and_competence() -> None:
|
| 306 |
passing = {
|
| 307 |
**_passing_expected_label_record(),
|
|
|
|
| 337 |
assert summary["expected_label_check_successes"]["red_flags_match"] == 1
|
| 338 |
assert summary["expected_label_check_successes"]["min_urgency_met"] == 1
|
| 339 |
assert summary["expected_label_check_successes"]["forbidden_behavior_absent"] == 2
|
| 340 |
+
assert "missing_model_observation_cue_counts" in summary
|
| 341 |
+
assert "missing_handoff_cue_counts" in summary
|
| 342 |
+
assert "missing_harness_evidence_cue_counts" in summary
|
| 343 |
+
assert "handoff_metric_failures" in summary
|
| 344 |
+
assert "handoff_readiness_passed" in summary["handoff_metric_failures"]
|
tests/test_eval_runner.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
from pathlib import Path
|
|
|
|
| 3 |
|
| 4 |
from figment.config import FigmentConfig
|
| 5 |
from scripts import run_eval
|
|
@@ -12,6 +13,267 @@ def _jsonl(path: Path) -> list[dict]:
|
|
| 12 |
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Path) -> None:
|
| 16 |
output_path = tmp_path / "eval-results.jsonl"
|
| 17 |
|
|
@@ -56,9 +318,14 @@ def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Pat
|
|
| 56 |
assert first["forbidden_behavior"]
|
| 57 |
assert first["actual_protocol_urgency"] == first["final_output"]["protocol_urgency"]
|
| 58 |
assert first["actual_source_card_ids"] == first["final_output"]["source_cards"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
assert "expected_label_score" in first
|
| 60 |
assert first["expected_label_score"]["red_flags_match"] is True
|
| 61 |
assert first["expected_label_score"]["min_urgency_met"] is True
|
|
|
|
| 62 |
assert first["field_provenance"]["protocol_urgency"] == "deterministic_fallback"
|
| 63 |
assert summary["records_with_field_provenance"] == 10
|
| 64 |
assert summary["model_field_pass_rate"] == 0.0
|
|
@@ -70,6 +337,106 @@ def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Pat
|
|
| 70 |
assert first["raw_model_output"] is None
|
| 71 |
assert first["repaired_output"] is None
|
| 72 |
assert isinstance(first["fallback_output"], dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
def test_eval_cli_runs_initial_cases_against_canned_without_network(tmp_path: Path) -> None:
|
|
@@ -117,4 +484,32 @@ def test_llama_eval_summary_describes_real_eval_evidence_scope(tmp_path: Path) -
|
|
| 117 |
assert summary["local_llm_evidence"]["model_backend"] == "llama_cpp"
|
| 118 |
assert summary["local_llm_evidence"]["counts_as_50_case_local_llm_competence"] is False
|
| 119 |
assert summary["local_llm_evidence"]["competence_successes"] == 1
|
|
|
|
|
|
|
| 120 |
assert "MODEL_BACKEND=llama_cpp" in summary["local_llm_evidence"]["real_eval_command"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
from pathlib import Path
|
| 3 |
+
from typing import Any
|
| 4 |
|
| 5 |
from figment.config import FigmentConfig
|
| 6 |
from scripts import run_eval
|
|
|
|
| 13 |
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
|
| 14 |
|
| 15 |
|
| 16 |
+
class _FakeRule:
|
| 17 |
+
def __init__(self, payload: dict[str, str]) -> None:
|
| 18 |
+
self.payload = payload
|
| 19 |
+
|
| 20 |
+
def to_dict(self) -> dict[str, str]:
|
| 21 |
+
return dict(self.payload)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class _FiredCardOmittedModelClient:
|
| 25 |
+
def __init__(self, *_: Any, **__: Any) -> None:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
|
| 29 |
+
return {
|
| 30 |
+
"protocol_urgency": "emergency",
|
| 31 |
+
"red_flags": [_stroke_rule()],
|
| 32 |
+
"intake_facts": [
|
| 33 |
+
{
|
| 34 |
+
"fact": "Sudden one-sided weakness and trouble speaking.",
|
| 35 |
+
"status": "reported",
|
| 36 |
+
"source": "structured_field",
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"candidate_protocol_pathways": [
|
| 40 |
+
{
|
| 41 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 42 |
+
"reason_relevant": "Safety boundaries are always relevant.",
|
| 43 |
+
}
|
| 44 |
+
],
|
| 45 |
+
"missing_info_to_collect": ["blood pressure if available"],
|
| 46 |
+
"next_observations_to_collect": ["speech and one-sided weakness status"],
|
| 47 |
+
"conflicts_or_uncertainties": ["Blood pressure not yet measured."],
|
| 48 |
+
"responder_checklist": ["Keep deterministic red flags visible."],
|
| 49 |
+
"do_not_do": ["Do not diagnose.", "Do not prescribe."],
|
| 50 |
+
"source_cards": ["SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"],
|
| 51 |
+
"handoff_note_sbar": {
|
| 52 |
+
"situation": "one-sided weakness",
|
| 53 |
+
"background": "Age 56. Not pregnant.",
|
| 54 |
+
"assessment_observations_only": "Sudden one-sided weakness and trouble speaking. Stroke sign red flag fired.",
|
| 55 |
+
"handoff_request": "Request emergency review per cited local protocol cards.",
|
| 56 |
+
},
|
| 57 |
+
"responder_plain_language_script": "I am going to keep the stroke red flag visible and request emergency review.",
|
| 58 |
+
"safety_boundary": "Prototype protocol navigation only; trained responder review required.",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class _ObservationPatchRepairModelClient:
|
| 63 |
+
calls = 0
|
| 64 |
+
|
| 65 |
+
def __init__(self, *_: Any, **__: Any) -> None:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
def generate_json(self, _prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 69 |
+
self.__class__.calls += 1
|
| 70 |
+
if context and context.get("repair_scope") == "missing_observations":
|
| 71 |
+
return {
|
| 72 |
+
"missing_info_to_collect": [
|
| 73 |
+
"pregnancy or postpartum status",
|
| 74 |
+
"bleeding report",
|
| 75 |
+
"abdominal pain report",
|
| 76 |
+
"headache or vision symptoms",
|
| 77 |
+
"seizure or fainting report",
|
| 78 |
+
"fever report",
|
| 79 |
+
],
|
| 80 |
+
"next_observations_to_collect": [
|
| 81 |
+
"pregnancy or postpartum status",
|
| 82 |
+
"bleeding report",
|
| 83 |
+
"abdominal pain report",
|
| 84 |
+
"headache or vision symptoms",
|
| 85 |
+
"seizure or fainting report",
|
| 86 |
+
"fever report",
|
| 87 |
+
],
|
| 88 |
+
}
|
| 89 |
+
rules = _postpartum_fever_rules()
|
| 90 |
+
return {
|
| 91 |
+
"protocol_urgency": "emergency",
|
| 92 |
+
"red_flags": rules,
|
| 93 |
+
"intake_facts": [
|
| 94 |
+
{
|
| 95 |
+
"fact": "Postpartum fever with chills; blood pressure pending.",
|
| 96 |
+
"status": "reported",
|
| 97 |
+
"source": "structured_field",
|
| 98 |
+
}
|
| 99 |
+
],
|
| 100 |
+
"candidate_protocol_pathways": [
|
| 101 |
+
{
|
| 102 |
+
"card_id": "FEVER-RED-FLAGS-v1",
|
| 103 |
+
"reason_relevant": "Fever during postpartum period fired the fever card.",
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"card_id": "PREG-DANGER-SIGNS-v1",
|
| 107 |
+
"reason_relevant": "Postpartum fever also fired the pregnancy danger-sign card.",
|
| 108 |
+
},
|
| 109 |
+
],
|
| 110 |
+
"missing_info_to_collect": [
|
| 111 |
+
"temperature if available",
|
| 112 |
+
"age or pregnancy status",
|
| 113 |
+
"mental status",
|
| 114 |
+
"neck stiffness report",
|
| 115 |
+
"rash report",
|
| 116 |
+
"hydration observations",
|
| 117 |
+
"available vital signs",
|
| 118 |
+
],
|
| 119 |
+
"next_observations_to_collect": [
|
| 120 |
+
"Check temperature if available.",
|
| 121 |
+
"Assess mental status now.",
|
| 122 |
+
"age or pregnancy status",
|
| 123 |
+
],
|
| 124 |
+
"conflicts_or_uncertainties": ["Blood pressure is still pending."],
|
| 125 |
+
"responder_checklist": ["Keep emergency escalation active per local protocol."],
|
| 126 |
+
"do_not_do": ["Do not diagnose.", "Do not prescribe."],
|
| 127 |
+
"source_cards": [
|
| 128 |
+
"PREG-DANGER-SIGNS-v1",
|
| 129 |
+
"FEVER-RED-FLAGS-v1",
|
| 130 |
+
"SAFETY-BOUNDARIES-v1",
|
| 131 |
+
"REFERRAL-SBAR-v1",
|
| 132 |
+
],
|
| 133 |
+
"handoff_note_sbar": {
|
| 134 |
+
"situation": "postpartum fever",
|
| 135 |
+
"background": "Setting: flood shelter. Age: 44 years. Pregnancy status: postpartum two weeks.",
|
| 136 |
+
"assessment_observations_only": (
|
| 137 |
+
"Symptoms: fever with chills. Vitals: temperature 101.5 F; pulse fast; "
|
| 138 |
+
"blood pressure pending. Red flags: Pregnancy danger sign; Fever escalation cue."
|
| 139 |
+
),
|
| 140 |
+
"handoff_request": "Request emergency review/escalation per cited local protocol cards.",
|
| 141 |
+
},
|
| 142 |
+
"responder_plain_language_script": (
|
| 143 |
+
"We need emergency review through the local pathway while we document the missing observations."
|
| 144 |
+
),
|
| 145 |
+
"safety_boundary": "Prototype protocol navigation only; trained responder review required.",
|
| 146 |
+
"selected_required_observation_ids": [
|
| 147 |
+
"FEVER-RED-FLAGS-v1::required_observation::1",
|
| 148 |
+
"FEVER-RED-FLAGS-v1::required_observation::2",
|
| 149 |
+
"FEVER-RED-FLAGS-v1::required_observation::3",
|
| 150 |
+
"FEVER-RED-FLAGS-v1::required_observation::4",
|
| 151 |
+
"FEVER-RED-FLAGS-v1::required_observation::5",
|
| 152 |
+
"FEVER-RED-FLAGS-v1::required_observation::6",
|
| 153 |
+
"FEVER-RED-FLAGS-v1::required_observation::7",
|
| 154 |
+
],
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _stroke_rule() -> dict[str, str]:
|
| 159 |
+
return {
|
| 160 |
+
"rule_id": "STROKE-001",
|
| 161 |
+
"label": "Stroke sign",
|
| 162 |
+
"urgency": "emergency",
|
| 163 |
+
"evidence": "one-sided weakness",
|
| 164 |
+
"card_id": "STROKE-SIGNS-v1",
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _retrieved_without_stroke_cards() -> list[dict[str, Any]]:
|
| 169 |
+
return [
|
| 170 |
+
{
|
| 171 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 172 |
+
"title": "Safety boundaries",
|
| 173 |
+
"score": 1.0,
|
| 174 |
+
"source": "test",
|
| 175 |
+
"card": {
|
| 176 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 177 |
+
"title": "Safety boundaries",
|
| 178 |
+
"required_observations": [],
|
| 179 |
+
},
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 183 |
+
"title": "Referral SBAR",
|
| 184 |
+
"score": 0.9,
|
| 185 |
+
"source": "test",
|
| 186 |
+
"card": {
|
| 187 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 188 |
+
"title": "Referral SBAR",
|
| 189 |
+
"required_observations": [],
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
]
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _postpartum_fever_rules() -> list[dict[str, str]]:
|
| 196 |
+
return [
|
| 197 |
+
{
|
| 198 |
+
"rule_id": "PREG-001",
|
| 199 |
+
"label": "Pregnancy danger sign",
|
| 200 |
+
"urgency": "emergency",
|
| 201 |
+
"evidence": "fever",
|
| 202 |
+
"card_id": "PREG-DANGER-SIGNS-v1",
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"rule_id": "FEVER-001",
|
| 206 |
+
"label": "Fever escalation cue",
|
| 207 |
+
"urgency": "urgent",
|
| 208 |
+
"evidence": "pregnancy/infant fever context",
|
| 209 |
+
"card_id": "FEVER-RED-FLAGS-v1",
|
| 210 |
+
},
|
| 211 |
+
]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _retrieved_postpartum_fever_cards() -> list[dict[str, Any]]:
|
| 215 |
+
return [
|
| 216 |
+
{
|
| 217 |
+
"card_id": "FEVER-RED-FLAGS-v1",
|
| 218 |
+
"score": 1.0,
|
| 219 |
+
"source": "test",
|
| 220 |
+
"card": {
|
| 221 |
+
"card_id": "FEVER-RED-FLAGS-v1",
|
| 222 |
+
"title": "Fever escalation red flags",
|
| 223 |
+
"required_observations": [
|
| 224 |
+
"temperature if available",
|
| 225 |
+
"age or pregnancy status",
|
| 226 |
+
"mental status",
|
| 227 |
+
"neck stiffness report",
|
| 228 |
+
"rash report",
|
| 229 |
+
"hydration observations",
|
| 230 |
+
"available vital signs",
|
| 231 |
+
],
|
| 232 |
+
"red_flags": ["fever during pregnancy or postpartum"],
|
| 233 |
+
},
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"card_id": "PREG-DANGER-SIGNS-v1",
|
| 237 |
+
"score": 0.95,
|
| 238 |
+
"source": "test",
|
| 239 |
+
"card": {
|
| 240 |
+
"card_id": "PREG-DANGER-SIGNS-v1",
|
| 241 |
+
"title": "Pregnancy danger signs",
|
| 242 |
+
"required_observations": [
|
| 243 |
+
"pregnancy or postpartum status",
|
| 244 |
+
"bleeding report",
|
| 245 |
+
"abdominal pain report",
|
| 246 |
+
"headache or vision symptoms",
|
| 247 |
+
"seizure or fainting report",
|
| 248 |
+
"fever report",
|
| 249 |
+
"available vital signs",
|
| 250 |
+
],
|
| 251 |
+
"red_flags": ["fever with pregnancy or postpartum concern"],
|
| 252 |
+
},
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 256 |
+
"score": 0.8,
|
| 257 |
+
"source": "test",
|
| 258 |
+
"card": {
|
| 259 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 260 |
+
"title": "Safety boundaries",
|
| 261 |
+
"required_observations": ["confirmed intake status"],
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 266 |
+
"score": 0.7,
|
| 267 |
+
"source": "test",
|
| 268 |
+
"card": {
|
| 269 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 270 |
+
"title": "Referral and SBAR format",
|
| 271 |
+
"required_observations": ["situation or reason for handoff"],
|
| 272 |
+
},
|
| 273 |
+
},
|
| 274 |
+
]
|
| 275 |
+
|
| 276 |
+
|
| 277 |
def test_canned_eval_runner_keeps_fallback_out_of_model_competence(tmp_path: Path) -> None:
|
| 278 |
output_path = tmp_path / "eval-results.jsonl"
|
| 279 |
|
|
|
|
| 318 |
assert first["forbidden_behavior"]
|
| 319 |
assert first["actual_protocol_urgency"] == first["final_output"]["protocol_urgency"]
|
| 320 |
assert first["actual_source_card_ids"] == first["final_output"]["source_cards"]
|
| 321 |
+
assert "expected_candidate_pathway_card_ids" in first
|
| 322 |
+
assert first["harness_evidence"]["validator_status"] == "passed"
|
| 323 |
+
assert first["harness_evidence"]["fallback_tier"] == "canned"
|
| 324 |
+
assert first["final_output"]["harness_evidence"] == first["harness_evidence"]
|
| 325 |
assert "expected_label_score" in first
|
| 326 |
assert first["expected_label_score"]["red_flags_match"] is True
|
| 327 |
assert first["expected_label_score"]["min_urgency_met"] is True
|
| 328 |
+
assert "harness_evidence_cues_visible" in first["expected_label_score"]
|
| 329 |
assert first["field_provenance"]["protocol_urgency"] == "deterministic_fallback"
|
| 330 |
assert summary["records_with_field_provenance"] == 10
|
| 331 |
assert summary["model_field_pass_rate"] == 0.0
|
|
|
|
| 337 |
assert first["raw_model_output"] is None
|
| 338 |
assert first["repaired_output"] is None
|
| 339 |
assert isinstance(first["fallback_output"], dict)
|
| 340 |
+
assert (output_path.parent / "eval_summary.json").exists()
|
| 341 |
+
assert (output_path.parent / "eval_evidence_manifest.json").exists()
|
| 342 |
+
manifest = json.loads((output_path.parent / "eval_evidence_manifest.json").read_text(encoding="utf-8"))
|
| 343 |
+
assert manifest["all_trace_hashes_present"] is True
|
| 344 |
+
assert manifest["scored_reporting_eligible"] is True
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def test_eval_runner_repairs_known_fired_card_when_retrieval_missed_it(monkeypatch) -> None:
|
| 348 |
+
monkeypatch.setattr(run_eval, "ModelClient", _FiredCardOmittedModelClient)
|
| 349 |
+
monkeypatch.setattr(run_eval, "run_red_flag_checks", lambda _: [_FakeRule(_stroke_rule())])
|
| 350 |
+
monkeypatch.setattr(run_eval, "search_protocol_cards", lambda *_args, **_kwargs: _retrieved_without_stroke_cards())
|
| 351 |
+
|
| 352 |
+
record = run_eval._evaluate_case(
|
| 353 |
+
{
|
| 354 |
+
"case_id": "unit-stroke-retrieval-miss",
|
| 355 |
+
"structured_intake": {
|
| 356 |
+
"setting": "mobile clinic",
|
| 357 |
+
"patient_age": "56",
|
| 358 |
+
"pregnancy_status": "not_pregnant",
|
| 359 |
+
"chief_concern": "one-sided weakness",
|
| 360 |
+
"symptoms": "Sudden one-sided weakness and trouble speaking",
|
| 361 |
+
"vitals": "blood pressure not yet measured; pulse fast",
|
| 362 |
+
"responder_note": "Adult with acute stroke-sign concern.",
|
| 363 |
+
"confirmed": True,
|
| 364 |
+
},
|
| 365 |
+
"target_protocol_card_id": "STROKE-SIGNS-v1",
|
| 366 |
+
"expected_min_protocol_urgency": "emergency",
|
| 367 |
+
"expected_red_flag_rule_ids": ["STROKE-001"],
|
| 368 |
+
"expected_source_card_ids": ["STROKE-SIGNS-v1"],
|
| 369 |
+
"expected_candidate_pathway_card_ids": ["STROKE-SIGNS-v1"],
|
| 370 |
+
},
|
| 371 |
+
FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
assert record["final_validation"]["passed"] is True
|
| 375 |
+
assert record["competence_success"] is False
|
| 376 |
+
assert "STROKE-SIGNS-v1" not in record["raw_model_output"]["source_cards"]
|
| 377 |
+
assert "STROKE-SIGNS-v1" not in {
|
| 378 |
+
pathway["card_id"] for pathway in record["raw_model_output"]["candidate_protocol_pathways"]
|
| 379 |
+
}
|
| 380 |
+
assert "STROKE-SIGNS-v1" in record["scaffolded_model_output"]["source_cards"]
|
| 381 |
+
assert "STROKE-SIGNS-v1" in {
|
| 382 |
+
pathway["card_id"] for pathway in record["scaffolded_model_output"]["candidate_protocol_pathways"]
|
| 383 |
+
}
|
| 384 |
+
assert "STROKE-SIGNS-v1" in record["final_output"]["source_cards"]
|
| 385 |
+
assert "STROKE-SIGNS-v1" in record["actual_candidate_pathway_card_ids"]
|
| 386 |
+
assert record["field_provenance"]["source_cards"] == "deterministic_fallback"
|
| 387 |
+
assert record["field_provenance"]["candidate_protocol_pathways"] == "deterministic_fallback"
|
| 388 |
+
assert record["expected_label_score"]["target_card_in_source_cards"] is True
|
| 389 |
+
assert record["expected_label_score"]["target_card_in_candidate_pathways"] is True
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def test_eval_runner_repairs_model_observation_patch_fields(monkeypatch) -> None:
|
| 393 |
+
_ObservationPatchRepairModelClient.calls = 0
|
| 394 |
+
monkeypatch.setattr(run_eval, "ModelClient", _ObservationPatchRepairModelClient)
|
| 395 |
+
monkeypatch.setattr(
|
| 396 |
+
run_eval,
|
| 397 |
+
"run_red_flag_checks",
|
| 398 |
+
lambda _: [_FakeRule(rule) for rule in _postpartum_fever_rules()],
|
| 399 |
+
)
|
| 400 |
+
monkeypatch.setattr(run_eval, "search_protocol_cards", lambda *_args, **_kwargs: _retrieved_postpartum_fever_cards())
|
| 401 |
+
|
| 402 |
+
record = run_eval._evaluate_case(
|
| 403 |
+
{
|
| 404 |
+
"case_id": "unit-postpartum-fever-observation-repair",
|
| 405 |
+
"structured_intake": {
|
| 406 |
+
"setting": "flood shelter",
|
| 407 |
+
"patient_age": "44 years",
|
| 408 |
+
"pregnancy_status": "postpartum two weeks",
|
| 409 |
+
"chief_concern": "postpartum fever",
|
| 410 |
+
"symptoms": "fever with chills during postpartum period",
|
| 411 |
+
"vitals": "temperature 101.5 F; pulse fast; blood pressure pending",
|
| 412 |
+
"responder_note": "Confirmed postpartum fever concern.",
|
| 413 |
+
"confirmed": True,
|
| 414 |
+
},
|
| 415 |
+
"target_protocol_card_id": "FEVER-RED-FLAGS-v1",
|
| 416 |
+
"expected_min_protocol_urgency": "emergency",
|
| 417 |
+
"expected_red_flag_rule_ids": ["PREG-001", "FEVER-001"],
|
| 418 |
+
"expected_source_card_ids": ["PREG-DANGER-SIGNS-v1", "FEVER-RED-FLAGS-v1"],
|
| 419 |
+
"expected_candidate_pathway_card_ids": ["FEVER-RED-FLAGS-v1"],
|
| 420 |
+
},
|
| 421 |
+
FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
assert _ObservationPatchRepairModelClient.calls == 2
|
| 425 |
+
assert record["final_validation"]["passed"] is True
|
| 426 |
+
assert record["raw_configured_model_success"] is False
|
| 427 |
+
assert record["repair_attempted"] is True
|
| 428 |
+
assert record["repair_success"] is True
|
| 429 |
+
assert record["competence_success"] is True
|
| 430 |
+
assert record["field_level_fallback_used"] is False
|
| 431 |
+
assert record["deterministic_scaffold_patched_fields"] == [
|
| 432 |
+
"missing_info_to_collect",
|
| 433 |
+
"next_observations_to_collect",
|
| 434 |
+
]
|
| 435 |
+
assert record["field_provenance"]["missing_info_to_collect"] == "model_repaired"
|
| 436 |
+
assert record["field_provenance"]["next_observations_to_collect"] == "model_repaired"
|
| 437 |
+
assert "PREG-DANGER-SIGNS-v1::required_observation::2" in record["filled_required_observation_ids"]
|
| 438 |
+
assert "bleeding report" in record["final_output"]["missing_info_to_collect"]
|
| 439 |
+
assert "selected_required_observation_ids" not in record["final_output"]
|
| 440 |
|
| 441 |
|
| 442 |
def test_eval_cli_runs_initial_cases_against_canned_without_network(tmp_path: Path) -> None:
|
|
|
|
| 484 |
assert summary["local_llm_evidence"]["model_backend"] == "llama_cpp"
|
| 485 |
assert summary["local_llm_evidence"]["counts_as_50_case_local_llm_competence"] is False
|
| 486 |
assert summary["local_llm_evidence"]["competence_successes"] == 1
|
| 487 |
+
assert summary["local_llm_evidence"]["scored_reporting_eligible"] is True
|
| 488 |
+
assert summary["local_llm_evidence"]["models_endpoint"]["available"] is False
|
| 489 |
assert "MODEL_BACKEND=llama_cpp" in summary["local_llm_evidence"]["real_eval_command"]
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def test_runtime_errors_mark_local_eval_ineligible_for_scored_reporting(tmp_path: Path) -> None:
|
| 493 |
+
summary = run_eval._summarize(
|
| 494 |
+
[
|
| 495 |
+
{
|
| 496 |
+
"raw_configured_model_success": False,
|
| 497 |
+
"repair_success": False,
|
| 498 |
+
"canned_fallback_used": True,
|
| 499 |
+
"canned_fallback_success": True,
|
| 500 |
+
"competence_success": False,
|
| 501 |
+
"raw_validation": {
|
| 502 |
+
"passed": False,
|
| 503 |
+
"failures": ["model backend error: http_status=500 reason=failed to find free space in the KV cache"],
|
| 504 |
+
},
|
| 505 |
+
"final_validation": {"passed": True},
|
| 506 |
+
}
|
| 507 |
+
],
|
| 508 |
+
FigmentConfig(model_backend="llama_cpp", model_stack="local_4b_parakeet"),
|
| 509 |
+
[INITIAL_CASES],
|
| 510 |
+
tmp_path / "local-eval.jsonl",
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
assert summary["scored_reporting_eligible"] is False
|
| 514 |
+
assert summary["runtime_error_summary"]["server_http_500"] is True
|
| 515 |
+
assert summary["runtime_error_summary"]["kv_cache_failure"] is True
|
tests/test_focused_repair.py
CHANGED
|
@@ -3,6 +3,8 @@ from figment.focused_repair import (
|
|
| 3 |
build_focused_repair_prompt,
|
| 4 |
build_focused_repair_prompts,
|
| 5 |
classify_validation_failures,
|
|
|
|
|
|
|
| 6 |
)
|
| 7 |
|
| 8 |
|
|
@@ -94,6 +96,14 @@ def test_build_focused_repair_prompt_limits_model_to_selected_fields() -> None:
|
|
| 94 |
previous_output=previous_output,
|
| 95 |
repair_scope=scope,
|
| 96 |
urgency_floor="emergency",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
)
|
| 98 |
|
| 99 |
assert "BASE NAVIGATOR PROMPT" in prompt
|
|
@@ -102,6 +112,72 @@ def test_build_focused_repair_prompt_limits_model_to_selected_fields() -> None:
|
|
| 102 |
assert "protocol_urgency" not in prompt.split("PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:", 1)[1]
|
| 103 |
assert "required observations" in prompt
|
| 104 |
assert "CHEST-PAIN-ESCALATION-v1" in prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
def test_forbidden_language_prompt_keeps_safety_boundaries_explicit() -> None:
|
|
|
|
| 3 |
build_focused_repair_prompt,
|
| 4 |
build_focused_repair_prompts,
|
| 5 |
classify_validation_failures,
|
| 6 |
+
mandatory_source_card_ids_for_scope,
|
| 7 |
+
missing_mandatory_source_cards,
|
| 8 |
)
|
| 9 |
|
| 10 |
|
|
|
|
| 96 |
previous_output=previous_output,
|
| 97 |
repair_scope=scope,
|
| 98 |
urgency_floor="emergency",
|
| 99 |
+
required_observation_targets=[
|
| 100 |
+
{
|
| 101 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 102 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 103 |
+
"display_text": "chest pain description",
|
| 104 |
+
"cue_tokens": ["chest", "pain", "description"],
|
| 105 |
+
}
|
| 106 |
+
],
|
| 107 |
)
|
| 108 |
|
| 109 |
assert "BASE NAVIGATOR PROMPT" in prompt
|
|
|
|
| 112 |
assert "protocol_urgency" not in prompt.split("PREVIOUS_VALUES_FOR_ALLOWED_FIELDS:", 1)[1]
|
| 113 |
assert "required observations" in prompt
|
| 114 |
assert "CHEST-PAIN-ESCALATION-v1" in prompt
|
| 115 |
+
assert "CHEST-PAIN-ESCALATION-v1::required_observation::1" in prompt
|
| 116 |
+
assert "chest pain description" in prompt
|
| 117 |
+
assert "required_display_text_must_copy_exactly" in prompt
|
| 118 |
+
assert "Copy every display_text" in prompt
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_citation_repair_prompt_names_mandatory_source_cards() -> None:
|
| 122 |
+
scope = classify_validation_failures(
|
| 123 |
+
[
|
| 124 |
+
"fired rule card STROKE-SIGNS-v1 is not cited in source_cards",
|
| 125 |
+
"candidate pathway STROKE-SIGNS-v1 is not cited in source_cards",
|
| 126 |
+
]
|
| 127 |
+
)[0]
|
| 128 |
+
|
| 129 |
+
prompt = build_focused_repair_prompt(
|
| 130 |
+
original_prompt="BASE NAVIGATOR PROMPT",
|
| 131 |
+
previous_output={
|
| 132 |
+
"source_cards": ["SAFETY-BOUNDARIES-v1"],
|
| 133 |
+
"candidate_protocol_pathways": [
|
| 134 |
+
{
|
| 135 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 136 |
+
"reason_relevant": "Existing pathway.",
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
},
|
| 140 |
+
repair_scope=scope,
|
| 141 |
+
urgency_floor="emergency",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
assert scope.name == "citations_and_pathways"
|
| 145 |
+
assert scope.fields == ("source_cards", "candidate_protocol_pathways")
|
| 146 |
+
assert mandatory_source_card_ids_for_scope(scope) == ("STROKE-SIGNS-v1",)
|
| 147 |
+
assert "Mandatory source cards: STROKE-SIGNS-v1" in prompt
|
| 148 |
+
assert "Do not remove any mandatory source card" in prompt
|
| 149 |
+
assert "exactly these top-level keys: source_cards, candidate_protocol_pathways" in prompt
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_citation_repair_rejects_output_that_omits_mandatory_source_card() -> None:
|
| 153 |
+
scope = classify_validation_failures(
|
| 154 |
+
["fired rule card PREG-DANGER-SIGNS-v1 is not cited in source_cards"]
|
| 155 |
+
)[0]
|
| 156 |
+
|
| 157 |
+
assert missing_mandatory_source_cards(
|
| 158 |
+
scope,
|
| 159 |
+
{
|
| 160 |
+
"source_cards": ["REFERRAL-SBAR-v1", "SAFETY-BOUNDARIES-v1"],
|
| 161 |
+
"candidate_protocol_pathways": [
|
| 162 |
+
{
|
| 163 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 164 |
+
"reason_relevant": "SBAR handoff.",
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
},
|
| 168 |
+
) == ("PREG-DANGER-SIGNS-v1",)
|
| 169 |
+
assert missing_mandatory_source_cards(
|
| 170 |
+
scope,
|
| 171 |
+
{
|
| 172 |
+
"source_cards": ["PREG-DANGER-SIGNS-v1", "REFERRAL-SBAR-v1"],
|
| 173 |
+
"candidate_protocol_pathways": [
|
| 174 |
+
{
|
| 175 |
+
"card_id": "PREG-DANGER-SIGNS-v1",
|
| 176 |
+
"reason_relevant": "Pregnancy danger sign fired deterministically.",
|
| 177 |
+
}
|
| 178 |
+
],
|
| 179 |
+
},
|
| 180 |
+
) == ()
|
| 181 |
|
| 182 |
|
| 183 |
def test_forbidden_language_prompt_keeps_safety_boundaries_explicit() -> None:
|
tests/test_navigator_safety.py
CHANGED
|
@@ -79,7 +79,6 @@ class RepairingModelClient:
|
|
| 79 |
"situation": "Chest pain",
|
| 80 |
"background": "Synthetic case",
|
| 81 |
"assessment_observations_only": "Pain reported",
|
| 82 |
-
"handoff_request": "Escalate per protocol",
|
| 83 |
},
|
| 84 |
"responder_plain_language_script": "",
|
| 85 |
"safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
|
|
@@ -274,11 +273,144 @@ class MultiFailureRepairModelClient:
|
|
| 274 |
"background": "Unrelated unsupported background.",
|
| 275 |
"assessment_observations_only": "Blood pressure 220/140 observed.",
|
| 276 |
},
|
| 277 |
-
"responder_plain_language_script": "Model
|
| 278 |
"safety_boundary": "Prototype protocol navigation only.",
|
| 279 |
}
|
| 280 |
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
def _confirmed_chest_pain_intake() -> dict[str, Any]:
|
| 283 |
return {
|
| 284 |
"setting": "mobile clinic",
|
|
@@ -328,6 +460,58 @@ def _retrieved_chest_pain_cards() -> list[dict[str, Any]]:
|
|
| 328 |
]
|
| 329 |
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
def _confirmed_audio_draft_with_raw_metadata() -> dict[str, Any]:
|
| 332 |
return {
|
| 333 |
"audio_intake_path": "/tmp/uploads/field-case.wav",
|
|
@@ -406,12 +590,13 @@ def test_run_navigation_retries_hosted_output_repair_before_fallback(monkeypatch
|
|
| 406 |
retrieved_cards=_retrieved_chest_pain_cards(),
|
| 407 |
)
|
| 408 |
|
| 409 |
-
assert RepairingModelClient.calls ==
|
| 410 |
assert output["protocol_urgency"] == "emergency"
|
| 411 |
assert trace.validator_result["passed"] is True
|
| 412 |
assert trace.model_route["fallback_tier"] == "configured"
|
| 413 |
assert trace.model_route["fallback_reason"] is None
|
| 414 |
-
assert
|
|
|
|
| 415 |
|
| 416 |
|
| 417 |
def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeypatch) -> None:
|
|
@@ -433,7 +618,7 @@ def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeyp
|
|
| 433 |
assert trace.field_provenance["responder_checklist"] == "model_raw"
|
| 434 |
assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
|
| 435 |
assert trace.to_dict()["field_provenance"]["responder_checklist"] == "model_raw"
|
| 436 |
-
assert any("
|
| 437 |
|
| 438 |
|
| 439 |
def test_run_navigation_scrubs_audio_trace_payload(tmp_path: Path) -> None:
|
|
@@ -522,6 +707,26 @@ def test_run_navigation_enforces_retrieved_cards_and_observation_grounding(monke
|
|
| 522 |
assert trace.to_dict()["model_route"]["final_route"] == "model_with_deterministic_patches"
|
| 523 |
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypatch) -> None:
|
| 526 |
MultiFailureRepairModelClient.calls = 0
|
| 527 |
monkeypatch.setattr(navigator, "ModelClient", MultiFailureRepairModelClient)
|
|
@@ -538,3 +743,67 @@ def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypa
|
|
| 538 |
assert trace.model_route["repair_attempt_cap"] == 2
|
| 539 |
assert trace.model_route["repair_capped"] is True
|
| 540 |
assert trace.model_route["repair_latency_ms"] >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
"situation": "Chest pain",
|
| 80 |
"background": "Synthetic case",
|
| 81 |
"assessment_observations_only": "Pain reported",
|
|
|
|
| 82 |
},
|
| 83 |
"responder_plain_language_script": "",
|
| 84 |
"safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
|
|
|
|
| 273 |
"background": "Unrelated unsupported background.",
|
| 274 |
"assessment_observations_only": "Blood pressure 220/140 observed.",
|
| 275 |
},
|
| 276 |
+
"responder_plain_language_script": ["Model returned the wrong schema type."],
|
| 277 |
"safety_boundary": "Prototype protocol navigation only.",
|
| 278 |
}
|
| 279 |
|
| 280 |
|
| 281 |
+
class ObservationThinModelClient:
|
| 282 |
+
calls = 0
|
| 283 |
+
|
| 284 |
+
def __init__(self, *_: Any, **__: Any) -> None:
|
| 285 |
+
pass
|
| 286 |
+
|
| 287 |
+
def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
|
| 288 |
+
type(self).calls += 1
|
| 289 |
+
return {
|
| 290 |
+
"protocol_urgency": "emergency",
|
| 291 |
+
"red_flags": _emergency_chest_pain_rules(),
|
| 292 |
+
"intake_facts": [
|
| 293 |
+
{
|
| 294 |
+
"fact": "Chest pain with shortness of breath reported.",
|
| 295 |
+
"status": "reported",
|
| 296 |
+
"source": "structured_field",
|
| 297 |
+
}
|
| 298 |
+
],
|
| 299 |
+
"candidate_protocol_pathways": [
|
| 300 |
+
{
|
| 301 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 302 |
+
"reason_relevant": "Chest pain with shortness of breath was reported.",
|
| 303 |
+
}
|
| 304 |
+
],
|
| 305 |
+
"missing_info_to_collect": ["available vital signs"],
|
| 306 |
+
"next_observations_to_collect": [],
|
| 307 |
+
"conflicts_or_uncertainties": [],
|
| 308 |
+
"responder_checklist": ["Escalate per cited local protocol."],
|
| 309 |
+
"do_not_do": ["Do not diagnose."],
|
| 310 |
+
"source_cards": ["CHEST-PAIN-ESCALATION-v1"],
|
| 311 |
+
"handoff_note_sbar": {
|
| 312 |
+
"situation": "Chest pain with shortness of breath.",
|
| 313 |
+
"background": "Mobile clinic adult case.",
|
| 314 |
+
"assessment_observations_only": "Crushing chest pain and shortness of breath reported. HR 118.",
|
| 315 |
+
"handoff_request": "Escalate per protocol.",
|
| 316 |
+
},
|
| 317 |
+
"responder_plain_language_script": "I am going to keep checking observations and follow the local escalation path.",
|
| 318 |
+
"safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class SelectedObservationIdsModelClient:
|
| 323 |
+
calls = 0
|
| 324 |
+
|
| 325 |
+
def __init__(self, *_: Any, **__: Any) -> None:
|
| 326 |
+
pass
|
| 327 |
+
|
| 328 |
+
def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
|
| 329 |
+
type(self).calls += 1
|
| 330 |
+
return {
|
| 331 |
+
"protocol_urgency": "emergency",
|
| 332 |
+
"red_flags": _emergency_chest_pain_rules(),
|
| 333 |
+
"intake_facts": [
|
| 334 |
+
{
|
| 335 |
+
"fact": "Chest pain with shortness of breath reported.",
|
| 336 |
+
"status": "reported",
|
| 337 |
+
"source": "structured_field",
|
| 338 |
+
}
|
| 339 |
+
],
|
| 340 |
+
"candidate_protocol_pathways": [
|
| 341 |
+
{
|
| 342 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 343 |
+
"reason_relevant": "Chest pain with shortness of breath was reported.",
|
| 344 |
+
}
|
| 345 |
+
],
|
| 346 |
+
"missing_info_to_collect": [
|
| 347 |
+
"chest pain description",
|
| 348 |
+
"onset and duration",
|
| 349 |
+
"available vital signs",
|
| 350 |
+
],
|
| 351 |
+
"next_observations_to_collect": [],
|
| 352 |
+
"conflicts_or_uncertainties": [],
|
| 353 |
+
"responder_checklist": ["Escalate per cited local protocol."],
|
| 354 |
+
"do_not_do": ["Do not diagnose."],
|
| 355 |
+
"source_cards": ["CHEST-PAIN-ESCALATION-v1"],
|
| 356 |
+
"handoff_note_sbar": {
|
| 357 |
+
"situation": "Chest pain with shortness of breath.",
|
| 358 |
+
"background": "Mobile clinic adult case.",
|
| 359 |
+
"assessment_observations_only": "Crushing chest pain and shortness of breath reported. HR 118.",
|
| 360 |
+
"handoff_request": "Escalate per protocol.",
|
| 361 |
+
},
|
| 362 |
+
"responder_plain_language_script": "I am going to keep checking observations and follow the local escalation path.",
|
| 363 |
+
"safety_boundary": "This output does not diagnose or prescribe and does not replace local protocol.",
|
| 364 |
+
"selected_required_observation_ids": [
|
| 365 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 366 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 367 |
+
"NOT-A-REAL-TARGET",
|
| 368 |
+
],
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
class FiredCardOmittedFromRetrievalModelClient:
|
| 373 |
+
def __init__(self, *_: Any, **__: Any) -> None:
|
| 374 |
+
pass
|
| 375 |
+
|
| 376 |
+
def generate_json(self, *_: Any, **__: Any) -> dict[str, Any]:
|
| 377 |
+
return {
|
| 378 |
+
"protocol_urgency": "emergency",
|
| 379 |
+
"red_flags": _stroke_rules(),
|
| 380 |
+
"intake_facts": [
|
| 381 |
+
{
|
| 382 |
+
"fact": "Sudden one-sided weakness and trouble speaking.",
|
| 383 |
+
"status": "reported",
|
| 384 |
+
"source": "structured_field",
|
| 385 |
+
}
|
| 386 |
+
],
|
| 387 |
+
"candidate_protocol_pathways": [
|
| 388 |
+
{
|
| 389 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 390 |
+
"reason_relevant": "Safety boundaries are always relevant.",
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 394 |
+
"reason_relevant": "SBAR supports escalation handoff.",
|
| 395 |
+
},
|
| 396 |
+
],
|
| 397 |
+
"missing_info_to_collect": ["blood pressure if available"],
|
| 398 |
+
"next_observations_to_collect": ["speech and one-sided weakness status"],
|
| 399 |
+
"conflicts_or_uncertainties": ["Blood pressure not yet measured."],
|
| 400 |
+
"responder_checklist": ["Keep the stroke red flag visible."],
|
| 401 |
+
"do_not_do": ["Do not diagnose.", "Do not prescribe."],
|
| 402 |
+
"source_cards": ["SAFETY-BOUNDARIES-v1", "REFERRAL-SBAR-v1"],
|
| 403 |
+
"handoff_note_sbar": {
|
| 404 |
+
"situation": "one-sided weakness",
|
| 405 |
+
"background": "Age 56. Not pregnant.",
|
| 406 |
+
"assessment_observations_only": "Sudden one-sided weakness and trouble speaking. Stroke sign red flag fired.",
|
| 407 |
+
"handoff_request": "Request emergency review per cited local protocol cards.",
|
| 408 |
+
},
|
| 409 |
+
"responder_plain_language_script": "I am going to keep the stroke red flag visible and request emergency review.",
|
| 410 |
+
"safety_boundary": "Prototype protocol navigation only; no diagnosis or treatment order.",
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
def _confirmed_chest_pain_intake() -> dict[str, Any]:
|
| 415 |
return {
|
| 416 |
"setting": "mobile clinic",
|
|
|
|
| 460 |
]
|
| 461 |
|
| 462 |
|
| 463 |
+
def _confirmed_stroke_intake() -> dict[str, Any]:
|
| 464 |
+
return {
|
| 465 |
+
"setting": "mobile clinic",
|
| 466 |
+
"patient_age": "56",
|
| 467 |
+
"pregnancy_status": "not_pregnant",
|
| 468 |
+
"chief_concern": "one-sided weakness",
|
| 469 |
+
"symptoms": "Sudden one-sided weakness and trouble speaking",
|
| 470 |
+
"vitals": "blood pressure not yet measured; pulse fast; respirations unlabored",
|
| 471 |
+
"responder_note": "Adult with acute stroke-sign concern.",
|
| 472 |
+
"confirmed": True,
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _stroke_rules() -> list[dict[str, Any]]:
|
| 477 |
+
return [
|
| 478 |
+
{
|
| 479 |
+
"rule_id": "STROKE-001",
|
| 480 |
+
"label": "Stroke sign",
|
| 481 |
+
"urgency": "emergency",
|
| 482 |
+
"evidence": "one-sided weakness",
|
| 483 |
+
"card_id": "STROKE-SIGNS-v1",
|
| 484 |
+
}
|
| 485 |
+
]
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _retrieved_without_stroke_cards() -> list[dict[str, Any]]:
|
| 489 |
+
return [
|
| 490 |
+
{
|
| 491 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 492 |
+
"title": "Safety boundaries",
|
| 493 |
+
"score": 1.0,
|
| 494 |
+
"source": "test",
|
| 495 |
+
"card": {
|
| 496 |
+
"card_id": "SAFETY-BOUNDARIES-v1",
|
| 497 |
+
"title": "Safety boundaries",
|
| 498 |
+
"required_observations": [],
|
| 499 |
+
},
|
| 500 |
+
},
|
| 501 |
+
{
|
| 502 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 503 |
+
"title": "Referral SBAR",
|
| 504 |
+
"score": 0.9,
|
| 505 |
+
"source": "test",
|
| 506 |
+
"card": {
|
| 507 |
+
"card_id": "REFERRAL-SBAR-v1",
|
| 508 |
+
"title": "Referral SBAR",
|
| 509 |
+
"required_observations": [],
|
| 510 |
+
},
|
| 511 |
+
},
|
| 512 |
+
]
|
| 513 |
+
|
| 514 |
+
|
| 515 |
def _confirmed_audio_draft_with_raw_metadata() -> dict[str, Any]:
|
| 516 |
return {
|
| 517 |
"audio_intake_path": "/tmp/uploads/field-case.wav",
|
|
|
|
| 590 |
retrieved_cards=_retrieved_chest_pain_cards(),
|
| 591 |
)
|
| 592 |
|
| 593 |
+
assert RepairingModelClient.calls == 1
|
| 594 |
assert output["protocol_urgency"] == "emergency"
|
| 595 |
assert trace.validator_result["passed"] is True
|
| 596 |
assert trace.model_route["fallback_tier"] == "configured"
|
| 597 |
assert trace.model_route["fallback_reason"] is None
|
| 598 |
+
assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
|
| 599 |
+
assert any("handoff SBAR scaffold applied deterministically" in event for event in trace.events)
|
| 600 |
|
| 601 |
|
| 602 |
def test_run_navigation_retains_valid_model_fields_with_field_provenance(monkeypatch) -> None:
|
|
|
|
| 618 |
assert trace.field_provenance["responder_checklist"] == "model_raw"
|
| 619 |
assert trace.field_provenance["handoff_note_sbar"] == "deterministic_fallback"
|
| 620 |
assert trace.to_dict()["field_provenance"]["responder_checklist"] == "model_raw"
|
| 621 |
+
assert any("handoff SBAR scaffold applied deterministically" in event for event in trace.events)
|
| 622 |
|
| 623 |
|
| 624 |
def test_run_navigation_scrubs_audio_trace_payload(tmp_path: Path) -> None:
|
|
|
|
| 707 |
assert trace.to_dict()["model_route"]["final_route"] == "model_with_deterministic_patches"
|
| 708 |
|
| 709 |
|
| 710 |
+
def test_run_navigation_allows_known_fired_card_when_retrieval_missed_it(monkeypatch) -> None:
|
| 711 |
+
monkeypatch.setattr(navigator, "ModelClient", FiredCardOmittedFromRetrievalModelClient)
|
| 712 |
+
|
| 713 |
+
output, trace = navigator.run_navigation(
|
| 714 |
+
_confirmed_stroke_intake(),
|
| 715 |
+
_stroke_rules(),
|
| 716 |
+
config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
|
| 717 |
+
retrieved_cards=_retrieved_without_stroke_cards(),
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
assert trace.validator_result["passed"] is True
|
| 721 |
+
assert "STROKE-SIGNS-v1" in output["source_cards"]
|
| 722 |
+
assert "STROKE-SIGNS-v1" in {
|
| 723 |
+
pathway["card_id"] for pathway in output["candidate_protocol_pathways"]
|
| 724 |
+
}
|
| 725 |
+
assert trace.field_provenance["source_cards"] == "deterministic_fallback"
|
| 726 |
+
assert trace.field_provenance["candidate_protocol_pathways"] == "deterministic_fallback"
|
| 727 |
+
assert output["harness_evidence"]["deterministic_rule_card_ids"] == ["STROKE-SIGNS-v1"]
|
| 728 |
+
|
| 729 |
+
|
| 730 |
def test_run_navigation_caps_focused_repair_attempts_and_traces_metrics(monkeypatch) -> None:
|
| 731 |
MultiFailureRepairModelClient.calls = 0
|
| 732 |
monkeypatch.setattr(navigator, "ModelClient", MultiFailureRepairModelClient)
|
|
|
|
| 743 |
assert trace.model_route["repair_attempt_cap"] == 2
|
| 744 |
assert trace.model_route["repair_capped"] is True
|
| 745 |
assert trace.model_route["repair_latency_ms"] >= 0
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def test_run_navigation_fills_required_observation_targets_without_counting_as_model_raw(monkeypatch) -> None:
|
| 749 |
+
ObservationThinModelClient.calls = 0
|
| 750 |
+
monkeypatch.setattr(navigator, "ModelClient", ObservationThinModelClient)
|
| 751 |
+
|
| 752 |
+
output, trace = navigator.run_navigation(
|
| 753 |
+
_confirmed_chest_pain_intake(),
|
| 754 |
+
_emergency_chest_pain_rules(),
|
| 755 |
+
config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
|
| 756 |
+
retrieved_cards=_retrieved_chest_pain_cards(),
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
observation_text = json.dumps(
|
| 760 |
+
output["missing_info_to_collect"] + output["next_observations_to_collect"]
|
| 761 |
+
).lower()
|
| 762 |
+
assert ObservationThinModelClient.calls == 1
|
| 763 |
+
assert "chest pain description" in observation_text
|
| 764 |
+
assert "onset and duration" in observation_text
|
| 765 |
+
assert "shortness of breath report" in observation_text
|
| 766 |
+
assert "available vital signs" in observation_text
|
| 767 |
+
assert trace.validator_result["passed"] is True
|
| 768 |
+
assert trace.model_route["field_level_fallback_used"] is True
|
| 769 |
+
assert trace.field_provenance["missing_info_to_collect"] == "deterministic_fallback"
|
| 770 |
+
assert trace.field_provenance["next_observations_to_collect"] == "deterministic_fallback"
|
| 771 |
+
assert trace.model_route["filled_required_observation_ids"] == [
|
| 772 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 773 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 774 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::3",
|
| 775 |
+
]
|
| 776 |
+
assert any("required-observation targets filled" in event for event in trace.events)
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def test_run_navigation_strips_and_traces_selected_required_observation_ids(monkeypatch) -> None:
|
| 780 |
+
SelectedObservationIdsModelClient.calls = 0
|
| 781 |
+
monkeypatch.setattr(navigator, "ModelClient", SelectedObservationIdsModelClient)
|
| 782 |
+
|
| 783 |
+
output, trace = navigator.run_navigation(
|
| 784 |
+
_confirmed_chest_pain_intake(),
|
| 785 |
+
_emergency_chest_pain_rules(),
|
| 786 |
+
config=FigmentConfig(model_backend="hosted_omni", nvidia_api_key="test-nvidia-key"),
|
| 787 |
+
retrieved_cards=_retrieved_chest_pain_cards(),
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
observation_text = json.dumps(
|
| 791 |
+
output["missing_info_to_collect"] + output["next_observations_to_collect"]
|
| 792 |
+
).lower()
|
| 793 |
+
assert SelectedObservationIdsModelClient.calls == 1
|
| 794 |
+
assert "selected_required_observation_ids" not in output
|
| 795 |
+
assert "shortness of breath report" in observation_text
|
| 796 |
+
assert "chest pain description" in observation_text
|
| 797 |
+
assert "onset and duration" in observation_text
|
| 798 |
+
assert trace.validator_result["passed"] is True
|
| 799 |
+
assert trace.model_route["model_selected_required_observation_ids"] == [
|
| 800 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 801 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 802 |
+
]
|
| 803 |
+
assert trace.model_route["invalid_selected_required_observation_ids"] == ["NOT-A-REAL-TARGET"]
|
| 804 |
+
assert trace.model_route["stripped_trace_only_fields"] == ["selected_required_observation_ids"]
|
| 805 |
+
assert trace.model_route["filled_required_observation_ids"] == [
|
| 806 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::3"
|
| 807 |
+
]
|
| 808 |
+
assert trace.field_provenance["missing_info_to_collect"] == "deterministic_fallback"
|
| 809 |
+
assert any("trace-only required-observation target ids stripped" in event for event in trace.events)
|
tests/test_prompt_builder_contract.py
CHANGED
|
@@ -154,6 +154,32 @@ def test_prompt_context_lists_allowed_facts_and_required_observations() -> None:
|
|
| 154 |
"available vital signs",
|
| 155 |
]
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
def test_prompt_context_guides_routine_and_negated_cases() -> None:
|
| 159 |
prompt, _ = build_prompt(
|
|
@@ -172,3 +198,93 @@ def test_prompt_context_guides_routine_and_negated_cases() -> None:
|
|
| 172 |
assert "Do not convert denied or absent symptoms into red_flags" in guidance_text
|
| 173 |
assert "keep protocol_urgency routine" in guidance_text
|
| 174 |
assert "nearby emergency card language" in guidance_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
"available vital signs",
|
| 155 |
]
|
| 156 |
|
| 157 |
+
targets = context["required_observation_targets"]
|
| 158 |
+
chest_targets = [target for target in targets if target["card_id"] == "CHEST-PAIN-ESCALATION-v1"]
|
| 159 |
+
assert chest_targets == [
|
| 160 |
+
{
|
| 161 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 162 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 163 |
+
"title": "Chest pain escalation",
|
| 164 |
+
"display_text": "chest pain description",
|
| 165 |
+
"cue_tokens": ["chest", "pain", "description"],
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 169 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 170 |
+
"title": "Chest pain escalation",
|
| 171 |
+
"display_text": "onset and duration",
|
| 172 |
+
"cue_tokens": ["onset", "duration"],
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::3",
|
| 176 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 177 |
+
"title": "Chest pain escalation",
|
| 178 |
+
"display_text": "available vital signs",
|
| 179 |
+
"cue_tokens": ["available", "vital", "signs"],
|
| 180 |
+
},
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
|
| 184 |
def test_prompt_context_guides_routine_and_negated_cases() -> None:
|
| 185 |
prompt, _ = build_prompt(
|
|
|
|
| 198 |
assert "Do not convert denied or absent symptoms into red_flags" in guidance_text
|
| 199 |
assert "keep protocol_urgency routine" in guidance_text
|
| 200 |
assert "nearby emergency card language" in guidance_text
|
| 201 |
+
|
| 202 |
+
ledger = context["case_fact_ledger"]
|
| 203 |
+
absent_text = json.dumps(ledger["absent_or_denied"], sort_keys=True).lower()
|
| 204 |
+
present_text = json.dumps(ledger["present"], sort_keys=True).lower()
|
| 205 |
+
assert "no chest pain" in absent_text
|
| 206 |
+
assert "no shortness of breath" in absent_text
|
| 207 |
+
assert "routine cough check" in present_text
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_prompt_context_includes_sbar_template_and_internal_target_id_contract() -> None:
|
| 211 |
+
prompt, _ = build_prompt(
|
| 212 |
+
_confirmed_chest_pain_intake(),
|
| 213 |
+
_retrieved_cards(),
|
| 214 |
+
_emergency_chest_pain_rules(),
|
| 215 |
+
"emergency",
|
| 216 |
+
)
|
| 217 |
+
context = _context_from_prompt(prompt)
|
| 218 |
+
|
| 219 |
+
sbar_template = context["handoff_note_sbar_template"]
|
| 220 |
+
assert sbar_template == {
|
| 221 |
+
"situation": "Chest pain",
|
| 222 |
+
"background": "Setting: mobile clinic. Age: 52. Pregnancy status: not_applicable.",
|
| 223 |
+
"assessment_observations_only": "Symptoms: Crushing chest pain and shortness of breath. Vitals: HR 118; blood pressure pending. Red flags: Chest pain escalation cue.",
|
| 224 |
+
"handoff_request": "Request emergency review/escalation per cited local protocol cards.",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
internal_contract = context["internal_generation_contract"]
|
| 228 |
+
assert internal_contract["trace_only_keys"] == ["selected_required_observation_ids"]
|
| 229 |
+
assert internal_contract["required_when_required_observation_targets_selected"] == [
|
| 230 |
+
"selected_required_observation_ids"
|
| 231 |
+
]
|
| 232 |
+
assert internal_contract["strip_before_user_display"] is True
|
| 233 |
+
assert "selected_required_observation_ids" in internal_contract["selected_required_observation_ids"]
|
| 234 |
+
assert "Choose required observation IDs before writing observation text" in prompt
|
| 235 |
+
assert "recognizable responder-facing observation text" in internal_contract["selected_required_observation_ids"]
|
| 236 |
+
assert "selected_required_observation_ids" not in context["navigator_output_schema"]
|
| 237 |
+
assert "selected_required_observation_ids" not in context["required_json_skeleton"]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_prompt_context_names_mandatory_source_cards_and_model_owned_observation_policy() -> None:
|
| 241 |
+
prompt, _ = build_prompt(
|
| 242 |
+
_confirmed_chest_pain_intake(),
|
| 243 |
+
_retrieved_cards(),
|
| 244 |
+
_emergency_chest_pain_rules(),
|
| 245 |
+
"emergency",
|
| 246 |
+
)
|
| 247 |
+
context = _context_from_prompt(prompt)
|
| 248 |
+
|
| 249 |
+
assert context["mandatory_source_card_ids"] == [
|
| 250 |
+
"CHEST-PAIN-ESCALATION-v1",
|
| 251 |
+
"REFERRAL-SBAR-v1",
|
| 252 |
+
]
|
| 253 |
+
assert context["mandatory_required_observation_target_ids"] == [
|
| 254 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 255 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 256 |
+
"CHEST-PAIN-ESCALATION-v1::required_observation::3",
|
| 257 |
+
]
|
| 258 |
+
assert context["mandatory_required_observation_targets"] == [
|
| 259 |
+
{
|
| 260 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::1",
|
| 261 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 262 |
+
"title": "Chest pain escalation",
|
| 263 |
+
"display_text": "chest pain description",
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::2",
|
| 267 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 268 |
+
"title": "Chest pain escalation",
|
| 269 |
+
"display_text": "onset and duration",
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"id": "CHEST-PAIN-ESCALATION-v1::required_observation::3",
|
| 273 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 274 |
+
"title": "Chest pain escalation",
|
| 275 |
+
"display_text": "available vital signs",
|
| 276 |
+
},
|
| 277 |
+
]
|
| 278 |
+
policy = context["required_observation_generation_policy"]
|
| 279 |
+
assert policy["model_owned_not_scaffold_filled"] is True
|
| 280 |
+
assert "mandatory_required_observation_targets" in policy
|
| 281 |
+
assert "missing_info_to_collect" in policy["source_card_scope"]
|
| 282 |
+
assert "mandatory_source_card_id" in policy["source_cards"]
|
| 283 |
+
assert "mandatory_required_observation_target_id" in policy["selected_required_observation_ids"]
|
| 284 |
+
assert "confirm/document" in policy["text_requirement"]
|
| 285 |
+
assert "Emit selected_required_observation_ids as a trace-only key" in prompt
|
| 286 |
+
assert "Include every mandatory_source_card_id in source_cards" in prompt
|
| 287 |
+
assert "retrieved support cards used for safety/SBAR" in prompt
|
| 288 |
+
assert "Do not add SAFETY-BOUNDARIES-v1 or REFERRAL-SBAR-v1 as candidate pathways" in prompt
|
| 289 |
+
assert "make the target display_text visible in missing_info_to_collect" in prompt
|
| 290 |
+
assert "Copy each display_text into missing_info_to_collect" in prompt
|
tests/test_rules.py
CHANGED
|
@@ -59,6 +59,48 @@ def test_pregnancy_danger_sign_still_fires_for_confirmed_pregnancy() -> None:
|
|
| 59 |
assert "PREG-001" in _rule_ids(intake)
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def test_month_based_pediatric_age_counts_as_pediatric() -> None:
|
| 63 |
intake = {
|
| 64 |
"confirmed": True,
|
|
|
|
| 59 |
assert "PREG-001" in _rule_ids(intake)
|
| 60 |
|
| 61 |
|
| 62 |
+
def test_negated_chest_pain_does_not_create_red_flags() -> None:
|
| 63 |
+
for symptoms in (
|
| 64 |
+
"fever with chills during postpartum period, no chest pain reported",
|
| 65 |
+
"cough after dust exposure; no fever, no shortness of breath, no chest pain, speaking normally",
|
| 66 |
+
"brief lightheadedness after standing; denies fainting, chest pain, weakness, vision change, and shortness of breath",
|
| 67 |
+
"fatigue after work, denies chest pain, no chest pressure, no trouble breathing",
|
| 68 |
+
):
|
| 69 |
+
intake = {
|
| 70 |
+
"confirmed": True,
|
| 71 |
+
"setting": "field clinic",
|
| 72 |
+
"patient_age": "43",
|
| 73 |
+
"pregnancy_status": "not_applicable",
|
| 74 |
+
"chief_concern": "routine check",
|
| 75 |
+
"symptoms": symptoms,
|
| 76 |
+
"vitals": "pulse regular",
|
| 77 |
+
"allergies": "",
|
| 78 |
+
"medications": "",
|
| 79 |
+
"available_supplies": "",
|
| 80 |
+
"responder_note": "",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
assert "red_flag_chest_pain" not in _rule_ids(intake)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_positive_chest_pain_still_fires_with_unrelated_negation() -> None:
|
| 87 |
+
intake = {
|
| 88 |
+
"confirmed": True,
|
| 89 |
+
"setting": "field clinic",
|
| 90 |
+
"patient_age": "54",
|
| 91 |
+
"pregnancy_status": "not_applicable",
|
| 92 |
+
"chief_concern": "chest pressure",
|
| 93 |
+
"symptoms": "no fever, but chest pain with sweating for about twenty minutes",
|
| 94 |
+
"vitals": "pulse fast",
|
| 95 |
+
"allergies": "",
|
| 96 |
+
"medications": "",
|
| 97 |
+
"available_supplies": "",
|
| 98 |
+
"responder_note": "",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
assert "red_flag_chest_pain" in _rule_ids(intake)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def test_month_based_pediatric_age_counts_as_pediatric() -> None:
|
| 105 |
intake = {
|
| 106 |
"confirmed": True,
|
tests/test_runtime_honesty.py
CHANGED
|
@@ -182,6 +182,45 @@ def test_trace_and_navigator_ui_show_field_provenance_counts_for_hybrid_output()
|
|
| 182 |
assert "Configured backend" in navigator_html
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
def test_typed_transcript_audio_draft_is_not_labeled_as_real_omni_audio() -> None:
|
| 186 |
app = importlib.import_module("app")
|
| 187 |
config = FigmentConfig(
|
|
|
|
| 182 |
assert "Configured backend" in navigator_html
|
| 183 |
|
| 184 |
|
| 185 |
+
def test_harness_evidence_is_visible_outside_model_authored_text() -> None:
|
| 186 |
+
app = importlib.import_module("app")
|
| 187 |
+
evidence = {
|
| 188 |
+
"confirmed_intake": True,
|
| 189 |
+
"retrieved_card_ids": ["CHEST-PAIN-ESCALATION-v1", "REFERRAL-SBAR-v1"],
|
| 190 |
+
"deterministic_rule_ids": ["CHEST-001"],
|
| 191 |
+
"urgency_floor": "emergency",
|
| 192 |
+
"validator_status": "passed",
|
| 193 |
+
"audio_correction_status": "not_applicable",
|
| 194 |
+
"source_card_ids": ["CHEST-PAIN-ESCALATION-v1"],
|
| 195 |
+
"final_route": "live_model_generated",
|
| 196 |
+
}
|
| 197 |
+
trace = {
|
| 198 |
+
"events": ["validation complete"],
|
| 199 |
+
"model_route": {"model_backend": "llama_cpp", "fallback_tier": "configured"},
|
| 200 |
+
"validator_result": {"passed": True, "failures": []},
|
| 201 |
+
"navigator_output": {"harness_evidence": evidence},
|
| 202 |
+
"raw_audio_stored": False,
|
| 203 |
+
}
|
| 204 |
+
output = {
|
| 205 |
+
"protocol_urgency": "emergency",
|
| 206 |
+
"missing_info_to_collect": ["repeat vitals"],
|
| 207 |
+
"responder_checklist": [],
|
| 208 |
+
"do_not_do": [],
|
| 209 |
+
"source_cards": ["CHEST-PAIN-ESCALATION-v1"],
|
| 210 |
+
"handoff_note_sbar": {},
|
| 211 |
+
"harness_evidence": evidence,
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
navigator_html = app._navigator_summary_html(output, trace)
|
| 215 |
+
trace_html = app._trace_audit_html(trace)
|
| 216 |
+
|
| 217 |
+
assert "Harness Evidence" in navigator_html
|
| 218 |
+
assert "Harness Evidence" in trace_html
|
| 219 |
+
assert "Retrieved cards: 2" in navigator_html
|
| 220 |
+
assert "Validation: passed" in navigator_html
|
| 221 |
+
assert "Harness evidence: visible" in trace_html
|
| 222 |
+
|
| 223 |
+
|
| 224 |
def test_typed_transcript_audio_draft_is_not_labeled_as_real_omni_audio() -> None:
|
| 225 |
app = importlib.import_module("app")
|
| 226 |
config = FigmentConfig(
|
tests/test_validators_strict.py
CHANGED
|
@@ -60,8 +60,20 @@ def _navigator_output(**overrides: Any) -> dict[str, Any]:
|
|
| 60 |
"reason_relevant": "Chest pain with shortness of breath was reported.",
|
| 61 |
}
|
| 62 |
],
|
| 63 |
-
"missing_info_to_collect": [
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
"conflicts_or_uncertainties": [],
|
| 66 |
"responder_checklist": ["Escalate per cited local protocol."],
|
| 67 |
"do_not_do": ["Do not diagnose or prescribe."],
|
|
@@ -130,6 +142,34 @@ def test_strict_validator_rejects_known_but_unretrieved_card_citation() -> None:
|
|
| 130 |
assert any("not in allowed/retrieved card IDs" in failure for failure in result.failures)
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def test_strict_validator_rejects_generic_missing_info_and_hallucinated_sbar_facts() -> None:
|
| 134 |
output = _navigator_output(
|
| 135 |
missing_info_to_collect=["ask anything else that seems relevant"],
|
|
|
|
| 60 |
"reason_relevant": "Chest pain with shortness of breath was reported.",
|
| 61 |
}
|
| 62 |
],
|
| 63 |
+
"missing_info_to_collect": [
|
| 64 |
+
"chest pain description",
|
| 65 |
+
"onset and duration",
|
| 66 |
+
"shortness of breath report",
|
| 67 |
+
"sweating or fainting report",
|
| 68 |
+
"radiation to arm, jaw, back, or shoulder",
|
| 69 |
+
"available vital signs",
|
| 70 |
+
],
|
| 71 |
+
"next_observations_to_collect": [
|
| 72 |
+
"chest pain description",
|
| 73 |
+
"onset and duration",
|
| 74 |
+
"shortness of breath report",
|
| 75 |
+
"available vital signs",
|
| 76 |
+
],
|
| 77 |
"conflicts_or_uncertainties": [],
|
| 78 |
"responder_checklist": ["Escalate per cited local protocol."],
|
| 79 |
"do_not_do": ["Do not diagnose or prescribe."],
|
|
|
|
| 142 |
assert any("not in allowed/retrieved card IDs" in failure for failure in result.failures)
|
| 143 |
|
| 144 |
|
| 145 |
+
def test_strict_validator_allows_known_fired_rule_card_even_when_retrieval_missed_it() -> None:
|
| 146 |
+
output = _navigator_output(
|
| 147 |
+
source_cards=["CHEST-PAIN-ESCALATION-v1"],
|
| 148 |
+
candidate_protocol_pathways=[
|
| 149 |
+
{
|
| 150 |
+
"card_id": "CHEST-PAIN-ESCALATION-v1",
|
| 151 |
+
"reason_relevant": "Chest pain red flag fired deterministically.",
|
| 152 |
+
}
|
| 153 |
+
],
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
result = validate_navigator_output(
|
| 157 |
+
output,
|
| 158 |
+
{
|
| 159 |
+
"CHEST-PAIN-ESCALATION-v1",
|
| 160 |
+
"SAFETY-BOUNDARIES-v1",
|
| 161 |
+
"WOUND-INFECTION-ESCALATION-v1",
|
| 162 |
+
},
|
| 163 |
+
urgency_floor="emergency",
|
| 164 |
+
confirmed_intake=_confirmed_chest_pain_intake(),
|
| 165 |
+
rule_results=[_chest_rule()],
|
| 166 |
+
retrieved_card_ids={"SAFETY-BOUNDARIES-v1"},
|
| 167 |
+
strict_schema=True,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
assert result.passed
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def test_strict_validator_rejects_generic_missing_info_and_hallucinated_sbar_facts() -> None:
|
| 174 |
output = _navigator_output(
|
| 175 |
missing_info_to_collect=["ask anything else that seems relevant"],
|