README.md CHANGED
@@ -1,138 +1,15 @@
1
  ---
2
  title: Sokrates
3
- colorFrom: blue
4
- colorTo: indigo
 
5
  sdk: gradio
6
- sdk_version: 4.44.0
7
- python_version: "3.11"
8
  app_file: app.py
9
  pinned: false
10
  license: mit
11
- short_description: AI-assisted clinical intake assistant
12
  ---
13
 
14
- # Sokrates
15
-
16
- 🔗 **Social Media Post + Video:** https://x.com/guidoputignano/status/2065494688501506353
17
-
18
- AI-assisted **clinical intake** for a medical visit. Sokrates listens to a
19
- doctor–patient conversation, transcribes it, automatically fills in a structured
20
- clinical intake form, and suggests the questions the doctor still needs to ask.
21
-
22
- Two sources of suggested questions:
23
- 1. **Gaps** — mandatory form fields that are still empty (deterministic engine).
24
- 2. **Clinical follow-ups** — coherent next questions generated by the LLM.
25
-
26
- > **Sokrates does not make diagnoses.** It prepares and structures data collection.
27
-
28
- ## Architecture
29
-
30
- ```
31
- audio -> ASR (+ optional diarization) -> orchestration loop ->
32
- [structured extraction + gap engine + question generation] -> three-panel UI
33
- ```
34
-
35
- - **UI**: Gradio Blocks, three columns — live transcript, the intake form filling
36
- in (green = filled, grey = missing), and a suggested-questions panel.
37
- - **ASR**: `faster-whisper` (large-v3), `language="en"`. Uploaded audio file
38
- (priority) and live microphone. Optional diarization via `pyannote` behind a flag.
39
- - **LLM**: any OpenAI-compatible endpoint (e.g. a vLLM server on Modal running
40
- **Qwen3-14B**, ≤ 32B params). Configured purely through environment variables.
41
- - **Schema**: oncology intake form as a Pydantic model (`sokrates/schema.py`).
42
-
43
- ## How to use
44
-
45
- 1. Provide a transcript one of three ways:
46
- - **Sample dialogue** — pick one and click *Load sample* (no audio needed).
47
- - **Upload / record a file** — click *Transcribe audio* to run ASR.
48
- - **Live microphone** — switch to the *Live microphone* tab and speak;
49
- Sokrates transcribes and updates the form automatically on pauses.
50
- 2. Click **Analyze transcript → update** (for the upload/sample paths).
51
- 3. Watch the **clinical form** fill in (green = filled, grey = optional missing,
52
- red-tinted = mandatory still missing) and the **suggested questions** appear
53
- (missing mandatory fields first, then coherent follow-ups). *Reset* clears the
54
- session.
55
-
56
- ## Modules
57
-
58
- | File | Role |
59
- | --------------------------- | ----------------------------------------------------------- |
60
- | `sokrates/schema.py` | Oncology intake form (Pydantic) + field metadata + merge. |
61
- | `sokrates/asr.py` | faster-whisper transcription (file + live mic), diarization.|
62
- | `sokrates/llm.py` | OpenAI-compatible client: extraction + question generation. |
63
- | `sokrates/gaps.py` | Deterministic gap engine (no LLM). |
64
- | `sokrates/orchestrator.py` | The loop: extract → gaps → questions. |
65
- | `app.py` | Gradio three-panel UI. |
66
-
67
- ## Local run
68
-
69
- ```bash
70
- python -m venv .venv && source .venv/bin/activate
71
- pip install -r requirements.txt
72
- python app.py
73
- ```
74
-
75
- Open the local URL Gradio prints (default http://127.0.0.1:7860).
76
-
77
- ## Configuration (environment variables)
78
-
79
- No credentials live in the code. The LLM client is configured at runtime:
80
-
81
- | Variable | Purpose | Example |
82
- | ------------------- | ----------------------------------------------- | ---------------------------------------- |
83
- | `MODEL_BASE_URL` | OpenAI-compatible base URL | `https://your-modal-app.modal.run/v1` |
84
- | `MODEL_API_KEY` | API key / token for the endpoint | `sk-...` (or any token your server wants)|
85
- | `MODEL_NAME` | Model identifier served by the endpoint | `Qwen/Qwen3-14B` |
86
- | `ENABLE_DIARIZATION`| `1` to enable pyannote diarization (optional) | `0` |
87
- | `WHISPER_MODEL` | faster-whisper model size | `large-v3` |
88
- | `HF_TOKEN` | Hugging Face token (only if using pyannote) | `hf_...` |
89
- | `SOKRATES_GUIDED_JSON` | `1` to use vLLM `guided_json` instead of `response_format` | `0` |
90
- | `SOKRATES_NO_THINK` | `1` to disable Qwen3 thinking (faster, terser) | `0` |
91
- | `MODEL_TEMPERATURE` | sampling temperature (extraction uses `0.0`) | `0.0` |
92
-
93
- ### Pointing at a Modal vLLM endpoint
94
-
95
- This repo ships a ready-to-deploy Modal script (`modal_vllm.py`) that serves
96
- **Qwen3-14B** as an OpenAI-compatible vLLM endpoint.
97
-
98
- ```bash
99
- # 1. One-time setup
100
- pip install modal
101
- modal token new
102
- # Choose the API key the endpoint will require (reuse it as MODEL_API_KEY):
103
- modal secret create sokrates-llm MODEL_API_KEY=sk-sokrates-demo-123
104
-
105
- # 2. Deploy (prints a https://...-serve.modal.run URL)
106
- modal deploy modal_vllm.py
107
-
108
- # 3. Point the app at it (note the trailing /v1)
109
- export MODEL_BASE_URL="https://<your-workspace>--sokrates-qwen3-14b-serve.modal.run/v1"
110
- export MODEL_API_KEY="sk-sokrates-demo-123" # must match the secret
111
- export MODEL_NAME="Qwen/Qwen3-14B"
112
- export SOKRATES_GUIDED_JSON=1 # vLLM enforces the JSON schema
113
- python app.py
114
- ```
115
-
116
- Smoke-test the endpoint before launching the app:
117
-
118
- ```bash
119
- curl $MODEL_BASE_URL/models -H "Authorization: Bearer $MODEL_API_KEY"
120
- ```
121
-
122
- No code changes are required to switch endpoints — only these variables.
123
-
124
- ## Deploy to a Hugging Face Space
125
-
126
- 1. Create a new **Gradio** Space.
127
- 2. Push this repo to the Space (or connect the GitHub repo).
128
- 3. In **Settings → Variables and secrets**, add `MODEL_BASE_URL`, `MODEL_API_KEY`,
129
- `MODEL_NAME` (and `HF_TOKEN` if using diarization) as **secrets**.
130
- 4. The Space runs `app.py` automatically.
131
-
132
- ## Sample dialogues
133
-
134
- Synthetic, English-only doctor–patient conversations live in `data/`:
135
- - `data/sample_dialogue_1.txt` — lung mass intake.
136
- - `data/sample_dialogue_2.txt` — breast lump intake.
137
-
138
- No real patient data is used anywhere in this project.
 
1
  ---
2
  title: Sokrates
3
+ emoji: 🐨
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.18.0
8
+ python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: listens, structures the form, suggests follow-up questions
13
  ---
14
 
15
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
__pycache__/modal_vllm.cpython-311.pyc DELETED
Binary file (4.35 kB)
 
app.py DELETED
@@ -1,587 +0,0 @@
1
- """Sokrates — Gradio entrypoint.
2
-
3
- Three-column clinical UI:
4
- 1. Live transcript (uploaded audio, live microphone, or a sample dialogue)
5
- 2. Clinical intake form filling in (green = filled, grey = missing)
6
- 3. Suggested questions for the doctor (missing mandatory fields + follow-ups)
7
-
8
- The flow is: audio -> ASR -> orchestration loop
9
- (structured extraction + gap engine + question generation) -> this UI.
10
- Sokrates does NOT make diagnoses; it structures data collection.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import glob
16
- import os
17
-
18
- import gradio as gr
19
-
20
- # Work around a gradio_client 1.3.0 bug: building the API schema crashes with
21
- # "argument of type 'bool' is not iterable" when a JSON schema contains boolean
22
- # values (e.g. additionalProperties: true). That crash makes Gradio's launch
23
- # health check think localhost is unreachable and abort. Tolerate bool schemas.
24
- import gradio_client.utils as _gcu
25
-
26
- _orig_json_to_pytype = _gcu._json_schema_to_python_type
27
- _orig_get_type = _gcu.get_type
28
-
29
-
30
- def _safe_json_to_pytype(schema, defs=None):
31
- if isinstance(schema, bool):
32
- return "Any"
33
- return _orig_json_to_pytype(schema, defs)
34
-
35
-
36
- def _safe_get_type(schema):
37
- if not isinstance(schema, dict):
38
- return "Any"
39
- return _orig_get_type(schema)
40
-
41
-
42
- _gcu._json_schema_to_python_type = _safe_json_to_pytype
43
- _gcu.get_type = _safe_get_type
44
-
45
- from sokrates import asr
46
- from sokrates.orchestrator import process_transcript
47
- from sokrates.schema import (
48
- FIELD_META,
49
- IntakeForm,
50
- coerce_form,
51
- format_value,
52
- is_empty,
53
- )
54
-
55
- DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
56
-
57
- CUSTOM_CSS = """
58
- :root {
59
- --sok-primary: #0e7490; --sok-primary-dark: #0b5566;
60
- --sok-accent: #2563eb; --sok-green: #16a34a; --sok-red: #dc2626;
61
- --sok-bg: #eef2f6; --sok-card: #ffffff; --sok-border: #e2e8f0;
62
- --sok-text: #1e293b; --sok-muted: #64748b;
63
- }
64
- .gradio-container { background: var(--sok-bg) !important; max-width: 1400px !important; }
65
- footer { display: none !important; }
66
-
67
- /* Header banner */
68
- .sok-banner {
69
- background: linear-gradient(120deg, #0e7490 0%, #0891b2 50%, #2563eb 100%);
70
- border-radius: 16px; padding: 1.1rem 1.4rem; margin-bottom: 0.4rem;
71
- color: #fff; box-shadow: 0 6px 20px rgba(8,89,110,0.25);
72
- display: flex; align-items: center; gap: 1rem;
73
- }
74
- .sok-logo {
75
- width: 52px; height: 52px; border-radius: 14px; flex: 0 0 auto;
76
- background: rgba(255,255,255,0.18); display: flex; align-items: center;
77
- justify-content: center; font-size: 1.7rem;
78
- }
79
- .sok-banner h1 { margin: 0; font-size: 1.7rem; font-weight: 800; letter-spacing: 0.3px; }
80
- .sok-banner .sub { opacity: 0.92; font-size: 0.92rem; margin-top: 0.15rem; }
81
- .sok-badge {
82
- margin-left: auto; background: rgba(255,255,255,0.16);
83
- border: 1px solid rgba(255,255,255,0.3); border-radius: 999px;
84
- padding: 0.35rem 0.85rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap;
85
- }
86
- .sok-steps { display: flex; gap: 0.5rem; margin: 0.5rem 0 0.2rem 0; }
87
- .sok-step {
88
- flex: 1; background: var(--sok-card); border: 1px solid var(--sok-border);
89
- border-radius: 10px; padding: 0.45rem 0.7rem; font-size: 0.8rem; color: var(--sok-muted);
90
- }
91
- .sok-step b { color: var(--sok-primary); }
92
-
93
- /* Cards */
94
- .panel-card {
95
- background: var(--sok-card); border: 1px solid var(--sok-border); border-radius: 16px;
96
- padding: 1rem 1.1rem; box-shadow: 0 2px 10px rgba(16,40,70,0.06); min-height: 480px;
97
- }
98
- .panel-title {
99
- font-weight: 700; color: var(--sok-text); margin-bottom: 0.7rem; font-size: 1.02rem;
100
- display: flex; align-items: center; gap: 0.5rem;
101
- }
102
- .panel-title .chip {
103
- width: 30px; height: 30px; border-radius: 9px; display: inline-flex;
104
- align-items: center; justify-content: center; font-size: 1rem;
105
- background: #ecfeff; color: var(--sok-primary);
106
- }
107
-
108
- /* Progress */
109
- .progress-wrap { margin: 0.1rem 0 0.9rem 0; }
110
- .progress-bar { height: 9px; border-radius: 999px; background: #eef2f6; overflow: hidden; }
111
- .progress-fill {
112
- height: 100%; border-radius: 999px;
113
- background: linear-gradient(90deg, #22c55e, #16a34a); transition: width 0.5s ease;
114
- }
115
- .progress-label {
116
- font-size: 0.78rem; color: var(--sok-muted); margin-top: 0.3rem;
117
- display: flex; justify-content: space-between;
118
- }
119
- .progress-label b { color: var(--sok-text); }
120
-
121
- /* Form rows */
122
- .form-row {
123
- display: flex; align-items: center; gap: 0.6rem;
124
- padding: 0.45rem 0.55rem; border-radius: 10px; margin-bottom: 0.32rem;
125
- border: 1px solid transparent; transition: transform 0.12s ease, box-shadow 0.12s ease;
126
- }
127
- .form-row:hover { transform: translateX(2px); }
128
- .form-row .ic {
129
- width: 20px; height: 20px; border-radius: 50%; flex: 0 0 auto; color: #fff;
130
- display: inline-flex; align-items: center; justify-content: center; font-size: 0.7rem;
131
- }
132
- .form-row .label { flex: 1; color: var(--sok-text); font-size: 0.9rem; font-weight: 500; }
133
- .form-row .value {
134
- color: var(--sok-text); font-size: 0.85rem; font-weight: 600;
135
- background: #f1f5f9; border-radius: 999px; padding: 0.15rem 0.6rem; max-width: 55%;
136
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
137
- }
138
- .form-row.filled { background: #f0fdf4; border-color: #dcfce7; }
139
- .form-row.filled .ic { background: var(--sok-green); }
140
- .form-row.filled .value { background: #dcfce7; color: #166534; }
141
- .form-row.missing { background: #f8fafc; }
142
- .form-row.missing .ic { background: #cbd5e1; }
143
- .form-row.missing.req-missing { background: #fef2f2; border-color: #fee2e2; }
144
- .form-row.missing.req-missing .ic { background: var(--sok-red); }
145
- .form-row .req { color: var(--sok-red); font-size: 0.7rem; margin-left: 3px; font-weight: 700; }
146
-
147
- /* Questions */
148
- .q-item {
149
- background: #f8fbff; border: 1px solid #e3edfb; border-left: 4px solid var(--sok-accent);
150
- border-radius: 10px; padding: 0.65rem 0.8rem; margin-bottom: 0.55rem; color: #1e3a5f;
151
- font-size: 0.92rem; display: flex; align-items: flex-start; gap: 0.6rem;
152
- box-shadow: 0 1px 4px rgba(37,99,235,0.06); transition: transform 0.12s ease;
153
- }
154
- .q-item:hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(37,99,235,0.12); }
155
- .q-num {
156
- flex: 0 0 auto; min-width: 1.5rem; height: 1.5rem; line-height: 1.5rem; text-align: center;
157
- background: var(--sok-accent); color: #fff; border-radius: 50%; font-size: 0.78rem; font-weight: 700;
158
- }
159
- .gaps-note {
160
- background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.8rem;
161
- border-radius: 9px; padding: 0.45rem 0.65rem; margin-bottom: 0.7rem;
162
- }
163
- .gaps-note b { color: #b45309; }
164
- .empty-state { color: var(--sok-muted); font-size: 0.88rem; text-align: center; padding: 1.5rem 0.5rem; }
165
- .disclaimer {
166
- color: #94a3b8; font-size: 0.76rem; margin-top: 0.7rem; font-style: italic;
167
- border-top: 1px dashed var(--sok-border); padding-top: 0.5rem;
168
- }
169
- /* Status pill — always clearly visible */
170
- .sok-status {
171
- background: #ecfeff; border: 1px solid #a5f3fc; border-radius: 10px;
172
- padding: 0.55rem 0.85rem; margin-top: 0.55rem;
173
- }
174
- .sok-status p, .sok-status span, .sok-status {
175
- margin: 0 !important; color: #0b5566 !important;
176
- font-weight: 600 !important; font-size: 0.92rem !important;
177
- }
178
-
179
- /* Transcript box — force readable light styling even in OS dark mode */
180
- .sok-transcript textarea {
181
- background: #ffffff !important; color: #1e293b !important;
182
- border: 1px solid var(--sok-border) !important; border-radius: 10px !important;
183
- font-size: 0.9rem !important; line-height: 1.4 !important;
184
- }
185
- .sok-transcript textarea::placeholder { color: #94a3b8 !important; }
186
- """
187
-
188
-
189
- def render_form_html(form: IntakeForm) -> str:
190
- """Render the intake form: green = filled, grey = optional-missing,
191
- red-tinted = mandatory-missing. Includes a mandatory-completeness bar."""
192
- rows = []
193
- filled_mand = total_mand = 0
194
- for meta in FIELD_META:
195
- value = getattr(form, meta.name)
196
- filled = not is_empty(value)
197
- if meta.mandatory:
198
- total_mand += 1
199
- filled_mand += int(filled)
200
- css = "filled" if filled else "missing"
201
- if not filled and meta.mandatory:
202
- css += " req-missing"
203
- req = '<span class="req">*</span>' if meta.mandatory else ""
204
- icon = "✓" if filled else ("!" if meta.mandatory else "")
205
- value_html = (
206
- f'<span class="value">{format_value(value)}</span>' if filled else ""
207
- )
208
- rows.append(
209
- f'<div class="form-row {css}">'
210
- f'<span class="ic">{icon}</span>'
211
- f'<span class="label">{meta.label}{req}</span>'
212
- f"{value_html}"
213
- f"</div>"
214
- )
215
- pct = int(100 * filled_mand / total_mand) if total_mand else 0
216
- progress = (
217
- '<div class="progress-wrap">'
218
- f'<div class="progress-bar"><div class="progress-fill" '
219
- f'style="width:{pct}%"></div></div>'
220
- f'<div class="progress-label"><span>Mandatory fields</span>'
221
- f"<b>{filled_mand}/{total_mand} complete</b></div></div>"
222
- )
223
- return (
224
- '<div class="panel-card">'
225
- '<div class="panel-title"><span class="chip">🩺</span>'
226
- "Clinical intake form</div>"
227
- + progress
228
- + "".join(rows)
229
- + '<div class="disclaimer">* mandatory field. '
230
- "Sokrates structures data collection — it does not make diagnoses.</div>"
231
- "</div>"
232
- )
233
-
234
-
235
- def render_questions_html(questions: list[str], missing: list[str] | None = None) -> str:
236
- """Render the suggested-questions panel with optional missing-field note."""
237
- missing = missing or []
238
- note = ""
239
- if missing:
240
- note = (
241
- '<div class="gaps-note">📋 <b>Still missing (mandatory):</b> '
242
- + ", ".join(missing)
243
- + "</div>"
244
- )
245
- if not questions:
246
- items = (
247
- '<div class="empty-state">💬 No suggested questions yet.<br>'
248
- "Analyze the transcript to generate them.</div>"
249
- )
250
- else:
251
- items = "".join(
252
- f'<div class="q-item"><span class="q-num">{i}</span>'
253
- f"<span>{q}</span></div>"
254
- for i, q in enumerate(questions, start=1)
255
- )
256
- return (
257
- '<div class="panel-card">'
258
- '<div class="panel-title"><span class="chip">💡</span>'
259
- "Suggested questions</div>"
260
- + note
261
- + items
262
- + "</div>"
263
- )
264
-
265
-
266
- # --- Sample dialogues (synthetic, English) -------------------------------
267
-
268
- def list_sample_dialogues() -> dict[str, str]:
269
- """Map a friendly label -> file path for each sample dialogue in data/."""
270
- samples: dict[str, str] = {}
271
- for path in sorted(glob.glob(os.path.join(DATA_DIR, "sample_dialogue_*.txt"))):
272
- label = (
273
- os.path.splitext(os.path.basename(path))[0]
274
- .replace("sample_dialogue_", "Sample dialogue ")
275
- .replace("_", " ")
276
- )
277
- samples[label] = path
278
- return samples
279
-
280
-
281
- SAMPLE_DIALOGUES = list_sample_dialogues()
282
-
283
-
284
- def load_sample_dialogue(label: str) -> str:
285
- """Return the text of a sample dialogue (used in place of ASR for the demo)."""
286
- path = SAMPLE_DIALOGUES.get(label)
287
- if not path or not os.path.exists(path):
288
- return ""
289
- with open(path, "r", encoding="utf-8") as fh:
290
- return fh.read().strip()
291
-
292
-
293
- def transcribe_audio(audio_path: str | None) -> str:
294
- """Run ASR on an uploaded/recorded audio file and return the transcript."""
295
- if not audio_path:
296
- return ""
297
- try:
298
- return asr.transcribe_file(audio_path)
299
- except Exception as exc: # surface ASR/setup errors in the UI, don't crash
300
- return f"[ASR error] {exc}"
301
-
302
-
303
- # --- Orchestration (Step 5) ----------------------------------------------
304
-
305
- def analyze_transcript(transcript: str, form: IntakeForm):
306
- """Run the full loop on the current transcript and repaint all panels.
307
-
308
- Implemented as a generator so the UI shows instant "Analyzing…" feedback
309
- before the (slower) model round-trip completes. Yields tuples of
310
- (form_state, form_html, questions_html, status_markdown).
311
- """
312
- form = coerce_form(form) # Gradio State may hand back a dict
313
- if not transcript or not transcript.strip():
314
- yield (
315
- form,
316
- render_form_html(form),
317
- render_questions_html([]),
318
- "⚠️ Transcript is empty — load a sample or transcribe audio first.",
319
- )
320
- return
321
- # Immediate feedback while the model runs.
322
- yield (
323
- form,
324
- render_form_html(form),
325
- render_questions_html([]),
326
- "⏳ Analyzing transcript with the model… (first call may be slower)",
327
- )
328
- try:
329
- result = process_transcript(transcript, form)
330
- except Exception as exc: # never surface a raw 'Error' badge to the user
331
- import traceback
332
-
333
- traceback.print_exc() # full detail in the terminal
334
- yield (
335
- form,
336
- render_form_html(form),
337
- render_questions_html([]),
338
- f"⚠️ Error: {exc}",
339
- )
340
- return
341
- yield (
342
- result.form,
343
- render_form_html(result.form),
344
- render_questions_html(result.questions, result.missing_mandatory),
345
- "✅ " + result.status,
346
- )
347
-
348
-
349
- def reset_session():
350
- """Clear the form, questions and transcript to start a fresh visit."""
351
- empty = IntakeForm()
352
- return (
353
- empty,
354
- render_form_html(empty),
355
- render_questions_html([]),
356
- "",
357
- "Session reset.",
358
- )
359
-
360
-
361
- # --- Live microphone: record a segment, transcribe + analyze on stop ---
362
- #
363
- # Gradio 4.x's streaming-microphone (`gr.Audio(streaming=True)` + `.stream()`)
364
- # is unreliable — chunks are often never delivered to the callback, so live
365
- # streaming silently captures nothing. We instead record a clip and process it
366
- # on `stop_recording`, reusing the same file-transcription path that the
367
- # Upload/sample tab already relies on. The user records a segment, pauses, and
368
- # Sokrates transcribes it and updates the form; recording again appends more.
369
-
370
- def record_and_analyze(audio_path: str | None, transcript: str, form: IntakeForm):
371
- """Transcribe a recorded mic clip, append it, and run the full loop.
372
-
373
- Implemented as a generator so the UI shows instant feedback before the
374
- (slower) ASR + model round-trip completes. Yields tuples of
375
- (transcript, form_html, questions_html, status_markdown, form_state).
376
- """
377
- form = coerce_form(form) # Gradio State may hand back a dict
378
- if not audio_path:
379
- yield (
380
- transcript,
381
- render_form_html(form),
382
- render_questions_html([]),
383
- "🎙️ No audio captured — click record, speak, then stop.",
384
- form,
385
- )
386
- return
387
- yield (
388
- transcript,
389
- render_form_html(form),
390
- render_questions_html([]),
391
- "⏳ Transcribing the recording…",
392
- form,
393
- )
394
- try:
395
- text = asr.transcribe_file(audio_path)
396
- except Exception as exc:
397
- import traceback
398
-
399
- traceback.print_exc()
400
- yield (
401
- transcript,
402
- render_form_html(form),
403
- render_questions_html([]),
404
- f"⚠️ ASR error: {exc}",
405
- form,
406
- )
407
- return
408
- if not text:
409
- yield (
410
- transcript,
411
- render_form_html(form),
412
- render_questions_html([]),
413
- "🎙️ No speech detected in that recording — try again.",
414
- form,
415
- )
416
- return
417
- new_transcript = (transcript + "\n" + text).strip() if transcript else text
418
- try:
419
- result = process_transcript(new_transcript, form)
420
- except Exception as exc:
421
- import traceback
422
-
423
- traceback.print_exc()
424
- yield (
425
- new_transcript,
426
- render_form_html(form),
427
- render_questions_html([]),
428
- f"⚠️ Error: {exc}",
429
- form,
430
- )
431
- return
432
- yield (
433
- new_transcript,
434
- render_form_html(result.form),
435
- render_questions_html(result.questions, result.missing_mandatory),
436
- "✅ " + result.status,
437
- result.form,
438
- )
439
-
440
-
441
- DEFAULT_TRANSCRIPT = (
442
- "Load a sample dialogue, upload an audio file, or use the microphone, "
443
- "then click “Analyze transcript”."
444
- )
445
-
446
- # Force light mode regardless of the OS/browser theme, so the clinical look is
447
- # consistent and every component stays readable (reloads once with __theme=light).
448
- FORCE_LIGHT_JS = """
449
- () => {
450
- const url = new URL(window.location.href);
451
- if (url.searchParams.get('__theme') !== 'light') {
452
- url.searchParams.set('__theme', 'light');
453
- window.location.replace(url.href);
454
- }
455
- }
456
- """
457
-
458
-
459
- def build_demo() -> gr.Blocks:
460
- theme = gr.themes.Soft(
461
- primary_hue=gr.themes.colors.cyan,
462
- secondary_hue=gr.themes.colors.blue,
463
- font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
464
- )
465
- with gr.Blocks(
466
- css=CUSTOM_CSS, title="Sokrates", theme=theme, js=FORCE_LIGHT_JS
467
- ) as demo:
468
- gr.HTML(
469
- '<div class="sok-banner">'
470
- '<div class="sok-logo">⚕️</div>'
471
- "<div><h1>Sokrates</h1>"
472
- '<div class="sub">AI-assisted clinical intake — listens, structures '
473
- "the form, and suggests follow-up questions.</div></div>"
474
- '<div class="sok-badge">🔒 No diagnoses · data collection only</div>'
475
- "</div>"
476
- '<div class="sok-steps">'
477
- '<div class="sok-step"><b>1 · Listen</b> — transcribe the visit</div>'
478
- '<div class="sok-step"><b>2 · Structure</b> — fill the intake form</div>'
479
- '<div class="sok-step"><b>3 · Ask</b> — suggest the next questions</div>'
480
- "</div>"
481
- )
482
-
483
- form_state = gr.State(IntakeForm())
484
-
485
- with gr.Row(equal_height=False):
486
- # --- Column 1: transcript & input ---
487
- with gr.Column(scale=1):
488
- gr.HTML('<div class="panel-title">🎙️ Live transcript</div>')
489
- with gr.Tab("Upload / sample"):
490
- audio_in = gr.Audio(
491
- sources=["upload", "microphone"],
492
- type="filepath",
493
- label="Upload an audio file (or record)",
494
- )
495
- transcribe_btn = gr.Button(
496
- "Transcribe audio", variant="secondary"
497
- )
498
- with gr.Row():
499
- sample_dd = gr.Dropdown(
500
- choices=list(SAMPLE_DIALOGUES.keys()),
501
- label="Or load a sample dialogue",
502
- scale=3,
503
- )
504
- sample_btn = gr.Button("Load sample", scale=1)
505
- with gr.Tab("Live microphone"):
506
- mic_in = gr.Audio(
507
- sources=["microphone"],
508
- type="filepath",
509
- label="Record the visit — transcribes when you stop",
510
- )
511
- gr.Markdown(
512
- "Click record and speak; click stop when you pause. "
513
- "Sokrates transcribes the clip and updates the form. "
514
- "Record again to add more to the same visit."
515
- )
516
-
517
- transcript_box = gr.Textbox(
518
- value="",
519
- placeholder=DEFAULT_TRANSCRIPT,
520
- lines=12,
521
- show_label=False,
522
- interactive=True,
523
- container=False,
524
- elem_classes="sok-transcript",
525
- )
526
- with gr.Row():
527
- analyze_btn = gr.Button(
528
- "Analyze transcript → update", variant="primary", scale=3
529
- )
530
- reset_btn = gr.Button("Reset", scale=1)
531
- status_box = gr.Markdown(
532
- "Ready — load a transcript and click **Analyze**.",
533
- elem_classes="sok-status",
534
- )
535
-
536
- # --- Column 2: clinical form ---
537
- with gr.Column(scale=1):
538
- form_html = gr.HTML(render_form_html(IntakeForm()))
539
-
540
- # --- Column 3: suggested questions ---
541
- with gr.Column(scale=1):
542
- questions_html = gr.HTML(render_questions_html([]))
543
-
544
- # --- Wiring ---
545
- transcribe_btn.click(
546
- fn=transcribe_audio, inputs=audio_in, outputs=transcript_box
547
- )
548
- sample_btn.click(
549
- fn=load_sample_dialogue, inputs=sample_dd, outputs=transcript_box
550
- )
551
- analyze_btn.click(
552
- fn=analyze_transcript,
553
- inputs=[transcript_box, form_state],
554
- outputs=[form_state, form_html, questions_html, status_box],
555
- )
556
- reset_btn.click(
557
- fn=reset_session,
558
- inputs=None,
559
- outputs=[
560
- form_state,
561
- form_html,
562
- questions_html,
563
- transcript_box,
564
- status_box,
565
- ],
566
- )
567
- mic_in.stop_recording(
568
- fn=record_and_analyze,
569
- inputs=[mic_in, transcript_box, form_state],
570
- outputs=[
571
- transcript_box,
572
- form_html,
573
- questions_html,
574
- status_box,
575
- form_state,
576
- ],
577
- )
578
-
579
- return demo
580
-
581
-
582
- if __name__ == "__main__":
583
- # Ensure localhost isn't routed through an HTTP proxy, which can otherwise
584
- # make Gradio's post-launch health check fail ("localhost not accessible").
585
- for _var in ("no_proxy", "NO_PROXY"):
586
- os.environ[_var] = "localhost,127.0.0.1,0.0.0.0," + os.environ.get(_var, "")
587
- build_demo().launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/sample_dialogue_1.txt DELETED
@@ -1,19 +0,0 @@
1
- Doctor: Good morning, please have a seat. What brings you in today?
2
- Patient: Good morning, doctor. I've had a persistent cough for about three weeks now, and lately I've been losing weight without trying.
3
- Doctor: I'm sorry to hear that. Can I confirm a few details first — how old are you?
4
- Patient: I'm 62 years old.
5
- Doctor: And you're a man, correct?
6
- Patient: Yes, that's right.
7
- Doctor: Besides the cough and the weight loss, any other symptoms? Shortness of breath, chest pain, coughing up blood?
8
- Patient: I do get a bit short of breath when I climb stairs, and once or twice there was a little blood when I coughed.
9
- Doctor: Thank you. Have you had any imaging done recently, like a chest X-ray or CT scan?
10
- Patient: Yes, my GP ordered a chest CT last week. They said there was a mass in my right lung.
11
- Doctor: I see. Has anyone performed a biopsy of that mass yet?
12
- Patient: No, not yet. They told me I'd need one but it hasn't been scheduled.
13
- Doctor: Understood. Do you have any other medical conditions — diabetes, high blood pressure, heart problems?
14
- Patient: I have high blood pressure, and I was told I have mild type 2 diabetes.
15
- Doctor: Are you currently taking any medications for those?
16
- Patient: Yes, I take metformin for the diabetes and ramipril for the blood pressure.
17
- Doctor: Any allergies to medications that you know of?
18
- Patient: I'm allergic to penicillin — it gives me a rash.
19
- Doctor: That's very helpful, thank you. Let's talk about the next steps for getting that biopsy arranged.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/sample_dialogue_2.txt DELETED
@@ -1,21 +0,0 @@
1
- Doctor: Hello, thanks for coming in today. How can I help you?
2
- Patient: Hi doctor. I found a lump in my left breast about a month ago and it hasn't gone away.
3
- Doctor: Thank you for coming in about it. May I ask your age?
4
- Patient: I'm 47.
5
- Doctor: And have you noticed any other changes — pain, skin changes, nipple discharge?
6
- Patient: The lump isn't really painful, but the skin over it looks a bit dimpled now.
7
- Doctor: How long would you say you've noticed the lump and the skin changes?
8
- Patient: The lump for about four weeks, and the dimpling started maybe two weeks ago.
9
- Doctor: Have you had a mammogram or an ultrasound of the breast?
10
- Patient: Yes, I had a mammogram and an ultrasound last week. They saw a suspicious area and recommended a biopsy.
11
- Doctor: Did you go ahead with the biopsy?
12
- Patient: Yes, I had a core needle biopsy two days ago. I'm waiting for the results.
13
- Doctor: Thank you. Do you know if the cancer has been staged yet?
14
- Patient: No, nothing has been confirmed yet. We're still waiting on the biopsy.
15
- Doctor: Are you currently taking any medications or undergoing any treatments?
16
- Patient: I take levothyroxine for an underactive thyroid, nothing else.
17
- Doctor: Any other medical conditions I should know about?
18
- Patient: Just the thyroid issue — hypothyroidism. Otherwise I'm generally healthy.
19
- Doctor: And any allergies to medications?
20
- Patient: No known allergies.
21
- Doctor: Thank you, that's all very useful. Once the biopsy results are back, we'll discuss the findings and plan from there.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modal_vllm.py DELETED
@@ -1,118 +0,0 @@
1
- """Serve Qwen3-14B on Modal as an OpenAI-compatible vLLM endpoint.
2
-
3
- This stands up exactly the kind of endpoint Sokrates' `MODEL_BASE_URL` expects:
4
- an OpenAI-compatible `/v1` server (chat completions + structured output) backed
5
- by vLLM, running Qwen3-14B (<= 32B params) on a Modal GPU.
6
-
7
- --------------------------------------------------------------------------------
8
- One-time setup
9
- --------------------------------------------------------------------------------
10
- pip install modal
11
- modal token new # authenticate the CLI
12
-
13
- # Pick the API key the endpoint will require, and store it as a secret.
14
- # Use the SAME value for the app's MODEL_API_KEY.
15
- modal secret create sokrates-llm MODEL_API_KEY=sk-sokrates-demo-123
16
-
17
- --------------------------------------------------------------------------------
18
- Deploy
19
- --------------------------------------------------------------------------------
20
- modal deploy modal_vllm.py
21
-
22
- Modal prints a URL like:
23
- https://<your-workspace>--sokrates-qwen3-14b-serve.modal.run
24
-
25
- Point the Gradio app at it (note the trailing /v1):
26
- export MODEL_BASE_URL="https://<...>-serve.modal.run/v1"
27
- export MODEL_API_KEY="sk-sokrates-demo-123" # must match the secret
28
- export MODEL_NAME="Qwen/Qwen3-14B"
29
- # vLLM is happiest enforcing the schema via guided_json:
30
- export SOKRATES_GUIDED_JSON=1
31
- python app.py
32
-
33
- --------------------------------------------------------------------------------
34
- Quick smoke test (after deploy)
35
- --------------------------------------------------------------------------------
36
- curl $MODEL_BASE_URL/models -H "Authorization: Bearer $MODEL_API_KEY"
37
- """
38
-
39
- import os
40
- import subprocess
41
-
42
- import modal
43
-
44
- # --- Configuration -----------------------------------------------------------
45
-
46
- MODEL_NAME = "Qwen/Qwen3-14B" # <= 32B params, as required
47
- # A100-80GB comfortably fits the 14B weights (~28 GB bf16) plus KV cache.
48
- # Use "H100" for more headroom/speed, or "L40S" (48 GB) to cut cost.
49
- GPU_CONFIG = os.environ.get("SOKRATES_MODAL_GPU", "A100-80GB")
50
- VLLM_PORT = 8000
51
- MAX_MODEL_LEN = 16384
52
-
53
- # vLLM build with Qwen3 support. Pin to keep deploys reproducible.
54
- vllm_image = (
55
- modal.Image.debian_slim(python_version="3.12")
56
- .pip_install(
57
- "vllm==0.9.1",
58
- # Pin transformers: vllm 0.9.1 conflicts with transformers >= 4.54
59
- # ("'aimv2' is already used by a Transformers config" crash on startup).
60
- "transformers==4.52.4",
61
- # Let vLLM pull a compatible huggingface_hub (>=0.32); just add the
62
- # hf_transfer extra for fast weight downloads.
63
- "hf_transfer",
64
- )
65
- # Faster weight downloads from the Hugging Face Hub.
66
- .env({"HF_HUB_ENABLE_HF_TRANSFER": "1", "VLLM_USE_V1": "1"})
67
- )
68
-
69
- app = modal.App("sokrates-qwen3-14b")
70
-
71
- # Persist downloaded weights across cold starts so we only pull them once.
72
- hf_cache = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
73
- vllm_cache = modal.Volume.from_name("vllm-cache", create_if_missing=True)
74
-
75
-
76
- @app.function(
77
- image=vllm_image,
78
- gpu=GPU_CONFIG,
79
- # The secret provides MODEL_API_KEY, which vLLM enforces on every request.
80
- secrets=[modal.Secret.from_name("sokrates-llm")],
81
- volumes={
82
- "/root/.cache/huggingface": hf_cache,
83
- "/root/.cache/vllm": vllm_cache,
84
- },
85
- timeout=60 * 60, # allow long-running server invocations
86
- scaledown_window=15 * 60, # keep warm 15 min after the last request
87
- # During a live demo set SOKRATES_KEEP_WARM=1 before `modal deploy` to keep
88
- # one replica always running (no cold starts). Costs GPU while idle, so set
89
- # it back to 0 afterwards.
90
- min_containers=int(os.environ.get("SOKRATES_KEEP_WARM", "0")),
91
- )
92
- @modal.concurrent(max_inputs=100) # one replica handles many concurrent requests
93
- @modal.web_server(port=VLLM_PORT, startup_timeout=15 * 60)
94
- def serve():
95
- """Launch the vLLM OpenAI-compatible API server."""
96
- api_key = os.environ["MODEL_API_KEY"]
97
- cmd = [
98
- "vllm",
99
- "serve",
100
- MODEL_NAME,
101
- "--host",
102
- "0.0.0.0",
103
- "--port",
104
- str(VLLM_PORT),
105
- "--api-key",
106
- api_key,
107
- "--served-model-name",
108
- MODEL_NAME,
109
- "--max-model-len",
110
- str(MAX_MODEL_LEN),
111
- "--gpu-memory-utilization",
112
- "0.92",
113
- # Structured-output backend used by response_format / guided_json.
114
- "--guided-decoding-backend",
115
- "xgrammar",
116
- ]
117
- # Run vLLM in the foreground process group of this container.
118
- subprocess.Popen(" ".join(cmd), shell=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,19 +0,0 @@
1
- # --- Core UI ---
2
- gradio>=4.44,<5
3
- # Gradio 4.x imports HfFolder, removed in huggingface_hub >= 0.26
4
- huggingface_hub>=0.23,<0.26
5
- # Gradio 4.44's templating breaks on fastapi/starlette >= 1.x; pin known-good.
6
- fastapi==0.114.2
7
- starlette==0.38.6
8
-
9
- # --- Schema / validation ---
10
- pydantic>=2.6
11
-
12
- # --- LLM client (OpenAI-compatible: vLLM, Modal, etc.) ---
13
- openai>=1.30
14
-
15
- # --- ASR (added/used from Step 2 onward) ---
16
- faster-whisper>=1.0.3
17
-
18
- # --- Optional diarization (Step 2, behind ENABLE_DIARIZATION flag) ---
19
- # pyannote.audio>=3.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
run.sh DELETED
@@ -1,24 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Launch Sokrates with environment variables loaded from a local .env file.
3
- #
4
- # Usage:
5
- # cp .env.example .env # once: fill in your endpoint + key
6
- # source <your-venv>/bin/activate
7
- # ./run.sh # starts the Gradio app
8
- # ./run.sh test # runs the endpoint diagnostic instead
9
- set -euo pipefail
10
- cd "$(dirname "$0")"
11
-
12
- if [ -f .env ]; then
13
- set -a # export everything we source
14
- . ./.env
15
- set +a
16
- else
17
- echo "No .env found. Run: cp .env.example .env (then edit it)"; exit 1
18
- fi
19
-
20
- if [ "${1:-}" = "test" ]; then
21
- exec python test_endpoint.py
22
- else
23
- exec python app.py
24
- fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sokrates/__init__.py DELETED
@@ -1,8 +0,0 @@
1
- """Sokrates: AI-assisted clinical intake during a medical visit.
2
-
3
- Sokrates listens to a doctor-patient conversation, transcribes it, fills in a
4
- structured clinical intake form, and suggests follow-up questions. It does NOT
5
- make diagnoses: it only prepares and structures data collection.
6
- """
7
-
8
- __version__ = "0.1.0"
 
 
 
 
 
 
 
 
 
sokrates/__pycache__/__init__.cpython-311.pyc DELETED
Binary file (506 Bytes)
 
sokrates/__pycache__/asr.cpython-311.pyc DELETED
Binary file (10.3 kB)
 
sokrates/__pycache__/gaps.cpython-311.pyc DELETED
Binary file (3.68 kB)
 
sokrates/__pycache__/llm.cpython-311.pyc DELETED
Binary file (15.9 kB)
 
sokrates/__pycache__/orchestrator.cpython-311.pyc DELETED
Binary file (3.84 kB)
 
sokrates/__pycache__/schema.cpython-311.pyc DELETED
Binary file (7.63 kB)
 
sokrates/asr.py DELETED
@@ -1,186 +0,0 @@
1
- """Automatic speech recognition for Sokrates.
2
-
3
- Transcribes an uploaded audio file (priority for the demo video) or a live
4
- microphone recording using ``faster-whisper`` (large-v3, English).
5
-
6
- Design notes:
7
- - The Whisper model is heavy, so it is loaded lazily and cached as a module
8
- singleton. Importing this module does NOT load the model, so ``app.py`` can
9
- start instantly and ASR dependencies stay optional until first use.
10
- - Diarization (who is speaking) is OPTIONAL, enabled with ENABLE_DIARIZATION=1
11
- and a valid HF_TOKEN. If pyannote is unavailable or fails, we transcribe
12
- without speaker labels instead of crashing.
13
-
14
- Configuration via environment variables:
15
- WHISPER_MODEL faster-whisper model size (default "large-v3")
16
- WHISPER_DEVICE "cpu", "cuda", or "auto" (default "auto")
17
- WHISPER_COMPUTE compute type, e.g. "int8", "float16" (default "auto")
18
- ENABLE_DIARIZATION "1" to enable pyannote diarization (default off)
19
- HF_TOKEN Hugging Face token, required for pyannote
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import os
25
- from dataclasses import dataclass
26
- from typing import List, Optional
27
-
28
- def _language():
29
- """Transcription language from WHISPER_LANGUAGE (default 'en').
30
-
31
- Set WHISPER_LANGUAGE=it for Italian, or 'auto' to let Whisper detect it.
32
- """
33
- lang = os.getenv("WHISPER_LANGUAGE", "en").strip().lower()
34
- return None if lang in ("auto", "detect", "") else lang
35
-
36
- _model = None # cached faster-whisper WhisperModel
37
- _diarizer = None # cached pyannote pipeline (or False if unavailable)
38
-
39
-
40
- @dataclass
41
- class TranscriptSegment:
42
- """A single transcribed chunk with timing and optional speaker label."""
43
-
44
- start: float
45
- end: float
46
- text: str
47
- speaker: Optional[str] = None
48
-
49
- def render(self) -> str:
50
- prefix = f"{self.speaker}: " if self.speaker else ""
51
- return f"{prefix}{self.text.strip()}"
52
-
53
-
54
- def _resolve_device() -> tuple[str, str]:
55
- """Pick (device, compute_type), honoring env overrides and GPU availability."""
56
- device = os.getenv("WHISPER_DEVICE", "auto")
57
- compute = os.getenv("WHISPER_COMPUTE", "auto")
58
- if device == "auto":
59
- try:
60
- import torch # noqa: WPS433 (optional dependency)
61
-
62
- device = "cuda" if torch.cuda.is_available() else "cpu"
63
- except Exception:
64
- device = "cpu"
65
- if compute == "auto":
66
- compute = "float16" if device == "cuda" else "int8"
67
- return device, compute
68
-
69
-
70
- def get_model():
71
- """Lazily build and cache the faster-whisper model."""
72
- global _model
73
- if _model is None:
74
- from faster_whisper import WhisperModel # local import: optional dep
75
-
76
- model_size = os.getenv("WHISPER_MODEL", "large-v3")
77
- device, compute = _resolve_device()
78
- _model = WhisperModel(model_size, device=device, compute_type=compute)
79
- return _model
80
-
81
-
82
- def diarization_enabled() -> bool:
83
- return os.getenv("ENABLE_DIARIZATION", "0") == "1"
84
-
85
-
86
- def _get_diarizer():
87
- """Lazily load the pyannote diarization pipeline, or None if unavailable."""
88
- global _diarizer
89
- if _diarizer is not None:
90
- return _diarizer or None
91
- token = os.getenv("HF_TOKEN")
92
- if not token:
93
- _diarizer = False
94
- return None
95
- try:
96
- from pyannote.audio import Pipeline # local import: optional dep
97
-
98
- _diarizer = Pipeline.from_pretrained(
99
- "pyannote/speaker-diarization-3.1", use_auth_token=token
100
- )
101
- except Exception:
102
- # pyannote missing, model not accessible, or token invalid -> skip.
103
- _diarizer = False
104
- return _diarizer or None
105
-
106
-
107
- def _assign_speakers(
108
- segments: List[TranscriptSegment], audio_path: str
109
- ) -> List[TranscriptSegment]:
110
- """Best-effort speaker labelling. Returns segments unchanged on any failure."""
111
- diarizer = _get_diarizer()
112
- if diarizer is None:
113
- return segments
114
- try:
115
- diarization = diarizer(audio_path)
116
- for seg in segments:
117
- mid = (seg.start + seg.end) / 2.0
118
- for turn, _, speaker in diarization.itertracks(yield_label=True):
119
- if turn.start <= mid <= turn.end:
120
- seg.speaker = speaker.replace("SPEAKER_", "Speaker ")
121
- break
122
- except Exception:
123
- return segments
124
- return segments
125
-
126
-
127
- def transcribe_segments(audio_path: str) -> List[TranscriptSegment]:
128
- """Transcribe an audio file into timed segments (with optional speakers)."""
129
- model = get_model()
130
- segments_iter, _info = model.transcribe(
131
- audio_path,
132
- language=_language(),
133
- vad_filter=True,
134
- )
135
- segments = [
136
- TranscriptSegment(start=s.start, end=s.end, text=s.text)
137
- for s in segments_iter
138
- ]
139
- if diarization_enabled():
140
- segments = _assign_speakers(segments, audio_path)
141
- return segments
142
-
143
-
144
- def transcribe_file(audio_path: str) -> str:
145
- """Transcribe an audio file and return a plain-text transcript."""
146
- if not audio_path:
147
- return ""
148
- segments = transcribe_segments(audio_path)
149
- return "\n".join(seg.render() for seg in segments).strip()
150
-
151
-
152
- SAMPLE_RATE = 16000
153
-
154
-
155
- def _to_mono_float32_16k(sample_rate: int, samples) -> "object":
156
- """Convert an arbitrary audio chunk to mono float32 at 16 kHz for Whisper."""
157
- import numpy as np
158
-
159
- audio = np.asarray(samples)
160
- if audio.ndim > 1: # stereo -> mono
161
- audio = audio.mean(axis=1)
162
- if np.issubdtype(audio.dtype, np.integer): # int16 -> [-1, 1] float
163
- audio = audio.astype(np.float32) / np.iinfo(audio.dtype).max
164
- else:
165
- audio = audio.astype(np.float32)
166
- if sample_rate != SAMPLE_RATE and audio.size > 0: # linear resample to 16k
167
- duration = audio.shape[0] / float(sample_rate)
168
- target_len = int(round(duration * SAMPLE_RATE))
169
- if target_len > 0:
170
- src_idx = np.linspace(0, audio.shape[0] - 1, num=target_len)
171
- audio = np.interp(src_idx, np.arange(audio.shape[0]), audio).astype(
172
- np.float32
173
- )
174
- return audio
175
-
176
-
177
- def transcribe_array(sample_rate: int, samples) -> str:
178
- """Transcribe an in-memory audio buffer (used by the live-microphone path)."""
179
- audio = _to_mono_float32_16k(sample_rate, samples)
180
- if getattr(audio, "size", 0) == 0:
181
- return ""
182
- model = get_model()
183
- segments_iter, _info = model.transcribe(
184
- audio, language=_language(), vad_filter=True
185
- )
186
- return " ".join(s.text.strip() for s in segments_iter).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sokrates/gaps.py DELETED
@@ -1,56 +0,0 @@
1
- """Gap engine: a deterministic (no-LLM) view of what the form still needs.
2
-
3
- Given the current intake form, this module reports which mandatory fields are
4
- still empty. The UI surfaces these directly, and the question generator uses
5
- them to prioritize what the doctor should ask next.
6
-
7
- This is intentionally pure Python with no model calls, so the "what is missing"
8
- signal is fully deterministic and auditable.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from dataclasses import dataclass
14
- from typing import List
15
-
16
- from sokrates.schema import FIELD_META, IntakeForm, is_empty
17
-
18
-
19
- @dataclass(frozen=True)
20
- class Gap:
21
- """A single missing field."""
22
-
23
- name: str
24
- label: str
25
- mandatory: bool
26
-
27
-
28
- def find_gaps(form: IntakeForm, mandatory_only: bool = True) -> List[Gap]:
29
- """Return the fields that are still empty.
30
-
31
- With ``mandatory_only=True`` (default) only required fields are returned,
32
- which is what drives the "questions still to ask" priority.
33
- """
34
- gaps: List[Gap] = []
35
- for meta in FIELD_META:
36
- if mandatory_only and not meta.mandatory:
37
- continue
38
- if is_empty(getattr(form, meta.name)):
39
- gaps.append(Gap(meta.name, meta.label, meta.mandatory))
40
- return gaps
41
-
42
-
43
- def missing_mandatory_labels(form: IntakeForm) -> List[str]:
44
- """Convenience: human-readable labels of the missing mandatory fields."""
45
- return [g.label for g in find_gaps(form, mandatory_only=True)]
46
-
47
-
48
- def completeness(form: IntakeForm) -> tuple[int, int]:
49
- """Return (filled_mandatory, total_mandatory) for a progress indicator."""
50
- total = sum(1 for m in FIELD_META if m.mandatory)
51
- filled = sum(
52
- 1
53
- for m in FIELD_META
54
- if m.mandatory and not is_empty(getattr(form, m.name))
55
- )
56
- return filled, total
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sokrates/llm.py DELETED
@@ -1,336 +0,0 @@
1
- """LLM access: OpenAI-compatible client + structured extraction.
2
-
3
- The model runs behind any OpenAI-compatible endpoint (e.g. a vLLM server on
4
- Modal serving Qwen3-14B). Nothing about the provider is hardcoded — base URL,
5
- API key and model name come from environment variables, so the same code can
6
- point at vLLM, OpenAI, or any compatible server without edits.
7
-
8
- Environment variables:
9
- MODEL_BASE_URL OpenAI-compatible base URL (e.g. https://.../v1)
10
- MODEL_API_KEY API key / token for the endpoint
11
- MODEL_NAME model identifier served by the endpoint (e.g. Qwen/Qwen3-14B)
12
-
13
- Optional:
14
- SOKRATES_GUIDED_JSON "1" to send the schema via vLLM's `guided_json`
15
- (extra_body) instead of OpenAI `response_format`.
16
- SOKRATES_NO_THINK "1" to disable Qwen3 thinking via chat_template_kwargs.
17
- MODEL_TEMPERATURE sampling temperature (default 0.0 for extraction).
18
-
19
- This module only handles structured extraction (Step 3). Question generation is
20
- added in Step 4.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import json
26
- import os
27
- import re
28
- from typing import Any, Optional
29
-
30
- from sokrates.schema import IntakeForm, merge_forms
31
-
32
- _client = None
33
-
34
-
35
- class LLMConfigError(RuntimeError):
36
- """Raised when the LLM endpoint is not configured via env vars."""
37
-
38
-
39
- def get_config() -> tuple[str, str, str]:
40
- """Return (base_url, api_key, model_name) from the environment."""
41
- base_url = os.getenv("MODEL_BASE_URL")
42
- api_key = os.getenv("MODEL_API_KEY")
43
- model_name = os.getenv("MODEL_NAME")
44
- missing = [
45
- name
46
- for name, val in (
47
- ("MODEL_BASE_URL", base_url),
48
- ("MODEL_API_KEY", api_key),
49
- ("MODEL_NAME", model_name),
50
- )
51
- if not val
52
- ]
53
- if missing:
54
- raise LLMConfigError(
55
- "Missing environment variable(s): "
56
- + ", ".join(missing)
57
- + ". Set MODEL_BASE_URL, MODEL_API_KEY and MODEL_NAME to point at "
58
- "your vLLM/OpenAI-compatible endpoint."
59
- )
60
- return base_url, api_key, model_name # type: ignore[return-value]
61
-
62
-
63
- def get_client():
64
- """Lazily build and cache the OpenAI-compatible client."""
65
- global _client
66
- if _client is None:
67
- from openai import OpenAI # local import: optional dep
68
-
69
- base_url, api_key, _ = get_config()
70
- # Bound the wait so a stuck request surfaces as an error instead of
71
- # hanging the UI. Generous default to tolerate a cold start.
72
- timeout = float(os.getenv("MODEL_TIMEOUT", "180"))
73
- _client = OpenAI(
74
- base_url=base_url, api_key=api_key, timeout=timeout, max_retries=1
75
- )
76
- return _client
77
-
78
-
79
- def _temperature() -> float:
80
- try:
81
- return float(os.getenv("MODEL_TEMPERATURE", "0.0"))
82
- except ValueError:
83
- return 0.0
84
-
85
-
86
- def _extra_body(schema: Optional[dict] = None) -> dict[str, Any]:
87
- """Assemble vLLM-specific extra_body options based on env flags."""
88
- body: dict[str, Any] = {}
89
- if schema is not None and os.getenv("SOKRATES_GUIDED_JSON", "0") == "1":
90
- body["guided_json"] = schema
91
- if os.getenv("SOKRATES_NO_THINK", "0") == "1":
92
- body["chat_template_kwargs"] = {"enable_thinking": False}
93
- return body
94
-
95
-
96
- def _parse_json_object(content: str) -> dict:
97
- """Parse a JSON object from model output, tolerating stray text/markdown."""
98
- content = content.strip()
99
- # Strip Qwen3-style <think>...</think> blocks if present.
100
- content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
101
- try:
102
- return json.loads(content)
103
- except json.JSONDecodeError:
104
- pass
105
- # Fall back to the first balanced {...} block.
106
- start = content.find("{")
107
- if start != -1:
108
- depth = 0
109
- for i in range(start, len(content)):
110
- if content[i] == "{":
111
- depth += 1
112
- elif content[i] == "}":
113
- depth -= 1
114
- if depth == 0:
115
- return json.loads(content[start : i + 1])
116
- raise ValueError(f"Could not parse JSON from model output: {content[:200]!r}")
117
-
118
-
119
- EXTRACTION_SYSTEM_PROMPT = (
120
- "You are a clinical intake assistant. From a doctor-patient conversation, "
121
- "output a SINGLE JSON object with exactly these keys, filling EVERY field the "
122
- "transcript supports:\n"
123
- '- "age": integer, patient age in years. "I\'m 71" / "Ho 71 anni" -> 71\n'
124
- '- "sex": one of "male", "female", "other" (ENGLISH). '
125
- '"sesso maschile" / "I\'m a man" -> "male"\n'
126
- '- "tumor_site": string, body location of the tumor/mass/lesion. '
127
- '"massa vicino alla prostata" / "mass near the prostate" -> "prostate"\n'
128
- '- "main_symptoms": array of strings — every reported symptom/complaint.\n'
129
- '- "symptom_duration": string, how long symptoms lasted. '
130
- '"da circa quattro mesi" -> "about four months"\n'
131
- '- "biopsy_done": boolean — true if a biopsy was performed/done.\n'
132
- '- "stage_known": boolean — true if the cancer stage is mentioned/known.\n'
133
- '- "cancer_stage": string, the stage value if mentioned, e.g. "T3", "II".\n'
134
- '- "current_therapies": array of strings — current medications/treatments.\n'
135
- '- "comorbidities": array of strings — other chronic conditions.\n'
136
- '- "allergies": array of strings — drug or other allergies.\n'
137
- "Rules:\n"
138
- "- Fill a field whenever the information appears ANYWHERE in the transcript, "
139
- "in ANY language. Translate enum values (sex) to English; keep symptom and "
140
- "condition wording as the patient said it.\n"
141
- "- Use null ONLY when the field is truly not mentioned.\n"
142
- "- Always capture age and sex when stated, even in a short reply.\n"
143
- "- Do NOT diagnose or invent. Output ONLY the JSON object."
144
- )
145
-
146
- # A worked example (few-shot) that teaches the model to fill scalars too.
147
- _EXAMPLE_USER = (
148
- "Conversation transcript:\n"
149
- "Good morning. How old are you? I'm 68, and I'm a woman. I found a lump in my "
150
- "left breast. They did a biopsy; it's not staged yet. I take levothyroxine.\n\n"
151
- "Extract the clinical intake fields as a JSON object."
152
- )
153
- _EXAMPLE_ASSISTANT = (
154
- '{"age": 68, "sex": "female", "tumor_site": "left breast", '
155
- '"main_symptoms": ["lump in the left breast"], "symptom_duration": null, '
156
- '"biopsy_done": true, "stage_known": false, "cancer_stage": null, '
157
- '"current_therapies": ["levothyroxine"], "comorbidities": null, '
158
- '"allergies": null}'
159
- )
160
-
161
-
162
- def _extraction_attempt(
163
- transcript: str, model_name: str, client, schema: dict, guided: bool
164
- ) -> dict:
165
- """One extraction round-trip. Returns a dict of non-null fields.
166
-
167
- Thinking is ALWAYS disabled here (structured output; a chain-of-thought
168
- doesn't help and, under guided decoding, can't be emitted at all).
169
- """
170
- user_prompt = (
171
- "Conversation transcript:\n"
172
- f"{transcript}\n\n"
173
- "Extract the clinical intake fields as a JSON object."
174
- )
175
- kwargs: dict[str, Any] = dict(
176
- model=model_name,
177
- messages=[
178
- {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
179
- {"role": "user", "content": _EXAMPLE_USER},
180
- {"role": "assistant", "content": _EXAMPLE_ASSISTANT},
181
- {"role": "user", "content": user_prompt},
182
- ],
183
- temperature=_temperature(),
184
- max_tokens=int(os.getenv("SOKRATES_EXTRACT_MAX_TOKENS", "1024")),
185
- )
186
- extra: dict[str, Any] = {"chat_template_kwargs": {"enable_thinking": False}}
187
- if guided:
188
- extra["guided_json"] = schema
189
- else:
190
- kwargs["response_format"] = {"type": "json_object"}
191
- kwargs["extra_body"] = extra
192
-
193
- resp = client.chat.completions.create(**kwargs)
194
- content = resp.choices[0].message.content or "{}"
195
- data = _parse_json_object(content)
196
- return {k: v for k, v in data.items() if v is not None}
197
-
198
-
199
- def _normalize_value(key: str, value: Any) -> Any:
200
- """Light normalization so common multilingual values still validate.
201
-
202
- Mainly maps sex words (any language / short forms) to the English enum.
203
- """
204
- if key == "sex" and isinstance(value, str):
205
- v = value.strip().lower()
206
- male = {"male", "m", "man", "maschile", "maschio", "uomo", "männlich", "homme"}
207
- female = {"female", "f", "woman", "femminile", "femmina", "donna", "femme"}
208
- if v in male:
209
- return "male"
210
- if v in female:
211
- return "female"
212
- return value
213
-
214
-
215
- def _build_form(data: dict) -> IntakeForm:
216
- """Validate field by field, dropping any value that doesn't fit the schema.
217
-
218
- This keeps good fields even if one value is malformed (e.g. an unmapped sex
219
- value), instead of rejecting the whole object.
220
- """
221
- clean: dict[str, Any] = {}
222
- for key, value in data.items():
223
- if value is None or key not in IntakeForm.model_fields:
224
- continue
225
- value = _normalize_value(key, value)
226
- try:
227
- IntakeForm.model_validate({key: value})
228
- except Exception:
229
- continue
230
- clean[key] = value
231
- return IntakeForm.model_validate(clean)
232
-
233
-
234
- def extract_form(transcript: str, current: Optional[IntakeForm] = None) -> IntakeForm:
235
- """Extract an IntakeForm from the transcript and merge into ``current``.
236
-
237
- Uses a free-form JSON call by default (the model fills every field it can,
238
- guided by the prompt + example). Guided decoding tended to fill only the
239
- list fields and null the scalars, so it's now only a fallback. The new JSON
240
- is merged WITHOUT overwriting fields already filled (schema.merge_forms).
241
- """
242
- current = current or IntakeForm()
243
- if not transcript or not transcript.strip():
244
- return current
245
-
246
- _, _, model_name = get_config() # validate env first (clear error if missing)
247
- client = get_client()
248
- schema = IntakeForm.model_json_schema()
249
-
250
- try:
251
- data = _extraction_attempt(
252
- transcript, model_name, client, schema, guided=False
253
- )
254
- except Exception:
255
- # Endpoint rejected json_object �� fall back to guided decoding.
256
- data = _extraction_attempt(
257
- transcript, model_name, client, schema, guided=True
258
- )
259
-
260
- # If nothing came back, try the other strategy once more.
261
- if not data:
262
- try:
263
- data = _extraction_attempt(
264
- transcript, model_name, client, schema, guided=True
265
- )
266
- except Exception:
267
- pass
268
-
269
- extracted = _build_form(data)
270
- return merge_forms(current, extracted)
271
- extracted = IntakeForm.model_validate(data)
272
- return merge_forms(current, extracted)
273
-
274
-
275
- QUESTION_SYSTEM_PROMPT = (
276
- "You are Sokrates, an assistant that helps a doctor run a clinical intake "
277
- "during a visit. You suggest the next questions the doctor should ask the "
278
- "patient to complete the intake.\n"
279
- "Strict rules:\n"
280
- "- DO NOT make diagnoses, do not suggest treatments, do not interpret "
281
- "findings. You only help COLLECT and STRUCTURE information.\n"
282
- "- Prioritize the listed missing mandatory fields first, then coherent "
283
- "clinical follow-ups based on what was already said.\n"
284
- "- Return AT MOST 3 questions, ordered by priority (most important first).\n"
285
- "- Each question must be short, in plain English, and directly askable to "
286
- "the patient.\n"
287
- "- Return ONLY a JSON object of the form "
288
- '{"questions": ["...", "..."]} with no extra text.'
289
- )
290
-
291
-
292
- def generate_questions(
293
- transcript: str,
294
- missing_fields: list[str],
295
- max_questions: int = 3,
296
- ) -> list[str]:
297
- """Generate up to ``max_questions`` prioritized follow-up questions.
298
-
299
- Combines the deterministic gaps (missing mandatory fields) with the
300
- conversation so far. Never diagnoses — see QUESTION_SYSTEM_PROMPT.
301
- """
302
- if not transcript or not transcript.strip():
303
- return []
304
-
305
- _, _, model_name = get_config()
306
- client = get_client()
307
-
308
- missing_str = ", ".join(missing_fields) if missing_fields else "(none)"
309
- user_prompt = (
310
- f"Missing mandatory intake fields: {missing_str}\n\n"
311
- "Conversation transcript so far:\n"
312
- f"{transcript}\n\n"
313
- f"Suggest at most {max_questions} prioritized questions the doctor "
314
- "should ask next to complete the intake. Do not diagnose."
315
- )
316
-
317
- kwargs: dict[str, Any] = dict(
318
- model=model_name,
319
- messages=[
320
- {"role": "system", "content": QUESTION_SYSTEM_PROMPT},
321
- {"role": "user", "content": user_prompt},
322
- ],
323
- temperature=max(_temperature(), 0.2),
324
- response_format={"type": "json_object"},
325
- max_tokens=int(os.getenv("SOKRATES_QUESTIONS_MAX_TOKENS", "400")),
326
- )
327
- extra = _extra_body()
328
- if extra:
329
- kwargs["extra_body"] = extra
330
-
331
- resp = client.chat.completions.create(**kwargs)
332
- content = resp.choices[0].message.content or "{}"
333
- data = _parse_json_object(content)
334
- questions = data.get("questions", []) if isinstance(data, dict) else []
335
- cleaned = [str(q).strip() for q in questions if str(q).strip()]
336
- return cleaned[:max_questions]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sokrates/orchestrator.py DELETED
@@ -1,78 +0,0 @@
1
- """Orchestration loop: transcript -> extraction -> gaps -> questions.
2
-
3
- This is the single entry point the UI calls after each transcript update. It:
4
- 1. extracts structured fields and merges them into the running form,
5
- 2. computes the still-missing mandatory fields (deterministic gap engine),
6
- 3. asks the LLM for up to 3 prioritized follow-up questions.
7
-
8
- It never diagnoses; it only structures data collection and suggests questions.
9
- The function is defensive: extraction and question generation can each fail
10
- independently (e.g. transient endpoint errors) without losing the form state.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from dataclasses import dataclass, field
16
- from typing import List
17
-
18
- from sokrates.gaps import completeness, find_gaps, missing_mandatory_labels
19
- from sokrates.llm import extract_form, generate_questions
20
- from sokrates.schema import IntakeForm
21
-
22
-
23
- @dataclass
24
- class TurnResult:
25
- """Everything the UI needs to repaint after processing a transcript."""
26
-
27
- form: IntakeForm
28
- missing_mandatory: List[str] = field(default_factory=list)
29
- questions: List[str] = field(default_factory=list)
30
- filled_mandatory: int = 0
31
- total_mandatory: int = 0
32
- notes: List[str] = field(default_factory=list)
33
-
34
- @property
35
- def status(self) -> str:
36
- msg = (
37
- f"{self.filled_mandatory}/{self.total_mandatory} mandatory fields "
38
- "filled."
39
- )
40
- if self.notes:
41
- msg += " " + " ".join(self.notes)
42
- return msg
43
-
44
-
45
- def process_transcript(
46
- transcript: str,
47
- form: IntakeForm | None = None,
48
- max_questions: int = 3,
49
- ) -> TurnResult:
50
- """Run one full orchestration turn over the current transcript."""
51
- form = form or IntakeForm()
52
- notes: List[str] = []
53
-
54
- # 1) Structured extraction (merges, never overwrites filled fields).
55
- try:
56
- form = extract_form(transcript, form)
57
- except Exception as exc:
58
- notes.append(f"⚠️ Extraction failed: {exc}")
59
-
60
- # 2) Deterministic gap engine.
61
- missing = missing_mandatory_labels(form)
62
- filled, total = completeness(form)
63
-
64
- # 3) Question generation (best-effort; uses gaps + transcript).
65
- questions: List[str] = []
66
- try:
67
- questions = generate_questions(transcript, missing, max_questions)
68
- except Exception as exc:
69
- notes.append(f"⚠️ Question generation failed: {exc}")
70
-
71
- return TurnResult(
72
- form=form,
73
- missing_mandatory=missing,
74
- questions=questions,
75
- filled_mandatory=filled,
76
- total_mandatory=total,
77
- notes=notes,
78
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
sokrates/schema.py DELETED
@@ -1,148 +0,0 @@
1
- """Clinical intake form schema (oncology) as a Pydantic model.
2
-
3
- This module is the single source of truth for:
4
- - the fields of the intake form (`IntakeForm`),
5
- - per-field display metadata and whether a field is mandatory (`FIELD_META`),
6
- - helpers to merge partial extractions without overwriting filled fields.
7
-
8
- Both the gap engine (`sokrates/gaps.py`) and the Gradio UI (`app.py`) read
9
- `FIELD_META`, so labels and "mandatory" status are defined in exactly one place.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from dataclasses import dataclass
15
- from typing import List, Literal, Optional
16
-
17
- from pydantic import BaseModel, Field
18
-
19
-
20
- Sex = Literal["male", "female", "other"]
21
-
22
-
23
- class IntakeForm(BaseModel):
24
- """Oncology clinical intake form.
25
-
26
- Every field is optional and starts as ``None`` (empty). The orchestration
27
- loop fills fields incrementally as the conversation progresses. List fields
28
- default to ``None`` (not ``[]``) so we can distinguish "not asked yet" from
29
- "asked, none reported".
30
- """
31
-
32
- age: Optional[int] = Field(default=None, description="Patient age in years.")
33
- sex: Optional[Sex] = Field(default=None, description="Patient biological sex.")
34
- tumor_site: Optional[str] = Field(
35
- default=None,
36
- description="Anatomical site of the tumor, e.g. 'left breast', 'lung'.",
37
- )
38
- main_symptoms: Optional[List[str]] = Field(
39
- default=None,
40
- description="Main presenting symptoms, e.g. ['cough', 'weight loss'].",
41
- )
42
- symptom_duration: Optional[str] = Field(
43
- default=None,
44
- description="How long symptoms have been present, e.g. '3 weeks'.",
45
- )
46
- biopsy_done: Optional[bool] = Field(
47
- default=None, description="Whether a biopsy has already been performed."
48
- )
49
- stage_known: Optional[bool] = Field(
50
- default=None, description="Whether the cancer stage is known."
51
- )
52
- cancer_stage: Optional[str] = Field(
53
- default=None,
54
- description="Cancer stage if known, e.g. 'T2N1M0' or 'II'.",
55
- )
56
- current_therapies: Optional[List[str]] = Field(
57
- default=None,
58
- description="Ongoing or current treatments/medications.",
59
- )
60
- comorbidities: Optional[List[str]] = Field(
61
- default=None,
62
- description="Pre-existing conditions, e.g. ['diabetes', 'hypertension'].",
63
- )
64
- allergies: Optional[List[str]] = Field(
65
- default=None,
66
- description="Known drug or other allergies.",
67
- )
68
-
69
-
70
- @dataclass(frozen=True)
71
- class FieldMeta:
72
- """Display + policy metadata for a single intake field."""
73
-
74
- name: str
75
- label: str
76
- mandatory: bool
77
-
78
-
79
- # Ordered metadata for every field in IntakeForm. The UI renders fields in this
80
- # order; the gap engine uses `mandatory` to decide what is still required.
81
- FIELD_META: List[FieldMeta] = [
82
- FieldMeta("age", "Age", True),
83
- FieldMeta("sex", "Sex", True),
84
- FieldMeta("tumor_site", "Tumor site", True),
85
- FieldMeta("main_symptoms", "Main symptoms", True),
86
- FieldMeta("symptom_duration", "Symptom duration", False),
87
- FieldMeta("biopsy_done", "Biopsy done", True),
88
- FieldMeta("stage_known", "Stage known", True),
89
- FieldMeta("cancer_stage", "Cancer stage", False),
90
- FieldMeta("current_therapies", "Current therapies", False),
91
- FieldMeta("comorbidities", "Comorbidities", False),
92
- FieldMeta("allergies", "Allergies", False),
93
- ]
94
-
95
- MANDATORY_FIELDS: List[str] = [m.name for m in FIELD_META if m.mandatory]
96
-
97
-
98
- def is_empty(value: object) -> bool:
99
- """Return True if a field value counts as 'not filled yet'."""
100
- if value is None:
101
- return True
102
- if isinstance(value, str) and value.strip() == "":
103
- return True
104
- if isinstance(value, list) and len(value) == 0:
105
- return True
106
- return False
107
-
108
-
109
- def coerce_form(value: object) -> IntakeForm:
110
- """Return an IntakeForm from None, a dict, or an IntakeForm.
111
-
112
- Gradio's ``State`` can hand back a plain dict instead of the model instance,
113
- so callers normalize through this before using the form.
114
- """
115
- if isinstance(value, IntakeForm):
116
- return value
117
- if isinstance(value, dict):
118
- return IntakeForm(**value)
119
- return IntakeForm()
120
-
121
-
122
- def merge_forms(base: IntakeForm, update: IntakeForm) -> IntakeForm:
123
- """Merge ``update`` into ``base`` without overwriting already-filled fields.
124
-
125
- A field in ``base`` is only replaced when it is currently empty AND the
126
- corresponding field in ``update`` is non-empty. This implements the
127
- "do not overwrite fields that are already filled" rule.
128
- """
129
- merged = base.model_copy(deep=True)
130
- for meta in FIELD_META:
131
- current = getattr(merged, meta.name)
132
- if not is_empty(current):
133
- continue
134
- incoming = getattr(update, meta.name)
135
- if not is_empty(incoming):
136
- setattr(merged, meta.name, incoming)
137
- return merged
138
-
139
-
140
- def format_value(value: object) -> str:
141
- """Human-readable rendering of a field value for the UI."""
142
- if is_empty(value):
143
- return ""
144
- if isinstance(value, bool):
145
- return "Yes" if value else "No"
146
- if isinstance(value, list):
147
- return ", ".join(str(v) for v in value)
148
- return str(value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_endpoint.py DELETED
@@ -1,68 +0,0 @@
1
- """Diagnostic: confirm the LLM endpoint works and measure latency.
2
-
3
- Run it with the same environment variables as the app:
4
-
5
- export MODEL_BASE_URL="https://....modal.run/v1"
6
- export MODEL_API_KEY="sk-sokrates-demo-123"
7
- export MODEL_NAME="Qwen/Qwen3-14B"
8
- export SOKRATES_GUIDED_JSON=1
9
- export SOKRATES_NO_THINK=1
10
- python test_endpoint.py
11
-
12
- It prints, step by step:
13
- - the resolved config,
14
- - whether the server lists the model,
15
- - the extraction call + how long it took,
16
- - the question-generation call + how long it took.
17
-
18
- If the first call is slow (tens of seconds to minutes) but the second is fast,
19
- that's a cold start — the model is loading on the GPU. Keep one replica warm:
20
- SOKRATES_KEEP_WARM=1 modal deploy modal_vllm.py
21
- """
22
-
23
- import time
24
-
25
- from sokrates.gaps import missing_mandatory_labels
26
- from sokrates.llm import generate_questions, get_client, get_config, extract_form
27
-
28
- TRANSCRIPT = (
29
- "Doctor: Good morning, how old are you?\n"
30
- "Patient: I'm 62, and I'm a man.\n"
31
- "Doctor: What brings you in?\n"
32
- "Patient: I've had a cough for three weeks and lost some weight. "
33
- "I had a chest CT that showed a mass in my right lung, but no biopsy yet."
34
- )
35
-
36
-
37
- def main() -> None:
38
- base_url, _key, model = get_config()
39
- print(f"• Endpoint : {base_url}")
40
- print(f"• Model : {model}\n")
41
-
42
- print("→ Checking the server lists the model …")
43
- t0 = time.time()
44
- models = get_client().models.list()
45
- served = [m.id for m in models.data]
46
- print(f" OK in {time.time() - t0:.1f}s — served models: {served}\n")
47
-
48
- print("→ Extraction call (fills the form) …")
49
- t0 = time.time()
50
- form = extract_form(TRANSCRIPT)
51
- print(f" Done in {time.time() - t0:.1f}s")
52
- print(f" Extracted: {form.model_dump(exclude_none=True)}\n")
53
-
54
- missing = missing_mandatory_labels(form)
55
- print(f" Still-missing mandatory fields: {missing}\n")
56
-
57
- print("→ Question-generation call …")
58
- t0 = time.time()
59
- questions = generate_questions(TRANSCRIPT, missing)
60
- print(f" Done in {time.time() - t0:.1f}s")
61
- for i, q in enumerate(questions, 1):
62
- print(f" {i}. {q}")
63
-
64
- print("\n✅ Endpoint works end to end.")
65
-
66
-
67
- if __name__ == "__main__":
68
- main()