"""Sokrates — Gradio entrypoint. Three-column clinical UI: 1. Live transcript (uploaded audio, live microphone, or a sample dialogue) 2. Clinical intake form filling in (green = filled, grey = missing) 3. Suggested questions for the doctor (missing mandatory fields + follow-ups) The flow is: audio -> ASR -> orchestration loop (structured extraction + gap engine + question generation) -> this UI. Sokrates does NOT make diagnoses; it structures data collection. """ from __future__ import annotations import glob import os import gradio as gr # Work around a gradio_client 1.3.0 bug: building the API schema crashes with # "argument of type 'bool' is not iterable" when a JSON schema contains boolean # values (e.g. additionalProperties: true). That crash makes Gradio's launch # health check think localhost is unreachable and abort. Tolerate bool schemas. import gradio_client.utils as _gcu _orig_json_to_pytype = _gcu._json_schema_to_python_type _orig_get_type = _gcu.get_type def _safe_json_to_pytype(schema, defs=None): if isinstance(schema, bool): return "Any" return _orig_json_to_pytype(schema, defs) def _safe_get_type(schema): if not isinstance(schema, dict): return "Any" return _orig_get_type(schema) _gcu._json_schema_to_python_type = _safe_json_to_pytype _gcu.get_type = _safe_get_type from sokrates import asr from sokrates.orchestrator import process_transcript from sokrates.schema import ( FIELD_META, IntakeForm, coerce_form, format_value, is_empty, ) DATA_DIR = os.path.join(os.path.dirname(__file__), "data") CUSTOM_CSS = """ :root { --sok-primary: #0e7490; --sok-primary-dark: #0b5566; --sok-accent: #2563eb; --sok-green: #16a34a; --sok-red: #dc2626; --sok-bg: #eef2f6; --sok-card: #ffffff; --sok-border: #e2e8f0; --sok-text: #1e293b; --sok-muted: #64748b; } .gradio-container { background: var(--sok-bg) !important; max-width: 1400px !important; } footer { display: none !important; } /* Header banner */ .sok-banner { background: linear-gradient(120deg, #0e7490 0%, #0891b2 50%, #2563eb 100%); border-radius: 16px; padding: 1.1rem 1.4rem; margin-bottom: 0.4rem; color: #fff; box-shadow: 0 6px 20px rgba(8,89,110,0.25); display: flex; align-items: center; gap: 1rem; } .sok-logo { width: 52px; height: 52px; border-radius: 14px; flex: 0 0 auto; background: rgba(255,255,255,0.18); display: flex; align-items: center; justify-content: center; font-size: 1.7rem; } .sok-banner h1 { margin: 0; font-size: 1.7rem; font-weight: 800; letter-spacing: 0.3px; } .sok-banner .sub { opacity: 0.92; font-size: 0.92rem; margin-top: 0.15rem; } .sok-badge { margin-left: auto; background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.3); border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap; } .sok-steps { display: flex; gap: 0.5rem; margin: 0.5rem 0 0.2rem 0; } .sok-step { flex: 1; background: var(--sok-card); border: 1px solid var(--sok-border); border-radius: 10px; padding: 0.45rem 0.7rem; font-size: 0.8rem; color: var(--sok-muted); } .sok-step b { color: var(--sok-primary); } /* Cards */ .panel-card { background: var(--sok-card); border: 1px solid var(--sok-border); border-radius: 16px; padding: 1rem 1.1rem; box-shadow: 0 2px 10px rgba(16,40,70,0.06); min-height: 480px; } .panel-title { font-weight: 700; color: var(--sok-text); margin-bottom: 0.7rem; font-size: 1.02rem; display: flex; align-items: center; gap: 0.5rem; } .panel-title .chip { width: 30px; height: 30px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; background: #ecfeff; color: var(--sok-primary); } /* Progress */ .progress-wrap { margin: 0.1rem 0 0.9rem 0; } .progress-bar { height: 9px; border-radius: 999px; background: #eef2f6; overflow: hidden; } .progress-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #22c55e, #16a34a); transition: width 0.5s ease; } .progress-label { font-size: 0.78rem; color: var(--sok-muted); margin-top: 0.3rem; display: flex; justify-content: space-between; } .progress-label b { color: var(--sok-text); } /* Form rows */ .form-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 0.55rem; border-radius: 10px; margin-bottom: 0.32rem; border: 1px solid transparent; transition: transform 0.12s ease, box-shadow 0.12s ease; } .form-row:hover { transform: translateX(2px); } .form-row .ic { width: 20px; height: 20px; border-radius: 50%; flex: 0 0 auto; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 0.7rem; } .form-row .label { flex: 1; color: var(--sok-text); font-size: 0.9rem; font-weight: 500; } .form-row .value { color: var(--sok-text); font-size: 0.85rem; font-weight: 600; background: #f1f5f9; border-radius: 999px; padding: 0.15rem 0.6rem; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .form-row.filled { background: #f0fdf4; border-color: #dcfce7; } .form-row.filled .ic { background: var(--sok-green); } .form-row.filled .value { background: #dcfce7; color: #166534; } .form-row.missing { background: #f8fafc; } .form-row.missing .ic { background: #cbd5e1; } .form-row.missing.req-missing { background: #fef2f2; border-color: #fee2e2; } .form-row.missing.req-missing .ic { background: var(--sok-red); } .form-row .req { color: var(--sok-red); font-size: 0.7rem; margin-left: 3px; font-weight: 700; } /* Questions */ .q-item { background: #f8fbff; border: 1px solid #e3edfb; border-left: 4px solid var(--sok-accent); border-radius: 10px; padding: 0.65rem 0.8rem; margin-bottom: 0.55rem; color: #1e3a5f; font-size: 0.92rem; display: flex; align-items: flex-start; gap: 0.6rem; box-shadow: 0 1px 4px rgba(37,99,235,0.06); transition: transform 0.12s ease; } .q-item:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(37,99,235,0.12); } .q-num { flex: 0 0 auto; min-width: 1.5rem; height: 1.5rem; line-height: 1.5rem; text-align: center; background: var(--sok-accent); color: #fff; border-radius: 50%; font-size: 0.78rem; font-weight: 700; } .gaps-note { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.8rem; border-radius: 9px; padding: 0.45rem 0.65rem; margin-bottom: 0.7rem; } .gaps-note b { color: #b45309; } .empty-state { color: var(--sok-muted); font-size: 0.88rem; text-align: center; padding: 1.5rem 0.5rem; } .disclaimer { color: #94a3b8; font-size: 0.76rem; margin-top: 0.7rem; font-style: italic; border-top: 1px dashed var(--sok-border); padding-top: 0.5rem; } /* Status pill — always clearly visible */ .sok-status { background: #ecfeff; border: 1px solid #a5f3fc; border-radius: 10px; padding: 0.55rem 0.85rem; margin-top: 0.55rem; } .sok-status p, .sok-status span, .sok-status { margin: 0 !important; color: #0b5566 !important; font-weight: 600 !important; font-size: 0.92rem !important; } /* Transcript box — force readable light styling even in OS dark mode */ .sok-transcript textarea { background: #ffffff !important; color: #1e293b !important; border: 1px solid var(--sok-border) !important; border-radius: 10px !important; font-size: 0.9rem !important; line-height: 1.4 !important; } .sok-transcript textarea::placeholder { color: #94a3b8 !important; } """ def render_form_html(form: IntakeForm) -> str: """Render the intake form: green = filled, grey = optional-missing, red-tinted = mandatory-missing. Includes a mandatory-completeness bar.""" rows = [] filled_mand = total_mand = 0 for meta in FIELD_META: value = getattr(form, meta.name) filled = not is_empty(value) if meta.mandatory: total_mand += 1 filled_mand += int(filled) css = "filled" if filled else "missing" if not filled and meta.mandatory: css += " req-missing" req = '*' if meta.mandatory else "" icon = "✓" if filled else ("!" if meta.mandatory else "") value_html = ( f'{format_value(value)}' if filled else "" ) rows.append( f'
' f'{icon}' f'{meta.label}{req}' f"{value_html}" f"
" ) pct = int(100 * filled_mand / total_mand) if total_mand else 0 progress = ( '
' f'
' f'
Mandatory fields' f"{filled_mand}/{total_mand} complete
" ) return ( '
' '
🩺' "Clinical intake form
" + progress + "".join(rows) + '
* mandatory field. ' "Sokrates structures data collection — it does not make diagnoses.
" "
" ) def render_questions_html(questions: list[str], missing: list[str] | None = None) -> str: """Render the suggested-questions panel with optional missing-field note.""" missing = missing or [] note = "" if missing: note = ( '
📋 Still missing (mandatory): ' + ", ".join(missing) + "
" ) if not questions: items = ( '
💬 No suggested questions yet.
' "Analyze the transcript to generate them.
" ) else: items = "".join( f'
{i}' f"{q}
" for i, q in enumerate(questions, start=1) ) return ( '
' '
💡' "Suggested questions
" + note + items + "
" ) # --- Sample dialogues (synthetic, English) ------------------------------- def list_sample_dialogues() -> dict[str, str]: """Map a friendly label -> file path for each sample dialogue in data/.""" samples: dict[str, str] = {} for path in sorted(glob.glob(os.path.join(DATA_DIR, "sample_dialogue_*.txt"))): label = ( os.path.splitext(os.path.basename(path))[0] .replace("sample_dialogue_", "Sample dialogue ") .replace("_", " ") ) samples[label] = path return samples SAMPLE_DIALOGUES = list_sample_dialogues() def load_sample_dialogue(label: str) -> str: """Return the text of a sample dialogue (used in place of ASR for the demo).""" path = SAMPLE_DIALOGUES.get(label) if not path or not os.path.exists(path): return "" with open(path, "r", encoding="utf-8") as fh: return fh.read().strip() def transcribe_audio(audio_path: str | None) -> str: """Run ASR on an uploaded/recorded audio file and return the transcript.""" if not audio_path: return "" try: return asr.transcribe_file(audio_path) except Exception as exc: # surface ASR/setup errors in the UI, don't crash return f"[ASR error] {exc}" # --- Orchestration (Step 5) ---------------------------------------------- def analyze_transcript(transcript: str, form: IntakeForm): """Run the full loop on the current transcript and repaint all panels. Implemented as a generator so the UI shows instant "Analyzing…" feedback before the (slower) model round-trip completes. Yields tuples of (form_state, form_html, questions_html, status_markdown). """ form = coerce_form(form) # Gradio State may hand back a dict if not transcript or not transcript.strip(): yield ( form, render_form_html(form), render_questions_html([]), "⚠️ Transcript is empty — load a sample or transcribe audio first.", ) return # Immediate feedback while the model runs. yield ( form, render_form_html(form), render_questions_html([]), "⏳ Analyzing transcript with the model… (first call may be slower)", ) try: result = process_transcript(transcript, form) except Exception as exc: # never surface a raw 'Error' badge to the user import traceback traceback.print_exc() # full detail in the terminal yield ( form, render_form_html(form), render_questions_html([]), f"⚠️ Error: {exc}", ) return yield ( result.form, render_form_html(result.form), render_questions_html(result.questions, result.missing_mandatory), "✅ " + result.status, ) def reset_session(): """Clear the form, questions and transcript to start a fresh visit.""" empty = IntakeForm() return ( empty, render_form_html(empty), render_questions_html([]), "", "Session reset.", ) # --- Live microphone: record a segment, transcribe + analyze on stop --- # # Gradio 4.x's streaming-microphone (`gr.Audio(streaming=True)` + `.stream()`) # is unreliable — chunks are often never delivered to the callback, so live # streaming silently captures nothing. We instead record a clip and process it # on `stop_recording`, reusing the same file-transcription path that the # Upload/sample tab already relies on. The user records a segment, pauses, and # Sokrates transcribes it and updates the form; recording again appends more. def record_and_analyze(audio_path: str | None, transcript: str, form: IntakeForm): """Transcribe a recorded mic clip, append it, and run the full loop. Implemented as a generator so the UI shows instant feedback before the (slower) ASR + model round-trip completes. Yields tuples of (transcript, form_html, questions_html, status_markdown, form_state). """ form = coerce_form(form) # Gradio State may hand back a dict if not audio_path: yield ( transcript, render_form_html(form), render_questions_html([]), "🎙️ No audio captured — click record, speak, then stop.", form, ) return yield ( transcript, render_form_html(form), render_questions_html([]), "⏳ Transcribing the recording…", form, ) try: text = asr.transcribe_file(audio_path) except Exception as exc: import traceback traceback.print_exc() yield ( transcript, render_form_html(form), render_questions_html([]), f"⚠️ ASR error: {exc}", form, ) return if not text: yield ( transcript, render_form_html(form), render_questions_html([]), "🎙️ No speech detected in that recording — try again.", form, ) return new_transcript = (transcript + "\n" + text).strip() if transcript else text try: result = process_transcript(new_transcript, form) except Exception as exc: import traceback traceback.print_exc() yield ( new_transcript, render_form_html(form), render_questions_html([]), f"⚠️ Error: {exc}", form, ) return yield ( new_transcript, render_form_html(result.form), render_questions_html(result.questions, result.missing_mandatory), "✅ " + result.status, result.form, ) DEFAULT_TRANSCRIPT = ( "Load a sample dialogue, upload an audio file, or use the microphone, " "then click “Analyze transcript”." ) # Force light mode regardless of the OS/browser theme, so the clinical look is # consistent and every component stays readable (reloads once with __theme=light). FORCE_LIGHT_JS = """ () => { const url = new URL(window.location.href); if (url.searchParams.get('__theme') !== 'light') { url.searchParams.set('__theme', 'light'); window.location.replace(url.href); } } """ def build_demo() -> gr.Blocks: theme = gr.themes.Soft( primary_hue=gr.themes.colors.cyan, secondary_hue=gr.themes.colors.blue, font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], ) with gr.Blocks( css=CUSTOM_CSS, title="Sokrates", theme=theme, js=FORCE_LIGHT_JS ) as demo: gr.HTML( '
' '' "

Sokrates

" '
AI-assisted clinical intake — listens, structures ' "the form, and suggests follow-up questions.
" '
🔒 No diagnoses · data collection only
' "
" '
' '
1 · Listen — transcribe the visit
' '
2 · Structure — fill the intake form
' '
3 · Ask — suggest the next questions
' "
" ) form_state = gr.State(IntakeForm()) with gr.Row(equal_height=False): # --- Column 1: transcript & input --- with gr.Column(scale=1): gr.HTML('
🎙️ Live transcript
') with gr.Tab("Upload / sample"): audio_in = gr.Audio( sources=["upload", "microphone"], type="filepath", label="Upload an audio file (or record)", ) transcribe_btn = gr.Button( "Transcribe audio", variant="secondary" ) with gr.Row(): sample_dd = gr.Dropdown( choices=list(SAMPLE_DIALOGUES.keys()), label="Or load a sample dialogue", scale=3, ) sample_btn = gr.Button("Load sample", scale=1) with gr.Tab("Live microphone"): mic_in = gr.Audio( sources=["microphone"], type="filepath", label="Record the visit — transcribes when you stop", ) gr.Markdown( "Click record and speak; click stop when you pause. " "Sokrates transcribes the clip and updates the form. " "Record again to add more to the same visit." ) transcript_box = gr.Textbox( value="", placeholder=DEFAULT_TRANSCRIPT, lines=12, show_label=False, interactive=True, container=False, elem_classes="sok-transcript", ) with gr.Row(): analyze_btn = gr.Button( "Analyze transcript → update", variant="primary", scale=3 ) reset_btn = gr.Button("Reset", scale=1) status_box = gr.Markdown( "Ready — load a transcript and click **Analyze**.", elem_classes="sok-status", ) # --- Column 2: clinical form --- with gr.Column(scale=1): form_html = gr.HTML(render_form_html(IntakeForm())) # --- Column 3: suggested questions --- with gr.Column(scale=1): questions_html = gr.HTML(render_questions_html([])) # --- Wiring --- transcribe_btn.click( fn=transcribe_audio, inputs=audio_in, outputs=transcript_box ) sample_btn.click( fn=load_sample_dialogue, inputs=sample_dd, outputs=transcript_box ) analyze_btn.click( fn=analyze_transcript, inputs=[transcript_box, form_state], outputs=[form_state, form_html, questions_html, status_box], ) reset_btn.click( fn=reset_session, inputs=None, outputs=[ form_state, form_html, questions_html, transcript_box, status_box, ], ) mic_in.stop_recording( fn=record_and_analyze, inputs=[mic_in, transcript_box, form_state], outputs=[ transcript_box, form_html, questions_html, status_box, form_state, ], ) return demo if __name__ == "__main__": # Ensure localhost isn't routed through an HTTP proxy, which can otherwise # make Gradio's post-launch health check fail ("localhost not accessible"). for _var in ("no_proxy", "NO_PROXY"): os.environ[_var] = "localhost,127.0.0.1,0.0.0.0," + os.environ.get(_var, "") build_demo().launch(server_name="0.0.0.0", server_port=7860)