"""Figment Gradio app scaffold."""
from __future__ import annotations
import html
import json
from pathlib import Path
from typing import Any
from fastapi.responses import HTMLResponse
from figment.audio_intake import confirm_audio_draft as _confirm_audio_draft
from figment.audio_intake import draft_audio_intake as _draft_audio_intake
from figment.config import FigmentConfig, load_config
from figment.model_client import ModelClient, ModelClientError, hosted_audio_limits_text, validate_hosted_audio_file
from figment.navigator import run_navigation
from figment.parakeet_asr import (
ParakeetAsrError,
parakeet_audio_limits_text,
transcribe_audio_with_parakeet,
validate_parakeet_audio_file,
)
from figment.retrieval import load_protocol_cards, query_from_intake, retrieval_source_summary, search_protocol_cards
from figment.rules import evaluate_rules, run_red_flag_checks
from figment.sbar import render_sbar
from figment.trace import normalize_trace_payload, runtime_route_label, stable_hash, write_trace
from figment.ui_theme import FIGMENT_CSS
from figment.validators import urgency_floor_from_rules, validate_audio_ready
try:
import gradio as gr
from gradio.data_classes import FileData
except (ImportError, OSError): # pragma: no cover - lets unit tests import without gradio installed
gr = None
FileData = Any # type: ignore[misc, assignment]
TAB_TITLES = [
"Intake",
"Risk Check",
"Protocol Guidance",
"Navigator Output + Handoff",
"Trace",
]
PROJECT_ROOT = Path(__file__).resolve().parent
DEMO_AUDIO_FILENAMES = (
"case_1_dictated_intake.wav",
"case_2_dictated_intake.wav",
"case_3_dictated_intake.wav",
)
INTAKE_FIELD_KEYS = (
"setting",
"patient_age",
"pregnancy_status",
"chief_concern",
"symptoms",
"vitals",
"allergies",
"medications",
"available_supplies",
"responder_note",
)
DEMO_CASES: dict[str, dict[str, str]] = {
"Disaster clinic: pediatric dehydration": {
"setting": "shelter clinic",
"patient_age": "7",
"pregnancy_status": "not_applicable",
"chief_concern": "vomiting and dehydration concern",
"symptoms": "lethargic, very dry mouth, no urine since morning",
"vitals": "temperature and blood pressure missing",
"allergies": "unknown",
"medications": "none reported",
"available_supplies": "oral rehydration solution, radio, transport team",
"responder_note": "Child after flood cleanup cannot keep fluids down.",
},
"Disaster injury: wound infection": {
"setting": "mobile clinic",
"patient_age": "43",
"pregnancy_status": "not_applicable",
"chief_concern": "wound getting worse",
"symptoms": "spreading redness, swelling, foul drainage",
"vitals": "temperature unknown",
"allergies": "unknown",
"medications": "unknown",
"available_supplies": "clean dressings, radio",
"responder_note": "Cut from debris three days ago.",
},
"Rural clinic: pregnancy danger sign": {
"setting": "rural clinic",
"patient_age": "29",
"pregnancy_status": "pregnant",
"chief_concern": "bleeding and severe headache",
"symptoms": "vaginal bleeding, severe headache, dizziness",
"vitals": "blood pressure not available",
"allergies": "unknown",
"medications": "prenatal vitamin reported",
"available_supplies": "phone, transport contact",
"responder_note": "Patient is pregnant and reports bleeding.",
},
}
def collect_intake(
setting: str,
patient_age: str,
pregnancy_status: str,
chief_concern: str,
symptoms: str,
vitals: str,
allergies: str,
medications: str,
available_supplies: str,
responder_note: str,
) -> dict[str, Any]:
return {
"setting": setting,
"patient_age": patient_age,
"pregnancy_status": pregnancy_status,
"chief_concern": chief_concern,
"symptoms": symptoms,
"vitals": vitals,
"allergies": allergies,
"medications": medications,
"available_supplies": available_supplies,
"responder_note": responder_note,
"confirmed": False,
}
def confirm_intake(intake: dict[str, Any], audio_draft: dict[str, Any] | None = None) -> dict[str, Any]:
audio_validation = validate_audio_ready(audio_draft)
if not audio_validation.passed:
raise ValueError("; ".join(audio_validation.failures))
confirmed = dict(intake)
confirmed["confirmed"] = True
return confirmed
def evaluate_red_flags(intake: dict[str, Any]) -> list[dict[str, Any]]:
if not intake.get("confirmed"):
return []
return [rule.to_dict() for rule in run_red_flag_checks(intake)]
def draft_audio_intake(
transcript: str = "",
config: FigmentConfig | None = None,
audio_file: str | None = None,
provider_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
config = (config or load_config()).validated()
provider_error = None
if audio_file and not transcript.strip() and provider_payload is None and _should_use_hosted_omni_audio(config):
try:
validate_hosted_audio_file(audio_file)
except ModelClientError as exc:
provider_error = f"Hosted Omni audio draft skipped; typed transcript or canned fallback required. {exc}"
else:
try:
provider_payload = ModelClient(config).generate_audio_draft(audio_file)
except ModelClientError as exc:
provider_error = f"Hosted Omni audio draft failed; typed transcript or canned fallback required. {exc}"
elif audio_file and not transcript.strip() and provider_payload is None and _should_use_parakeet_audio(config):
try:
validate_parakeet_audio_file(audio_file)
except ParakeetAsrError as exc:
provider_error = f"Parakeet ASR draft skipped; typed transcript or canned fallback required. {exc}"
else:
try:
provider_payload = transcribe_audio_with_parakeet(audio_file, config=config)
except ParakeetAsrError as exc:
provider_error = f"Parakeet ASR draft failed; typed transcript or canned fallback required. {exc}"
draft = _draft_audio_intake(
transcript=transcript,
config=config,
provider_payload=provider_payload,
audio_file_received=bool(audio_file),
)
if audio_file:
draft["audio_file_received"] = True
draft["audio_filename"] = Path(audio_file).name
draft["raw_audio_stored"] = False
retention_note = (
"Original clip bytes are not written to Figment traces; Gradio may keep upload/session files "
"while the app is running, and committed demo clips stay on disk."
)
if _should_use_hosted_omni_audio(config):
hosted_disclosure = _hosted_audio_disclosure_text()
draft["hosted_audio_disclosure"] = hosted_disclosure
retention_note = f"{retention_note} {hosted_disclosure}"
elif _should_use_parakeet_audio(config):
retention_note = (
f"{retention_note} Parakeet ASR runs on the configured local/ZeroGPU runtime; "
f"limit: {parakeet_audio_limits_text()}."
)
draft["audio_retention_note"] = retention_note
if provider_error and draft.get("audio_intake_path") == "audio_received_needs_transcript_or_model":
draft["processing_status"] = provider_error
return draft
def confirm_audio_draft(
intake: dict[str, Any],
audio_draft: dict[str, Any],
*,
accept: bool = True,
edits: dict[str, str] | None = None,
reject_fields: set[str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
return _confirm_audio_draft(intake, audio_draft, accept=accept, edits=edits, reject_fields=reject_fields)
def run_case(intake: dict[str, Any], config: FigmentConfig | None = None, audio_draft: dict[str, Any] | None = None) -> dict[str, Any]:
confirmed = confirm_intake(intake, audio_draft=audio_draft)
rules = evaluate_red_flags(confirmed)
runtime_config = (config or load_config()).validated()
retrieved_cards = search_protocol_cards(query_from_intake(confirmed))
output, trace = run_navigation(
confirmed,
rules,
audio_draft=audio_draft,
config=runtime_config,
retrieved_cards=retrieved_cards,
)
evaluation = evaluate_rules(confirmed)
trace_payload = normalize_trace_payload(trace.to_dict())
trace_payload["retrieval"] = retrieval_source_summary(retrieved_cards)
return {
"intake": confirmed,
"risk": evaluation,
"retrieved_cards": retrieved_cards,
"navigator_output": output,
"sbar": render_sbar(output, trace.validator_result),
"trace": trace_payload,
}
def trace_download_path(trace: dict[str, Any], config: FigmentConfig | None = None) -> str:
config = (config or load_config()).validated()
trace_id = stable_hash(trace or {})
path = config.trace_dir / f"figment-trace-{trace_id}.json"
return str(write_trace(trace or {}, path))
class _FallbackDemo:
def queue(self) -> "_FallbackDemo":
return self
def launch(self, *args: Any, **kwargs: Any) -> "_FallbackDemo":
return self
def build_app(config: FigmentConfig | None = None):
config = (config or load_config()).validated()
if gr is None:
return _FallbackDemo()
if not hasattr(gr, "Server"):
raise RuntimeError("Figment Server mode requires gradio>=6.0 so gradio.Server is available.")
server = gr.Server(
title="Figment",
summary="Protocol navigator for field clinics and disaster response.",
version="1.0.0",
)
@server.api(name="runtime", concurrency_limit=None)
def runtime_api() -> dict[str, Any]:
return _runtime_payload(config)
@server.api(name="load_demo_case", concurrency_limit=None)
def load_demo_case_api(name: str) -> dict[str, Any]:
fields = _fields_dict_from_values(_load_demo_case(name))
return {
"fields": fields,
"intake": collect_intake(*_field_values(fields)),
"risk": _empty_risk_result(),
"risk_html": _risk_summary_html(_empty_risk_result()),
"guidance_html": _protocol_results_html([]),
"navigator_html": _navigator_summary_html({}),
"trace_audit_html": _trace_audit_html({}),
}
@server.api(name="draft_audio", concurrency_limit=1)
def draft_audio_api(audio_file: FileData | None = None, transcript: str = "") -> dict[str, Any]:
path = _file_data_path(audio_file)
return draft_audio_intake(transcript=transcript or "", config=config, audio_file=path)
@server.api(name="apply_audio_draft", concurrency_limit=None)
def apply_audio_draft_api(fields: dict[str, Any], audio_draft: dict[str, Any] | None = None) -> dict[str, Any]:
values = _field_values(fields)
updated = _apply_audio_draft_ui(*values, audio_draft)
updated_fields = _fields_dict_from_values(updated[: len(INTAKE_FIELD_KEYS)])
return {
"fields": updated_fields,
"audio_draft": updated[-1],
"intake": collect_intake(*_field_values(updated_fields)),
"risk": _empty_risk_result(),
"risk_html": _risk_summary_html(_empty_risk_result()),
"guidance_html": _protocol_results_html([]),
"navigator_html": _navigator_summary_html({}),
"trace_audit_html": _trace_audit_html({}),
}
@server.api(name="confirm_intake", concurrency_limit=None)
def confirm_intake_api(fields: dict[str, Any], audio_draft: dict[str, Any] | None = None) -> dict[str, Any]:
confirmed, intake_state, updated_audio = _confirm_ui_intake(*_field_values(fields), audio_draft)
return {"intake": confirmed, "intake_state": intake_state, "audio_draft": updated_audio}
@server.api(name="risk_check", concurrency_limit=None)
def risk_check_api(intake: dict[str, Any]) -> dict[str, Any]:
risk, summary = _risk_ui_with_summary(intake)
return {"risk": risk, "risk_html": summary}
@server.api(name="retrieve_protocol_cards", concurrency_limit=None)
def retrieve_protocol_cards_api(intake: dict[str, Any]) -> dict[str, Any]:
cards, evidence, summary = _retrieve_with_evidence_and_summary_ui(intake)
return {"cards": cards, "evidence": evidence, "guidance_html": summary}
@server.api(name="run_navigator", concurrency_limit=1)
def run_navigator_api(intake: dict[str, Any], audio_draft: dict[str, Any] | None = None) -> dict[str, Any]:
output, sbar, trace, trace_state, summary, audit = _navigate_ui_with_summary(intake, audio_draft, config=config)
return {
"navigator_output": output,
"sbar": sbar,
"trace": trace,
"trace_state": trace_state,
"navigator_html": summary,
"trace_audit_html": audit,
}
@server.get("/", response_class=HTMLResponse)
async def homepage() -> str:
return _server_homepage_html(config)
@server.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "mode": "gradio.Server"}
return server
def _h(value: Any) -> str:
return html.escape("" if value is None else str(value), quote=True)
def _app_header_html() -> str:
return """
Figment
Offline protocol support for field clinics and disaster response
!For trained responders only. Not a substitute for clinical judgment.
"""
def _statusline_html(config: FigmentConfig) -> str:
audio_chip = "green" if config.enable_audio_intake else "amber"
backend_chip = "blue" if config.model_backend in {"hosted_omni", "hf_zerogpu"} else "amber"
return f"""
Runtime{_h(_model_mode_label(config))}MODEL_STACK={_h(config.model_stack)}MODEL_BACKEND={_h(config.model_backend)}ENABLE_AUDIO_INTAKE={_h('ON' if config.enable_audio_intake else 'OFF')}Privacy: no raw audio retained in traces
Reference checklist for the frozen safety floor. These rules are deterministic.
__RED_FLAG_CHECKLIST__
Rule output
The model cannot lower the deterministic protocol_urgency floor.
Raw deterministic red flags JSON
{}
Protocol card browser
Local protocol cards retrieved from the confirmed intake.
Retrieved protocol cards JSON
[]
Navigator output JSON
Machine-readable protocol navigation output.
{}
Run steps timeline
Audit trail from intake through validation.
Trace JSON
Raw audit object for review and export.
{}
"""
return (
html_doc.replace("__FIGMENT_CSS__", FIGMENT_CSS)
.replace("__FIGMENT_DATA__", _json_for_script(initial_data))
.replace("__RED_FLAG_CHECKLIST__", _red_flag_checklist_html())
)
def _model_mode_label(config: FigmentConfig) -> str:
if config.model_backend == "hosted_omni":
return "Configured backend: hosted_omni"
if config.model_backend == "hf_zerogpu":
return "Configured backend: hf_zerogpu"
if config.model_backend == "llama_cpp":
return "Configured backend: llama_cpp"
return "Configured backend: canned"
def _audio_section_title(config: FigmentConfig) -> str:
if not config.enable_audio_intake or config.audio_backend == "none":
return "Audio draft intake disabled"
if config.audio_backend == "omni_native" and config.model_backend == "hosted_omni":
return "Hosted Omni audio draft"
if config.audio_backend == "parakeet_nemo":
return "Parakeet ASR draft"
if config.audio_backend == "canned":
return "Canned audio demo draft"
return "Audio draft intake"
def _audio_section_subtitle(config: FigmentConfig) -> str:
if not config.enable_audio_intake or config.audio_backend == "none":
return "Typed confirmed intake remains the only active source for rules and navigation."
if config.audio_backend == "omni_native" and config.model_backend == "hosted_omni":
return (
"Record or upload responder dictation for a provisional Omni draft. Audio is sent to the configured "
f"hosted endpoint; use only synthetic or de-identified clips. Limit: {hosted_audio_limits_text()}."
)
if config.audio_backend == "parakeet_nemo":
return "Use configured Parakeet ASR for provisional field suggestions, then confirm fields before rules run."
if config.audio_backend == "canned":
return "Use canned clips only as repeatable demo input, then confirm fields before rules run."
return "Draft suggestions are provisional until the confirmed intake form is reviewed."
def _audio_clip_label(config: FigmentConfig) -> str:
if not config.enable_audio_intake or config.audio_backend == "none":
return "Audio intake disabled"
if config.audio_backend == "parakeet_nemo":
return "Parakeet audio intake"
if config.audio_backend == "canned":
return "Demo audio intake"
return "Hosted Omni audio intake"
def _transcript_label(config: FigmentConfig) -> str:
if not config.enable_audio_intake or config.audio_backend == "none":
return "Typed transcript heuristic disabled"
return "Typed transcript heuristic"
def _audio_runtime_chips_html(config: FigmentConfig) -> str:
if not config.enable_audio_intake or config.audio_backend == "none":
return 'Audio intake disabled'
chips = ['Confirm before rules run']
if config.audio_backend == "omni_native" and config.model_backend == "hosted_omni":
chips.insert(0, 'Hosted Omni audio')
chips.append('Hosted endpoint: synthetic/de-identified only')
elif config.audio_backend == "parakeet_nemo":
chips.insert(0, 'Parakeet ASR')
elif config.audio_backend == "canned":
chips.insert(0, 'Canned demo audio')
else:
chips.insert(0, 'Typed transcript heuristic')
return " ".join(chips)
def _section_header_html(title: str, subtitle: str = "") -> str:
subtitle_html = f'
{_h(subtitle)}
' if subtitle else ""
return f'
{_h(title)}
{subtitle_html}'
def _red_flag_checklist_html() -> str:
categories = {
"Airway / Breathing": [
"Unable to speak full sentences",
"O2 sat below local threshold",
"Stridor or severe wheeze",
"RR very high or very low",
],
"Circulation": [
"SBP below local threshold",
"Cap refill prolonged",
"Active bleeding not controlled",
"Pulse thready or collapsing",
],
"Neurologic": [
"Unresponsive or difficult to arouse",
"New confusion or disorientation",
"Seizure activity",
"Severe headache with danger signs",
],
"Pregnancy": [
"Vaginal bleeding",
"Severe headache or visual changes",
"Convulsions",
"Severe abdominal pain",
],
"Pediatric": [
"Lethargic or not waking",
"Poor feeding or refuses fluids",
"Cap refill prolonged",
"No urine reported",
],
"Infection / Wound": [
"Spreading redness",
"Foul drainage",
"Suspected sepsis cues",
"Rapidly worsening pain",
],
"Chest Pain / Stroke": [
"Crushing or pressure pain",
"Radiates to arm, jaw, or back",
"Face droop or arm weakness",
"Speech difficulty",
],
}
panels = []
for title, items in categories.items():
panels.append(
'
'
f'
{_h(title)}
'
f'
{"".join(f"
{_h(item)}
" for item in items)}
'
"
"
)
return f'
{"".join(panels)}
'
def _risk_ui_with_summary(intake: dict[str, Any]) -> tuple[dict[str, Any], str]:
result = _risk_ui(intake)
return result, _risk_summary_html(result)
def _risk_summary_html(result: dict[str, Any]) -> str:
urgency = str(result.get("protocol_urgency") or "routine").lower()
if urgency not in {"routine", "monitor", "urgent", "emergency"}:
urgency = "routine"
rules = result.get("red_flags") if isinstance(result.get("red_flags"), list) else []
rows = []
for rule in rules:
if not isinstance(rule, dict):
continue
rows.append(
"
"
f"
{_h(rule.get('rule_id'))}
"
f"
{_h(rule.get('evidence'))}
"
f"
{_h(rule.get('card_id'))}
"
f"
{_urgency_chip_html(str(rule.get('urgency') or urgency))}
"
"
"
)
if not rows:
rows.append('
No confirmed intake red flags have fired yet.
')
source_cards = sorted({str(rule.get("card_id")) for rule in rules if isinstance(rule, dict) and rule.get("card_id")})
if not source_cards:
source_cards = ["Run rules after confirming intake"]
return f"""
PROTOCOL_URGENCY
{_h(urgency.upper())}
Deterministic safety floor locked
Rules enforce this minimum. AI cannot lower this floor.
Fired Rules (deterministic)
Rule ID
Evidence
Protocol Card
Urgency Floor
{''.join(rows)}
Validation Messages
Confirmed intake required before rules.
Deterministic rules triggered: {_h(len(rules))}
Schema validation: ready for navigator.
Source Protocol Cards
{''.join(f'{_h(card)} ' for card in source_cards)}
Search and filters are represented by the confirmed intake query in this prototype.
Card ID
Condition
Urgency
Version
{''.join(rows)}
"""
def _protocol_results_html(cards: list[dict[str, Any]]) -> str:
if not cards:
return """
Selected Protocol Card
Confirm intake, then retrieve protocol cards to populate this browser.
"""
first = cards[0]
card = first.get("card") if isinstance(first.get("card"), dict) else first
title = str(card.get("title") or first.get("title") or "Selected protocol card")
card_id = str(card.get("card_id") or first.get("card_id") or "")
rationale_rows = []
for item in cards:
item_card = item.get("card") if isinstance(item.get("card"), dict) else item
rationale_rows.append(
"
"
f"
{_h(item.get('card_id') or item_card.get('card_id'))}