amd-gaia 0.15.0__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/METADATA +223 -223
  2. amd_gaia-0.15.1.dist-info/RECORD +178 -0
  3. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/entry_points.txt +1 -0
  4. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/licenses/LICENSE.md +20 -20
  5. gaia/__init__.py +29 -29
  6. gaia/agents/__init__.py +19 -19
  7. gaia/agents/base/__init__.py +9 -9
  8. gaia/agents/base/agent.py +2177 -2177
  9. gaia/agents/base/api_agent.py +120 -120
  10. gaia/agents/base/console.py +1841 -1841
  11. gaia/agents/base/errors.py +237 -237
  12. gaia/agents/base/mcp_agent.py +86 -86
  13. gaia/agents/base/tools.py +83 -83
  14. gaia/agents/blender/agent.py +556 -556
  15. gaia/agents/blender/agent_simple.py +133 -135
  16. gaia/agents/blender/app.py +211 -211
  17. gaia/agents/blender/app_simple.py +41 -41
  18. gaia/agents/blender/core/__init__.py +16 -16
  19. gaia/agents/blender/core/materials.py +506 -506
  20. gaia/agents/blender/core/objects.py +316 -316
  21. gaia/agents/blender/core/rendering.py +225 -225
  22. gaia/agents/blender/core/scene.py +220 -220
  23. gaia/agents/blender/core/view.py +146 -146
  24. gaia/agents/chat/__init__.py +9 -9
  25. gaia/agents/chat/agent.py +835 -835
  26. gaia/agents/chat/app.py +1058 -1058
  27. gaia/agents/chat/session.py +508 -508
  28. gaia/agents/chat/tools/__init__.py +15 -15
  29. gaia/agents/chat/tools/file_tools.py +96 -96
  30. gaia/agents/chat/tools/rag_tools.py +1729 -1729
  31. gaia/agents/chat/tools/shell_tools.py +436 -436
  32. gaia/agents/code/__init__.py +7 -7
  33. gaia/agents/code/agent.py +549 -549
  34. gaia/agents/code/cli.py +377 -0
  35. gaia/agents/code/models.py +135 -135
  36. gaia/agents/code/orchestration/__init__.py +24 -24
  37. gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
  38. gaia/agents/code/orchestration/checklist_generator.py +713 -713
  39. gaia/agents/code/orchestration/factories/__init__.py +9 -9
  40. gaia/agents/code/orchestration/factories/base.py +63 -63
  41. gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
  42. gaia/agents/code/orchestration/factories/python_factory.py +106 -106
  43. gaia/agents/code/orchestration/orchestrator.py +841 -841
  44. gaia/agents/code/orchestration/project_analyzer.py +391 -391
  45. gaia/agents/code/orchestration/steps/__init__.py +67 -67
  46. gaia/agents/code/orchestration/steps/base.py +188 -188
  47. gaia/agents/code/orchestration/steps/error_handler.py +314 -314
  48. gaia/agents/code/orchestration/steps/nextjs.py +828 -828
  49. gaia/agents/code/orchestration/steps/python.py +307 -307
  50. gaia/agents/code/orchestration/template_catalog.py +469 -469
  51. gaia/agents/code/orchestration/workflows/__init__.py +14 -14
  52. gaia/agents/code/orchestration/workflows/base.py +80 -80
  53. gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
  54. gaia/agents/code/orchestration/workflows/python.py +94 -94
  55. gaia/agents/code/prompts/__init__.py +11 -11
  56. gaia/agents/code/prompts/base_prompt.py +77 -77
  57. gaia/agents/code/prompts/code_patterns.py +2036 -2036
  58. gaia/agents/code/prompts/nextjs_prompt.py +40 -40
  59. gaia/agents/code/prompts/python_prompt.py +109 -109
  60. gaia/agents/code/schema_inference.py +365 -365
  61. gaia/agents/code/system_prompt.py +41 -41
  62. gaia/agents/code/tools/__init__.py +42 -42
  63. gaia/agents/code/tools/cli_tools.py +1138 -1138
  64. gaia/agents/code/tools/code_formatting.py +319 -319
  65. gaia/agents/code/tools/code_tools.py +769 -769
  66. gaia/agents/code/tools/error_fixing.py +1347 -1347
  67. gaia/agents/code/tools/external_tools.py +180 -180
  68. gaia/agents/code/tools/file_io.py +845 -845
  69. gaia/agents/code/tools/prisma_tools.py +190 -190
  70. gaia/agents/code/tools/project_management.py +1016 -1016
  71. gaia/agents/code/tools/testing.py +321 -321
  72. gaia/agents/code/tools/typescript_tools.py +122 -122
  73. gaia/agents/code/tools/validation_parsing.py +461 -461
  74. gaia/agents/code/tools/validation_tools.py +806 -806
  75. gaia/agents/code/tools/web_dev_tools.py +1758 -1758
  76. gaia/agents/code/validators/__init__.py +16 -16
  77. gaia/agents/code/validators/antipattern_checker.py +241 -241
  78. gaia/agents/code/validators/ast_analyzer.py +197 -197
  79. gaia/agents/code/validators/requirements_validator.py +145 -145
  80. gaia/agents/code/validators/syntax_validator.py +171 -171
  81. gaia/agents/docker/__init__.py +7 -7
  82. gaia/agents/docker/agent.py +642 -642
  83. gaia/agents/emr/__init__.py +8 -8
  84. gaia/agents/emr/agent.py +1506 -1506
  85. gaia/agents/emr/cli.py +1322 -1322
  86. gaia/agents/emr/constants.py +475 -475
  87. gaia/agents/emr/dashboard/__init__.py +4 -4
  88. gaia/agents/emr/dashboard/server.py +1974 -1974
  89. gaia/agents/jira/__init__.py +11 -11
  90. gaia/agents/jira/agent.py +894 -894
  91. gaia/agents/jira/jql_templates.py +299 -299
  92. gaia/agents/routing/__init__.py +7 -7
  93. gaia/agents/routing/agent.py +567 -570
  94. gaia/agents/routing/system_prompt.py +75 -75
  95. gaia/agents/summarize/__init__.py +11 -0
  96. gaia/agents/summarize/agent.py +885 -0
  97. gaia/agents/summarize/prompts.py +129 -0
  98. gaia/api/__init__.py +23 -23
  99. gaia/api/agent_registry.py +238 -238
  100. gaia/api/app.py +305 -305
  101. gaia/api/openai_server.py +575 -575
  102. gaia/api/schemas.py +186 -186
  103. gaia/api/sse_handler.py +373 -373
  104. gaia/apps/__init__.py +4 -4
  105. gaia/apps/llm/__init__.py +6 -6
  106. gaia/apps/llm/app.py +173 -169
  107. gaia/apps/summarize/app.py +116 -633
  108. gaia/apps/summarize/html_viewer.py +133 -133
  109. gaia/apps/summarize/pdf_formatter.py +284 -284
  110. gaia/audio/__init__.py +2 -2
  111. gaia/audio/audio_client.py +439 -439
  112. gaia/audio/audio_recorder.py +269 -269
  113. gaia/audio/kokoro_tts.py +599 -599
  114. gaia/audio/whisper_asr.py +432 -432
  115. gaia/chat/__init__.py +16 -16
  116. gaia/chat/app.py +430 -430
  117. gaia/chat/prompts.py +522 -522
  118. gaia/chat/sdk.py +1228 -1225
  119. gaia/cli.py +5481 -5632
  120. gaia/database/__init__.py +10 -10
  121. gaia/database/agent.py +176 -176
  122. gaia/database/mixin.py +290 -290
  123. gaia/database/testing.py +64 -64
  124. gaia/eval/batch_experiment.py +2332 -2332
  125. gaia/eval/claude.py +542 -542
  126. gaia/eval/config.py +37 -37
  127. gaia/eval/email_generator.py +512 -512
  128. gaia/eval/eval.py +3179 -3179
  129. gaia/eval/groundtruth.py +1130 -1130
  130. gaia/eval/transcript_generator.py +582 -582
  131. gaia/eval/webapp/README.md +167 -167
  132. gaia/eval/webapp/package-lock.json +875 -875
  133. gaia/eval/webapp/package.json +20 -20
  134. gaia/eval/webapp/public/app.js +3402 -3402
  135. gaia/eval/webapp/public/index.html +87 -87
  136. gaia/eval/webapp/public/styles.css +3661 -3661
  137. gaia/eval/webapp/server.js +415 -415
  138. gaia/eval/webapp/test-setup.js +72 -72
  139. gaia/llm/__init__.py +9 -2
  140. gaia/llm/base_client.py +60 -0
  141. gaia/llm/exceptions.py +12 -0
  142. gaia/llm/factory.py +70 -0
  143. gaia/llm/lemonade_client.py +3236 -3221
  144. gaia/llm/lemonade_manager.py +294 -294
  145. gaia/llm/providers/__init__.py +9 -0
  146. gaia/llm/providers/claude.py +108 -0
  147. gaia/llm/providers/lemonade.py +120 -0
  148. gaia/llm/providers/openai_provider.py +79 -0
  149. gaia/llm/vlm_client.py +382 -382
  150. gaia/logger.py +189 -189
  151. gaia/mcp/agent_mcp_server.py +245 -245
  152. gaia/mcp/blender_mcp_client.py +138 -138
  153. gaia/mcp/blender_mcp_server.py +648 -648
  154. gaia/mcp/context7_cache.py +332 -332
  155. gaia/mcp/external_services.py +518 -518
  156. gaia/mcp/mcp_bridge.py +811 -550
  157. gaia/mcp/servers/__init__.py +6 -6
  158. gaia/mcp/servers/docker_mcp.py +83 -83
  159. gaia/perf_analysis.py +361 -0
  160. gaia/rag/__init__.py +10 -10
  161. gaia/rag/app.py +293 -293
  162. gaia/rag/demo.py +304 -304
  163. gaia/rag/pdf_utils.py +235 -235
  164. gaia/rag/sdk.py +2194 -2194
  165. gaia/security.py +163 -163
  166. gaia/talk/app.py +289 -289
  167. gaia/talk/sdk.py +538 -538
  168. gaia/testing/__init__.py +87 -87
  169. gaia/testing/assertions.py +330 -330
  170. gaia/testing/fixtures.py +333 -333
  171. gaia/testing/mocks.py +493 -493
  172. gaia/util.py +46 -46
  173. gaia/utils/__init__.py +33 -33
  174. gaia/utils/file_watcher.py +675 -675
  175. gaia/utils/parsing.py +223 -223
  176. gaia/version.py +100 -100
  177. amd_gaia-0.15.0.dist-info/RECORD +0 -168
  178. gaia/agents/code/app.py +0 -266
  179. gaia/llm/llm_client.py +0 -723
  180. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/WHEEL +0 -0
  181. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/top_level.txt +0 -0
gaia/agents/emr/cli.py CHANGED
@@ -1,1322 +1,1322 @@
1
- # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
- # SPDX-License-Identifier: MIT
3
-
4
- """CLI for Medical Intake Agent."""
5
-
6
- import argparse
7
- import logging
8
- import sys
9
- from pathlib import Path
10
-
11
- from rich.console import Console
12
- from rich.panel import Panel
13
- from rich.table import Table
14
-
15
- from gaia.agents.emr.agent import MedicalIntakeAgent
16
-
17
- logger = logging.getLogger(__name__)
18
- console = Console()
19
-
20
-
21
- def _print_header(watch_dir: str, db_path: str):
22
- """Print a styled header for the CLI."""
23
- console.print()
24
- console.print(
25
- Panel.fit(
26
- "[bold cyan]Medical Intake Agent[/bold cyan]\n"
27
- "[dim]Automatic Patient Form Processing[/dim]",
28
- border_style="cyan",
29
- )
30
- )
31
-
32
- # Status table
33
- table = Table(show_header=False, box=None, padding=(0, 2))
34
- table.add_column(style="dim")
35
- table.add_column()
36
- table.add_row("📁 Watch folder:", watch_dir)
37
- table.add_row("💾 Database:", db_path)
38
- console.print(table)
39
- console.print()
40
-
41
- # Commands help
42
- console.print("[dim]Commands:[/dim]")
43
- console.print(" [cyan]stats[/cyan] Show processing statistics")
44
- console.print(" [cyan]quit[/cyan] Stop and exit")
45
- console.print(" [dim]Or type questions about patients[/dim]")
46
- console.print()
47
-
48
-
49
- def _print_prompt():
50
- """Print the input prompt with visual separators."""
51
- console.print("─" * 80, style="dim")
52
- console.print("> ", end="", style="bold green")
53
- sys.stdout.flush() # Ensure prompt is displayed before input() blocks
54
-
55
-
56
- def cmd_watch(args):
57
- """Start watching directory for intake forms."""
58
- _print_header(args.watch_dir, args.db)
59
-
60
- console.print("[dim]Starting agent...[/dim]")
61
-
62
- agent = MedicalIntakeAgent(
63
- watch_dir=args.watch_dir,
64
- db_path=args.db,
65
- vlm_model=args.vlm_model,
66
- )
67
-
68
- console.print("[green]✓ Ready![/green] Drop intake forms to process them.\n")
69
- sys.stdout.flush() # Ensure Ready message appears before prompt
70
-
71
- try:
72
- while True:
73
- try:
74
- _print_prompt()
75
- user_input = input().strip()
76
- except EOFError:
77
- break
78
-
79
- if not user_input:
80
- continue
81
-
82
- if user_input.lower() in ("quit", "exit", "q"):
83
- break
84
-
85
- console.print("─" * 80, style="dim")
86
-
87
- if user_input.lower() == "stats":
88
- cmd_stats_inline(agent)
89
- else:
90
- # Process the query
91
- agent.process_query(user_input)
92
- print()
93
-
94
- except KeyboardInterrupt:
95
- print()
96
- finally:
97
- console.print("[dim]Stopping agent...[/dim]")
98
- agent.stop()
99
- console.print("[green]✓ Stopped[/green]")
100
-
101
-
102
- def cmd_stats_inline(agent):
103
- """Show stats inline during watch mode."""
104
- try:
105
- stats = agent.get_stats()
106
- _print_stats_table(stats)
107
- except Exception as e:
108
- console.print(f"[red]Error getting stats: {e}[/red]")
109
-
110
-
111
- def cmd_process(args):
112
- """Process a single intake form file."""
113
- if not Path(args.file).exists():
114
- console.print(f"[red]Error: File not found: {args.file}[/red]")
115
- return 1
116
-
117
- console.print(f"[dim]Processing: {args.file}[/dim]")
118
-
119
- agent = MedicalIntakeAgent(
120
- watch_dir=args.watch_dir,
121
- db_path=args.db,
122
- vlm_model=args.vlm_model,
123
- auto_start_watching=False,
124
- )
125
-
126
- try:
127
- # pylint: disable=protected-access
128
- patient_data = agent._process_intake_form(args.file)
129
-
130
- if patient_data:
131
- # Agent already prints success and patient details
132
- return 0
133
- else:
134
- console.print(f"[red]Failed to process: {args.file}[/red]")
135
- return 1
136
-
137
- finally:
138
- agent.stop()
139
-
140
-
141
- def cmd_query(args):
142
- """Query patient database."""
143
- agent = MedicalIntakeAgent(
144
- watch_dir=args.watch_dir,
145
- db_path=args.db,
146
- auto_start_watching=False,
147
- )
148
-
149
- try:
150
- agent.process_query(args.question)
151
- return 0
152
- finally:
153
- agent.stop()
154
-
155
-
156
- def _print_stats_table(stats: dict):
157
- """Print statistics using Rich formatting."""
158
- console.print()
159
-
160
- # Time savings highlight
161
- time_table = Table(show_header=False, box=None, padding=(0, 1))
162
- time_table.add_column(style="bold green")
163
- time_table.add_column(style="green")
164
- time_table.add_row(
165
- f"⏱️ {stats['time_saved_minutes']} min saved",
166
- f"({stats['time_saved_percent']} faster)",
167
- )
168
- console.print(Panel(time_table, title="Time Savings", border_style="green"))
169
-
170
- # Main stats grid
171
- grid = Table.grid(expand=True, padding=(0, 2))
172
- grid.add_column()
173
- grid.add_column()
174
-
175
- # Patients table
176
- patients = Table(show_header=False, box=None)
177
- patients.add_column(style="dim")
178
- patients.add_column(style="bold")
179
- patients.add_row("Total", str(stats["total_patients"]))
180
- patients.add_row("New", str(stats["new_patients"]))
181
- patients.add_row("Returning", str(stats["returning_patients"]))
182
- patients.add_row("Today", str(stats["processed_today"]))
183
-
184
- # Processing table
185
- processing = Table(show_header=False, box=None)
186
- processing.add_column(style="dim")
187
- processing.add_column(style="bold")
188
- processing.add_row("Processed", str(stats["files_processed"]))
189
- processing.add_row("Success", str(stats["extraction_success"]))
190
- processing.add_row("Failed", str(stats["extraction_failed"]))
191
- processing.add_row("Rate", stats["success_rate"])
192
-
193
- grid.add_row(
194
- Panel(patients, title="👥 Patients", border_style="cyan"),
195
- Panel(processing, title="📋 Processing", border_style="cyan"),
196
- )
197
- console.print(grid)
198
-
199
- # Alerts (if any)
200
- if stats.get("unacknowledged_alerts", 0) > 0:
201
- console.print(
202
- f"[bold red]🚨 {stats['unacknowledged_alerts']} unacknowledged alert(s)[/bold red]"
203
- )
204
- console.print()
205
-
206
-
207
- def cmd_stats(args):
208
- """Show processing statistics."""
209
- agent = MedicalIntakeAgent(
210
- watch_dir=args.watch_dir,
211
- db_path=args.db,
212
- auto_start_watching=False,
213
- silent_mode=True,
214
- )
215
-
216
- try:
217
- stats = agent.get_stats()
218
- _print_stats_table(stats)
219
- return 0
220
- finally:
221
- agent.stop()
222
-
223
-
224
- def cmd_reset(args):
225
- """Reset by deleting the database file."""
226
- import os
227
-
228
- from rich.prompt import Confirm
229
-
230
- db_path = Path(args.db)
231
-
232
- # Check if database exists
233
- if not db_path.exists():
234
- console.print("[dim]Database file does not exist. Nothing to reset.[/dim]")
235
- return 0
236
-
237
- # Get stats before deletion to show what will be deleted
238
- total_patients = 0
239
- agent = None
240
- try:
241
- agent = MedicalIntakeAgent(
242
- watch_dir=args.watch_dir,
243
- db_path=args.db,
244
- auto_start_watching=False,
245
- silent_mode=True,
246
- )
247
- stats = agent.get_stats()
248
- total_patients = stats.get("total_patients", 0)
249
- except Exception:
250
- pass # If we can't read stats, proceed anyway
251
- finally:
252
- if agent:
253
- agent.stop()
254
-
255
- # Confirmation prompt unless --force is used
256
- if not args.force:
257
- console.print()
258
- console.print(
259
- "[bold yellow]⚠️ WARNING:[/bold yellow] This will permanently delete:"
260
- )
261
- if total_patients > 0:
262
- console.print(f" • {total_patients} patient record(s)")
263
- console.print(" • All associated alerts and intake sessions")
264
- console.print(f" • Database file: {db_path}")
265
- console.print()
266
-
267
- if not Confirm.ask("[bold red]Are you sure you want to continue?[/bold red]"):
268
- console.print("[dim]Cancelled.[/dim]")
269
- return 0
270
-
271
- # Delete the database file
272
- try:
273
- os.remove(db_path)
274
- console.print()
275
- console.print("[bold green]✓ Database deleted successfully[/bold green]")
276
- console.print(f" Removed: {db_path}")
277
- console.print()
278
- console.print(
279
- "[dim]A fresh database will be created when you next run the agent.[/dim]"
280
- )
281
- return 0
282
- except Exception as e:
283
- console.print(f"[red]Error deleting database: {e}[/red]")
284
- return 1
285
-
286
-
287
- def cmd_init(args):
288
- """Initialize EMR agent by downloading and loading required models."""
289
- import time
290
-
291
- from gaia.llm.lemonade_client import LemonadeClient
292
-
293
- console.print()
294
- console.print(
295
- Panel.fit(
296
- "[bold cyan]EMR Agent Setup[/bold cyan]\n"
297
- "[dim]Downloading and loading required models[/dim]",
298
- border_style="cyan",
299
- )
300
- )
301
- console.print()
302
-
303
- # Required models for EMR agent
304
- vlm_model = args.vlm_model # Default: Qwen3-VL-4B-Instruct-GGUF
305
- llm_model = "Qwen3-Coder-30B-A3B-Instruct-GGUF" # For chat/query processing
306
- embed_model = "nomic-embed-text-v2-moe-GGUF" # For similarity search
307
-
308
- REQUIRED_CONTEXT_SIZE = 32768
309
-
310
- # Step 1: Check Lemonade server and context size
311
- console.print("[bold]Step 1:[/bold] Checking Lemonade server...")
312
- try:
313
- client = LemonadeClient(model=vlm_model)
314
- health = client.health_check()
315
- if health.get("status") == "ok":
316
- console.print(" [green]✓[/green] Lemonade server is running")
317
-
318
- # Check context size
319
- context_size = health.get("context_size", 0)
320
- if context_size >= REQUIRED_CONTEXT_SIZE:
321
- console.print(
322
- f" [green]✓[/green] Context size: [cyan]{context_size:,}[/cyan] tokens (recommended: {REQUIRED_CONTEXT_SIZE:,})"
323
- )
324
- elif context_size > 0:
325
- console.print(
326
- f" [yellow]⚠[/yellow] Context size: [yellow]{context_size:,}[/yellow] tokens"
327
- )
328
- console.print(
329
- f" [yellow]Warning:[/yellow] Context size should be at least [cyan]{REQUIRED_CONTEXT_SIZE:,}[/cyan] for reliable form processing"
330
- )
331
- console.print(
332
- " [dim]To fix: Right-click Lemonade tray → Settings → Context Size → 32768[/dim]"
333
- )
334
- else:
335
- console.print(
336
- " [dim]Context size: Not reported (will check after model load)[/dim]"
337
- )
338
- else:
339
- console.print(" [red]✗[/red] Lemonade server not responding")
340
- console.print()
341
- console.print("[yellow]Please start Lemonade server first:[/yellow]")
342
- console.print(" 1. Open Lemonade from the system tray")
343
- console.print(" 2. Or run: [cyan]lemonade-server[/cyan]")
344
- return 1
345
- except Exception as e:
346
- console.print(f" [red]✗[/red] Cannot connect to Lemonade: {e}")
347
- console.print()
348
- console.print("[yellow]Please start Lemonade server first:[/yellow]")
349
- console.print(" 1. Open Lemonade from the system tray")
350
- console.print(" 2. Or run: [cyan]lemonade-server[/cyan]")
351
- return 1
352
-
353
- # Step 2: Check required models
354
- console.print()
355
- console.print("[bold]Step 2:[/bold] Checking required models...")
356
-
357
- try:
358
- models_response = client.list_models()
359
- available_models = models_response.get("data", [])
360
- downloaded_model_ids = [m.get("id", "") for m in available_models]
361
-
362
- # Check each required model
363
- required_models = [
364
- ("VLM", vlm_model, "Form extraction"),
365
- ("LLM", llm_model, "Chat/query processing"),
366
- ("Embedding", embed_model, "Similarity search"),
367
- ]
368
-
369
- models_to_download = []
370
- for model_type, model_name, _purpose in required_models:
371
- is_downloaded = model_name in downloaded_model_ids
372
- if is_downloaded:
373
- console.print(
374
- f" [green]✓[/green] {model_type}: [cyan]{model_name}[/cyan]"
375
- )
376
- else:
377
- console.print(
378
- f" [dim]○[/dim] {model_type}: [cyan]{model_name}[/cyan] [dim](not downloaded)[/dim]"
379
- )
380
- models_to_download.append((model_type, model_name))
381
-
382
- if models_to_download:
383
- console.print()
384
- console.print(
385
- f" [yellow]⚠️ {len(models_to_download)} model(s) need to be downloaded[/yellow]"
386
- )
387
-
388
- except Exception as e:
389
- console.print(f" [red]✗[/red] Failed to check models: {e}")
390
- return 1
391
-
392
- # Step 3: Load all required models
393
- console.print()
394
- console.print("[bold]Step 3:[/bold] Loading required models...")
395
- console.print(" [dim]Loading models into memory for fast inference...[/dim]")
396
- console.print()
397
-
398
- models_loaded = {}
399
-
400
- # Load VLM model first (most important for form processing)
401
- for model_type, model_name in [
402
- ("VLM", vlm_model),
403
- ("LLM", llm_model),
404
- ("Embedding", embed_model),
405
- ]:
406
- console.print(f" Loading {model_type}: [cyan]{model_name}[/cyan]...")
407
-
408
- try:
409
- start_time = time.time()
410
- client.load_model(model_name, timeout=1800, auto_download=True)
411
- elapsed = time.time() - start_time
412
- models_loaded[model_type] = True
413
- console.print(f" [green]✓[/green] {model_type} loaded ({elapsed:.1f}s)")
414
- except Exception as e:
415
- error_msg = str(e)
416
- models_loaded[model_type] = False
417
-
418
- # Check for common errors
419
- if "being used by another process" in error_msg:
420
- console.print(
421
- f" [yellow]![/yellow] {model_type}: File locked, try again later"
422
- )
423
- elif (
424
- "not found" in error_msg.lower()
425
- or "does not exist" in error_msg.lower()
426
- ):
427
- console.print(
428
- f" [yellow]![/yellow] {model_type}: Model not available in registry"
429
- )
430
- else:
431
- console.print(f" [yellow]![/yellow] {model_type}: {error_msg[:50]}...")
432
-
433
- # Check if critical models loaded
434
- if not models_loaded.get("VLM"):
435
- console.print()
436
- console.print(
437
- "[red]✗ VLM model failed to load - form processing will not work[/red]"
438
- )
439
- return 1
440
-
441
- # Clear VLM context to ensure fresh memory allocation
442
- console.print()
443
- console.print(" [dim]Clearing VLM context for clean memory...[/dim]")
444
- try:
445
- client.unload_model()
446
- client.load_model(vlm_model, timeout=300, auto_download=True)
447
- console.print(" [green]✓[/green] VLM context cleared")
448
- except Exception as e:
449
- console.print(f" [dim]Context clear skipped: {e}[/dim]")
450
-
451
- # Step 4: Verify models and check context size
452
- console.print()
453
- console.print("[bold]Step 4:[/bold] Verifying models are ready...")
454
-
455
- vlm_ready = False
456
- llm_ready = False
457
- embed_ready = False
458
- final_context_size = 0
459
-
460
- try:
461
- # Check health for context size
462
- health = client.health_check()
463
- final_context_size = health.get("context_size", 0)
464
-
465
- # Check each model
466
- vlm_ready = client.check_model_loaded(vlm_model)
467
- llm_ready = client.check_model_loaded(llm_model)
468
- embed_ready = client.check_model_loaded(embed_model)
469
-
470
- if vlm_ready:
471
- console.print(" [green]✓[/green] VLM: Ready for form extraction")
472
- else:
473
- console.print(" [yellow]![/yellow] VLM: Will load on first use")
474
-
475
- if llm_ready:
476
- console.print(" [green]✓[/green] LLM: Ready for chat queries")
477
- else:
478
- console.print(" [dim]○[/dim] LLM: Will load on first use")
479
-
480
- if embed_ready:
481
- console.print(" [green]✓[/green] Embedding: Ready for search")
482
- else:
483
- console.print(" [dim]○[/dim] Embedding: Will load on first use")
484
-
485
- # Report context size
486
- if final_context_size >= REQUIRED_CONTEXT_SIZE:
487
- console.print(
488
- f" [green]✓[/green] Context size: [cyan]{final_context_size:,}[/cyan] tokens"
489
- )
490
- elif final_context_size > 0:
491
- console.print(
492
- f" [yellow]⚠[/yellow] Context size: [yellow]{final_context_size:,}[/yellow] tokens (need {REQUIRED_CONTEXT_SIZE:,})"
493
- )
494
-
495
- except Exception as e:
496
- console.print(f" [yellow]![/yellow] Could not verify: {e}")
497
-
498
- # Step 5: Show all downloaded and loaded models
499
- console.print()
500
- console.print("[bold]Step 5:[/bold] Model inventory...")
501
-
502
- try:
503
- models_response = client.list_models()
504
- all_models = models_response.get("data", [])
505
-
506
- # Categorize models
507
- vlm_models = []
508
- llm_models = []
509
- embed_models = []
510
-
511
- for m in all_models:
512
- model_id = m.get("id", "")
513
- model_lower = model_id.lower()
514
-
515
- if "vl" in model_lower or "vision" in model_lower or "vlm" in model_lower:
516
- vlm_models.append(model_id)
517
- elif (
518
- "embed" in model_lower
519
- or "bge" in model_lower
520
- or "e5" in model_lower
521
- or "nomic" in model_lower
522
- ):
523
- embed_models.append(model_id)
524
- else:
525
- llm_models.append(model_id)
526
-
527
- # Show categorized models
528
- if vlm_models:
529
- console.print(
530
- f" [cyan]VLM Models:[/cyan] {', '.join(vlm_models[:3])}"
531
- + (f" (+{len(vlm_models)-3} more)" if len(vlm_models) > 3 else "")
532
- )
533
- if llm_models:
534
- console.print(
535
- f" [cyan]LLM Models:[/cyan] {', '.join(llm_models[:3])}"
536
- + (f" (+{len(llm_models)-3} more)" if len(llm_models) > 3 else "")
537
- )
538
- if embed_models:
539
- console.print(
540
- f" [cyan]Embedding Models:[/cyan] {', '.join(embed_models[:3])}"
541
- + (f" (+{len(embed_models)-3} more)" if len(embed_models) > 3 else "")
542
- )
543
-
544
- console.print(f" [dim]Total models available: {len(all_models)}[/dim]")
545
-
546
- except Exception as e:
547
- console.print(f" [dim]Could not list models: {e}[/dim]")
548
-
549
- # Success summary
550
- console.print()
551
-
552
- # Build model status lines
553
- model_status_lines = []
554
-
555
- # VLM status
556
- if vlm_ready:
557
- model_status_lines.append(
558
- f"[green]✓[/green] VLM: [cyan]{vlm_model}[/cyan] - Ready"
559
- )
560
- else:
561
- model_status_lines.append(
562
- f"[yellow]![/yellow] VLM: [cyan]{vlm_model}[/cyan] - Will load on first use"
563
- )
564
-
565
- # LLM status
566
- if llm_ready:
567
- model_status_lines.append(
568
- f"[green]✓[/green] LLM: [cyan]{llm_model}[/cyan] - Ready"
569
- )
570
- else:
571
- model_status_lines.append(
572
- f"[dim]○[/dim] LLM: [cyan]{llm_model}[/cyan] - Will load on first use"
573
- )
574
-
575
- # Embedding status
576
- if embed_ready:
577
- model_status_lines.append(
578
- f"[green]✓[/green] Embedding: [cyan]{embed_model}[/cyan] - Ready"
579
- )
580
- else:
581
- model_status_lines.append(
582
- f"[dim]○[/dim] Embedding: [cyan]{embed_model}[/cyan] - Will load on first use"
583
- )
584
-
585
- # Context size status
586
- if final_context_size >= REQUIRED_CONTEXT_SIZE:
587
- model_status_lines.append(
588
- f"[green]✓[/green] Context size: {final_context_size:,} tokens"
589
- )
590
- elif final_context_size > 0:
591
- model_status_lines.append(
592
- f"[yellow]⚠[/yellow] Context size: {final_context_size:,} tokens (need {REQUIRED_CONTEXT_SIZE:,})"
593
- )
594
-
595
- # Count ready models
596
- ready_count = sum([vlm_ready, llm_ready, embed_ready])
597
-
598
- console.print(
599
- Panel.fit(
600
- f"[bold green]✓ EMR Agent initialized ({ready_count}/3 models ready)[/bold green]\n\n"
601
- + "\n".join(model_status_lines)
602
- + "\n\n"
603
- "[dim]You can now run:[/dim]\n"
604
- " [cyan]gaia-emr dashboard[/cyan] - Start the web dashboard\n"
605
- " [cyan]gaia-emr watch[/cyan] - Watch folder for intake forms\n"
606
- " [cyan]gaia-emr process <file>[/cyan] - Process a single file",
607
- border_style="green",
608
- )
609
- )
610
- console.print()
611
-
612
- # Context size warning if needed
613
- if 0 < final_context_size < REQUIRED_CONTEXT_SIZE:
614
- console.print(
615
- Panel.fit(
616
- "[yellow]⚠️ Context Size Warning[/yellow]\n\n"
617
- f"Current context size ({final_context_size:,}) may be too small for processing intake forms.\n"
618
- "Large images can require 4,000-8,000+ tokens.\n\n"
619
- "[bold]To fix:[/bold]\n"
620
- " 1. Right-click Lemonade tray icon → Settings\n"
621
- " 2. Set Context Size to [cyan]32768[/cyan]\n"
622
- " 3. Click Apply and restart the model",
623
- border_style="yellow",
624
- )
625
- )
626
- console.print()
627
-
628
- return 0
629
-
630
-
631
- def cmd_test(args):
632
- """Test VLM extraction pipeline on a single file."""
633
- import io
634
- import json
635
- import time
636
-
637
- from PIL import Image
638
-
639
- from gaia.llm.vlm_client import VLMClient
640
-
641
- file_path = Path(args.file)
642
- if not file_path.exists():
643
- console.print(f"[red]Error: File not found: {file_path}[/red]")
644
- return 1
645
-
646
- console.print()
647
- console.print(
648
- Panel.fit(
649
- "[bold cyan]EMR Agent - VLM Pipeline Test[/bold cyan]\n"
650
- f"[dim]Testing extraction on: {file_path.name}[/dim]",
651
- border_style="cyan",
652
- )
653
- )
654
- console.print()
655
-
656
- # Step 1: Read and analyze file
657
- console.print("[bold]Step 1:[/bold] Reading file...")
658
- try:
659
- raw_bytes = file_path.read_bytes()
660
- file_size_kb = len(raw_bytes) / 1024
661
- console.print(f" File size: {file_size_kb:.1f} KB")
662
-
663
- # Get image dimensions
664
- img = Image.open(io.BytesIO(raw_bytes))
665
- orig_width, orig_height = img.size
666
- console.print(f" Dimensions: {orig_width}x{orig_height} pixels")
667
-
668
- # Auto-rotate based on EXIF orientation
669
- try:
670
- from PIL import ExifTags
671
-
672
- exif = img._getexif() # pylint: disable=protected-access
673
- if exif:
674
- for tag, value in exif.items():
675
- if ExifTags.TAGS.get(tag) == "Orientation":
676
- if value == 3:
677
- img = img.rotate(180, expand=True)
678
- console.print(" [dim]Auto-rotated 180°[/dim]")
679
- elif value == 6:
680
- img = img.rotate(270, expand=True)
681
- console.print(" [dim]Auto-rotated 90° CW[/dim]")
682
- elif value == 8:
683
- img = img.rotate(90, expand=True)
684
- console.print(" [dim]Auto-rotated 90° CCW[/dim]")
685
- orig_width, orig_height = img.size
686
- break
687
- except Exception:
688
- pass # No EXIF or rotation info
689
- except Exception as e:
690
- console.print(f" [red]✗[/red] Failed to read file: {e}")
691
- return 1
692
-
693
- # Step 2: Optimize image (same as agent)
694
- console.print()
695
- console.print("[bold]Step 2:[/bold] Optimizing image...")
696
- max_dimension = args.max_dimension
697
- jpeg_quality = args.jpeg_quality
698
-
699
- try:
700
- if orig_width > max_dimension or orig_height > max_dimension:
701
- scale = min(max_dimension / orig_width, max_dimension / orig_height)
702
- new_width = int(orig_width * scale)
703
- new_height = int(orig_height * scale)
704
- img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
705
- console.print(
706
- f" Resized: {orig_width}x{orig_height} → {new_width}x{new_height}"
707
- )
708
- else:
709
- new_width, new_height = orig_width, orig_height
710
- console.print(f" No resize needed (under {max_dimension}px)")
711
-
712
- # Convert to RGB and JPEG
713
- if img.mode in ("RGBA", "P"):
714
- img = img.convert("RGB")
715
-
716
- output = io.BytesIO()
717
- img.save(output, format="JPEG", quality=jpeg_quality, optimize=True)
718
- image_bytes = output.getvalue()
719
-
720
- opt_size_kb = len(image_bytes) / 1024
721
- reduction = (1 - opt_size_kb / file_size_kb) * 100
722
- console.print(
723
- f" Optimized: {file_size_kb:.0f}KB → {opt_size_kb:.0f}KB ({reduction:.0f}% smaller)"
724
- )
725
-
726
- # Estimate image tokens (rough: ~1 token per 14x14 pixel patch)
727
- est_img_tokens = (new_width // 14) * (new_height // 14)
728
- console.print(f" Est. image tokens: ~{est_img_tokens:,}")
729
- except Exception as e:
730
- console.print(f" [red]✗[/red] Failed to optimize: {e}")
731
- return 1
732
-
733
- # Step 3: Initialize VLM
734
- console.print()
735
- console.print("[bold]Step 3:[/bold] Initializing VLM...")
736
- try:
737
- vlm = VLMClient(vlm_model=args.vlm_model)
738
-
739
- # Clear context if requested (unload and reload model)
740
- if getattr(args, "clear_context", False):
741
- console.print(" [dim]Clearing VLM context (unload + reload)...[/dim]")
742
- try:
743
- vlm.client.unload_model()
744
- vlm.client.load_model(args.vlm_model, timeout=300, auto_download=True)
745
- console.print(" [green]✓[/green] Context cleared")
746
- except Exception as e:
747
- console.print(f" [yellow]⚠[/yellow] Could not clear context: {e}")
748
-
749
- console.print(f" [green]✓[/green] VLM ready: [cyan]{vlm.vlm_model}[/cyan]")
750
- except Exception as e:
751
- console.print(f" [red]✗[/red] Failed to initialize VLM: {e}")
752
- return 1
753
-
754
- # Step 4: Extract data with auto-retry on memory errors
755
- console.print()
756
- console.print("[bold]Step 4:[/bold] Extracting patient data...")
757
-
758
- extraction_prompt = """Extract ALL patient information from this medical intake form.
759
-
760
- Return a JSON object with these fields (use null for missing/unclear):
761
- {
762
- "form_date": "YYYY-MM-DD (date form was filled, today's date)",
763
- "first_name": "...",
764
- "last_name": "...",
765
- "date_of_birth": "YYYY-MM-DD",
766
- "age": "patient's age if listed",
767
- "gender": "Male/Female/Other",
768
- "preferred_pronouns": "he/him, she/her, they/them if listed",
769
- "ssn": "XXX-XX-XXXX (social security number)",
770
- "marital_status": "Single/Married/Divorced/Widowed/Partnered",
771
- "spouse_name": "spouse's name if listed",
772
- "phone": "home phone number",
773
- "mobile_phone": "cell/mobile phone number",
774
- "work_phone": "work phone number",
775
- "email": "...",
776
- "address": "street address",
777
- "city": "...",
778
- "state": "...",
779
- "zip_code": "...",
780
- "preferred_language": "English/Spanish/etc if listed",
781
- "race": "if listed",
782
- "ethnicity": "if listed",
783
- "contact_preference": "preferred contact method if listed",
784
- "emergency_contact_name": "name of emergency contact person",
785
- "emergency_contact_relationship": "relationship to patient (e.g. Mom, Spouse, Friend)",
786
- "emergency_contact_phone": "emergency contact's phone number",
787
- "referring_physician": "name of referring physician/doctor",
788
- "referring_physician_phone": "phone number next to referring physician",
789
- "primary_care_physician": "PCP name if different from referring",
790
- "preferred_pharmacy": "pharmacy name if listed",
791
- "employment_status": "Employed/Self Employed/Unemployed/Retired/Student/Disabled/Military",
792
- "occupation": "job title if listed",
793
- "employer": "employer/company name",
794
- "employer_address": "employer address if listed",
795
- "insurance_provider": "insurance company name",
796
- "insurance_id": "policy number",
797
- "insurance_group_number": "group number",
798
- "insured_name": "name of insured person (may differ from patient)",
799
- "insured_dob": "YYYY-MM-DD",
800
- "insurance_phone": "insurance contact number",
801
- "billing_address": "billing address if different from home",
802
- "guarantor_name": "person responsible for payment if listed",
803
- "reason_for_visit": "chief complaint or reason for visit",
804
- "date_of_injury": "YYYY-MM-DD (date of injury or onset of symptoms)",
805
- "pain_location": "where pain is located if listed",
806
- "pain_onset": "when pain began (e.g. three months ago)",
807
- "pain_cause": "what caused the pain/condition",
808
- "pain_progression": "Improved/Worsened/Stayed the same",
809
- "work_related_injury": "Yes/No",
810
- "car_accident": "Yes/No",
811
- "medical_conditions": "existing medical conditions",
812
- "allergies": "known allergies",
813
- "medications": "current medications",
814
- "signature_date": "YYYY-MM-DD (date signed)"
815
- }
816
-
817
- IMPORTANT: Return ONLY the JSON object, no other text."""
818
-
819
- # Retry loop with progressively smaller images on memory errors
820
- max_retries = 3
821
- current_img = img
822
- current_bytes = image_bytes
823
- current_width, current_height = new_width, new_height
824
- current_size_kb = opt_size_kb
825
-
826
- for attempt in range(max_retries):
827
- est_img_tokens = (current_width // 14) * (current_height // 14)
828
- console.print(
829
- f" Image: {current_width}x{current_height}, {current_size_kb:.0f}KB (~{est_img_tokens:,} tokens)"
830
- )
831
-
832
- if attempt == 0:
833
- console.print(" [dim]This may take 30-60 seconds...[/dim]")
834
- else:
835
- console.print(
836
- f" [dim]Retry {attempt}/{max_retries-1} with smaller image...[/dim]"
837
- )
838
-
839
- try:
840
- start_time = time.time()
841
- raw_text = vlm.extract_from_image(
842
- image_bytes=current_bytes,
843
- prompt=extraction_prompt,
844
- )
845
- extraction_time = time.time() - start_time
846
-
847
- # Check for memory-related errors
848
- if (
849
- "failed to process image" in raw_text
850
- or "memory slot" in raw_text.lower()
851
- ):
852
- if attempt < max_retries - 1:
853
- console.print(
854
- " [yellow]⚠[/yellow] Memory error, reducing image size..."
855
- )
856
- # Reduce image to 75% of current size
857
- scale = 0.75
858
- current_width = int(current_width * scale)
859
- current_height = int(current_height * scale)
860
- current_img = current_img.resize(
861
- (current_width, current_height), Image.Resampling.LANCZOS
862
- )
863
- output = io.BytesIO()
864
- current_img.save(
865
- output, format="JPEG", quality=jpeg_quality, optimize=True
866
- )
867
- current_bytes = output.getvalue()
868
- current_size_kb = len(current_bytes) / 1024
869
- continue
870
- else:
871
- console.print(f" [red]✗[/red] {raw_text}")
872
- console.print()
873
- console.print("[yellow]Suggestions:[/yellow]")
874
- console.print(" 1. Try with smaller image: --max-dimension 640")
875
- console.print(" 2. Restart Lemonade Server to clear memory")
876
- console.print(" 3. Reload the VLM model in Lemonade")
877
- return 1
878
-
879
- if raw_text.startswith("[VLM extraction failed:"):
880
- console.print(f" [red]✗[/red] {raw_text}")
881
- return 1
882
-
883
- # Success!
884
- console.print(
885
- f" [green]✓[/green] Extraction complete ({len(raw_text)} chars, {extraction_time:.1f}s)"
886
- )
887
-
888
- # Estimate tokens/sec (output tokens only)
889
- est_output_tokens = len(raw_text) / 4
890
- tps = est_output_tokens / extraction_time if extraction_time > 0 else 0
891
- console.print(
892
- f" Output: ~{est_output_tokens:.0f} tokens at ~{tps:.1f} TPS"
893
- )
894
-
895
- # Total throughput including prompt processing
896
- total_tokens = (
897
- est_img_tokens + 200 + est_output_tokens
898
- ) # img + prompt + output
899
- total_tps = total_tokens / extraction_time if extraction_time > 0 else 0
900
- console.print(
901
- f" Total throughput: ~{total_tps:.0f} TPS (incl. {est_img_tokens:,} image tokens)"
902
- )
903
-
904
- # Update dimensions for final report
905
- new_width, new_height = current_width, current_height
906
- break
907
-
908
- except Exception as e:
909
- console.print(f" [red]✗[/red] Extraction failed: {e}")
910
- return 1
911
- else:
912
- # All retries exhausted
913
- console.print(" [red]✗[/red] All retries failed")
914
- return 1
915
-
916
- # Step 5: Parse JSON
917
- console.print()
918
- console.print("[bold]Step 5:[/bold] Parsing JSON...")
919
- try:
920
- # Try direct parse
921
- patient_data = json.loads(raw_text)
922
- console.print(" [green]✓[/green] JSON parsed successfully")
923
- except json.JSONDecodeError:
924
- # Try to find JSON in text
925
- try:
926
- start = raw_text.find("{")
927
- end = raw_text.rfind("}") + 1
928
- if start >= 0 and end > start:
929
- patient_data = json.loads(raw_text[start:end])
930
- console.print(" [green]✓[/green] JSON extracted from response")
931
- else:
932
- console.print(" [red]✗[/red] No JSON found in response")
933
- console.print()
934
- console.print("[bold]Raw VLM Output:[/bold]")
935
- console.print(raw_text[:500])
936
- return 1
937
- except json.JSONDecodeError as e:
938
- console.print(f" [red]✗[/red] JSON parse failed: {e}")
939
- return 1
940
-
941
- # Display results
942
- console.print()
943
- console.print(
944
- Panel.fit(
945
- "[bold green]✓ Extraction Successful[/bold green]",
946
- border_style="green",
947
- )
948
- )
949
-
950
- # Show extracted fields
951
- console.print()
952
- console.print("[bold]Extracted Fields:[/bold]")
953
- fields_found = 0
954
- for key, value in patient_data.items():
955
- if value and value != "null":
956
- fields_found += 1
957
- console.print(f" [cyan]{key}:[/cyan] {value}")
958
-
959
- console.print()
960
- console.print(f"[dim]Fields extracted: {fields_found}/53[/dim]")
961
- console.print()
962
-
963
- # Timing breakdown section
964
- console.print("[bold]⏱️ TIMING BREAKDOWN[/bold]")
965
- console.print("-" * 40)
966
- console.print(f" Model: {args.vlm_model}")
967
- console.print(f" Image dimensions: {new_width}x{new_height}")
968
- console.print(f" VLM extraction: {extraction_time:.2f}s")
969
- console.print(f" Est. image tokens: ~{est_img_tokens:,}")
970
- console.print(f" Est. output tokens: ~{int(est_output_tokens)}")
971
- console.print(f" Est. tokens/sec: ~{tps:.1f} TPS")
972
- console.print()
973
-
974
- # Success summary
975
- if patient_data.get("first_name") and patient_data.get("last_name"):
976
- console.print(
977
- f" Patient: {patient_data.get('first_name', '')} {patient_data.get('last_name', '')}"
978
- )
979
- console.print(" [green]✓ Pipeline test PASSED[/green]")
980
- else:
981
- console.print(" [yellow]⚠ Missing required name fields[/yellow]")
982
- console.print(" [red]✗ Pipeline test FAILED[/red]")
983
- console.print()
984
-
985
- return 0
986
-
987
-
988
- def _launch_electron(url: str, delay: float = 1.5) -> bool:
989
- """
990
- Launch Electron app to display the dashboard.
991
-
992
- Returns True if Electron was launched successfully, False otherwise.
993
- """
994
- import os
995
- import platform
996
- import shutil
997
- import subprocess
998
- import time
999
-
1000
- time.sleep(delay) # Wait for server to start
1001
-
1002
- # Find the Electron wrapper directory
1003
- electron_dir = Path(__file__).parent / "dashboard" / "electron"
1004
- main_js = electron_dir / "main.js"
1005
-
1006
- if not main_js.exists():
1007
- logger.warning(f"Electron wrapper not found at {electron_dir}")
1008
- return False
1009
-
1010
- # On Windows, use npm.cmd and npx.cmd
1011
- is_windows = platform.system() == "Windows"
1012
- npm_cmd = "npm.cmd" if is_windows else "npm"
1013
- npx_cmd = "npx.cmd" if is_windows else "npx"
1014
-
1015
- # Check if npx/electron is available
1016
- npx_path = shutil.which(npx_cmd)
1017
- if not npx_path:
1018
- logger.warning(f"{npx_cmd} not found in PATH, cannot launch Electron")
1019
- return False
1020
-
1021
- try:
1022
- # Check if node_modules exists, if not run npm install first
1023
- node_modules = electron_dir / "node_modules"
1024
- if not node_modules.exists():
1025
- console.print("[dim]Installing Electron dependencies...[/dim]")
1026
- subprocess.run(
1027
- [npm_cmd, "install"],
1028
- cwd=str(electron_dir),
1029
- capture_output=True,
1030
- check=True,
1031
- shell=is_windows, # Use shell on Windows for .cmd files
1032
- )
1033
-
1034
- # Launch Electron with the dashboard URL
1035
- env = os.environ.copy()
1036
- env["EMR_DASHBOARD_URL"] = url
1037
-
1038
- subprocess.Popen(
1039
- [npx_cmd, "electron", "."],
1040
- cwd=str(electron_dir),
1041
- env=env,
1042
- stdout=subprocess.DEVNULL,
1043
- stderr=subprocess.DEVNULL,
1044
- shell=is_windows, # Use shell on Windows for .cmd files
1045
- )
1046
- return True
1047
- except Exception as e:
1048
- logger.warning(f"Failed to launch Electron: {e}")
1049
- return False
1050
-
1051
-
1052
- def cmd_dashboard(args):
1053
- """Start web dashboard."""
1054
- import threading
1055
- import time
1056
- import webbrowser
1057
-
1058
- def open_browser(url: str, delay: float = 1.5):
1059
- """Open browser after a short delay to allow server to start."""
1060
- time.sleep(delay)
1061
- webbrowser.open(url)
1062
-
1063
- def open_electron_or_browser(url: str, use_browser: bool, delay: float = 1.5):
1064
- """Open Electron app, falling back to browser if needed."""
1065
- if use_browser:
1066
- open_browser(url, delay)
1067
- else:
1068
- if not _launch_electron(url, delay):
1069
- console.print(
1070
- "[yellow]Electron not available, opening in browser instead...[/yellow]"
1071
- )
1072
- open_browser(url, delay)
1073
-
1074
- try:
1075
- from gaia.agents.emr.dashboard.server import run_dashboard
1076
-
1077
- console.print()
1078
- console.print(
1079
- Panel.fit(
1080
- "[bold cyan]EMR Dashboard[/bold cyan]\n"
1081
- "[dim]Real-time Patient Processing Monitor[/dim]",
1082
- border_style="cyan",
1083
- )
1084
- )
1085
-
1086
- url = f"http://localhost:{args.port}"
1087
-
1088
- table = Table(show_header=False, box=None, padding=(0, 2))
1089
- table.add_column(style="dim")
1090
- table.add_column()
1091
- table.add_row("📁 Watch folder:", args.watch_dir)
1092
- table.add_row("💾 Database:", args.db)
1093
- table.add_row("🌐 URL:", f"[bold cyan]{url}[/bold cyan]")
1094
- console.print(table)
1095
- console.print("\n[dim]Press Ctrl+C to stop[/dim]\n")
1096
-
1097
- # Auto-open unless --no-open flag is set
1098
- if not getattr(args, "no_open", False):
1099
- use_browser = getattr(args, "browser", False)
1100
- if use_browser:
1101
- console.print("[dim]Opening dashboard in browser...[/dim]\n")
1102
- else:
1103
- console.print("[dim]Opening dashboard in Electron app...[/dim]\n")
1104
-
1105
- open_thread = threading.Thread(
1106
- target=open_electron_or_browser,
1107
- args=(url, use_browser),
1108
- daemon=True,
1109
- )
1110
- open_thread.start()
1111
-
1112
- run_dashboard(
1113
- watch_dir=args.watch_dir,
1114
- db_path=args.db,
1115
- host=args.host,
1116
- port=args.port,
1117
- )
1118
- except ImportError:
1119
- console.print("[red]Error: Dashboard dependencies not installed[/red]")
1120
- console.print("Install with: [cyan]pip install 'amd-gaia[api]'[/cyan]")
1121
- return 1
1122
- except KeyboardInterrupt:
1123
- console.print("\n[dim]Shutting down dashboard...[/dim]")
1124
- return 0
1125
-
1126
-
1127
- def _add_common_args(parser):
1128
- """Add common arguments to a parser."""
1129
- parser.add_argument(
1130
- "--watch-dir",
1131
- default="./intake_forms",
1132
- help="Directory to watch for intake forms (default: ./intake_forms)",
1133
- )
1134
- parser.add_argument(
1135
- "--db",
1136
- default="./data/patients.db",
1137
- help="Path to patient database (default: ./data/patients.db)",
1138
- )
1139
- parser.add_argument(
1140
- "--vlm-model",
1141
- default="Qwen3-VL-4B-Instruct-GGUF",
1142
- help="VLM model for extraction (default: Qwen3-VL-4B-Instruct-GGUF)",
1143
- )
1144
- parser.add_argument(
1145
- "--debug",
1146
- action="store_true",
1147
- help="Enable debug logging",
1148
- )
1149
-
1150
-
1151
- def main():
1152
- """Main CLI entry point."""
1153
- parser = argparse.ArgumentParser(
1154
- description="Medical Intake Agent CLI",
1155
- formatter_class=argparse.RawDescriptionHelpFormatter,
1156
- )
1157
-
1158
- subparsers = parser.add_subparsers(dest="command", help="Command to run")
1159
-
1160
- # Watch command
1161
- parser_watch = subparsers.add_parser(
1162
- "watch",
1163
- help="Watch directory for new intake forms",
1164
- )
1165
- _add_common_args(parser_watch)
1166
- parser_watch.set_defaults(func=cmd_watch)
1167
-
1168
- # Process command
1169
- parser_process = subparsers.add_parser(
1170
- "process",
1171
- help="Process a single intake form",
1172
- )
1173
- _add_common_args(parser_process)
1174
- parser_process.add_argument("file", help="Path to intake form file")
1175
- parser_process.set_defaults(func=cmd_process)
1176
-
1177
- # Query command
1178
- parser_query = subparsers.add_parser(
1179
- "query",
1180
- help="Query patient database",
1181
- )
1182
- _add_common_args(parser_query)
1183
- parser_query.add_argument("question", help="Question to ask")
1184
- parser_query.set_defaults(func=cmd_query)
1185
-
1186
- # Stats command
1187
- parser_stats = subparsers.add_parser(
1188
- "stats",
1189
- help="Show processing statistics",
1190
- )
1191
- _add_common_args(parser_stats)
1192
- parser_stats.set_defaults(func=cmd_stats)
1193
-
1194
- # Reset command
1195
- parser_reset = subparsers.add_parser(
1196
- "reset",
1197
- help="Clear all patient data from the database",
1198
- )
1199
- _add_common_args(parser_reset)
1200
- parser_reset.add_argument(
1201
- "--force",
1202
- "-f",
1203
- action="store_true",
1204
- help="Skip confirmation prompt",
1205
- )
1206
- parser_reset.set_defaults(func=cmd_reset)
1207
-
1208
- # Init command
1209
- parser_init = subparsers.add_parser(
1210
- "init",
1211
- help="Download and setup required VLM models",
1212
- )
1213
- parser_init.add_argument(
1214
- "--vlm-model",
1215
- default="Qwen3-VL-4B-Instruct-GGUF",
1216
- help="VLM model to download (default: Qwen3-VL-4B-Instruct-GGUF)",
1217
- )
1218
- parser_init.add_argument(
1219
- "--debug",
1220
- action="store_true",
1221
- help="Enable debug logging",
1222
- )
1223
- parser_init.set_defaults(func=cmd_init)
1224
-
1225
- # Test command
1226
- parser_test = subparsers.add_parser(
1227
- "test",
1228
- help="Test VLM extraction pipeline on a single file",
1229
- )
1230
- parser_test.add_argument(
1231
- "file",
1232
- help="Path to intake form image (PNG, JPG, PDF)",
1233
- )
1234
- parser_test.add_argument(
1235
- "--vlm-model",
1236
- default="Qwen3-VL-4B-Instruct-GGUF",
1237
- help="VLM model to use (default: Qwen3-VL-4B-Instruct-GGUF)",
1238
- )
1239
- parser_test.add_argument(
1240
- "--max-dimension",
1241
- type=int,
1242
- default=1024,
1243
- help="Max image dimension in pixels (default: 1024)",
1244
- )
1245
- parser_test.add_argument(
1246
- "--jpeg-quality",
1247
- type=int,
1248
- default=85,
1249
- help="JPEG compression quality (default: 85)",
1250
- )
1251
- parser_test.add_argument(
1252
- "--clear-context",
1253
- action="store_true",
1254
- help="Clear VLM context before processing (fixes memory errors)",
1255
- )
1256
- parser_test.add_argument(
1257
- "--debug",
1258
- action="store_true",
1259
- help="Enable debug logging",
1260
- )
1261
- parser_test.set_defaults(func=cmd_test)
1262
-
1263
- # Dashboard command
1264
- parser_dashboard = subparsers.add_parser(
1265
- "dashboard",
1266
- help="Start web dashboard",
1267
- )
1268
- _add_common_args(parser_dashboard)
1269
- parser_dashboard.add_argument(
1270
- "--host",
1271
- default="127.0.0.1",
1272
- help="Dashboard host (default: 127.0.0.1)",
1273
- )
1274
- parser_dashboard.add_argument(
1275
- "--port",
1276
- type=int,
1277
- default=8080,
1278
- help="Dashboard port (default: 8080)",
1279
- )
1280
- parser_dashboard.add_argument(
1281
- "--no-open",
1282
- action="store_true",
1283
- help="Don't automatically open dashboard",
1284
- )
1285
- parser_dashboard.add_argument(
1286
- "--browser",
1287
- action="store_true",
1288
- help="Open in web browser instead of Electron app",
1289
- )
1290
- parser_dashboard.set_defaults(func=cmd_dashboard)
1291
-
1292
- args = parser.parse_args()
1293
-
1294
- # Run command
1295
- if not args.command:
1296
- parser.print_help()
1297
- return 0
1298
-
1299
- # Configure logging - WARNING by default, DEBUG with --debug flag
1300
- if getattr(args, "debug", False):
1301
- logging.basicConfig(
1302
- level=logging.DEBUG,
1303
- format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
1304
- datefmt="%H:%M:%S",
1305
- )
1306
- else:
1307
- # Suppress all logs from gaia modules for cleaner output
1308
- logging.basicConfig(level=logging.WARNING)
1309
- for logger_name in [
1310
- "gaia",
1311
- "gaia.llm",
1312
- "gaia.database",
1313
- "gaia.agents",
1314
- "gaia.utils",
1315
- ]:
1316
- logging.getLogger(logger_name).setLevel(logging.WARNING)
1317
-
1318
- return args.func(args)
1319
-
1320
-
1321
- if __name__ == "__main__":
1322
- sys.exit(main())
1
+ # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """CLI for Medical Intake Agent."""
5
+
6
+ import argparse
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from gaia.agents.emr.agent import MedicalIntakeAgent
16
+
17
+ logger = logging.getLogger(__name__)
18
+ console = Console()
19
+
20
+
21
+ def _print_header(watch_dir: str, db_path: str):
22
+ """Print a styled header for the CLI."""
23
+ console.print()
24
+ console.print(
25
+ Panel.fit(
26
+ "[bold cyan]Medical Intake Agent[/bold cyan]\n"
27
+ "[dim]Automatic Patient Form Processing[/dim]",
28
+ border_style="cyan",
29
+ )
30
+ )
31
+
32
+ # Status table
33
+ table = Table(show_header=False, box=None, padding=(0, 2))
34
+ table.add_column(style="dim")
35
+ table.add_column()
36
+ table.add_row("📁 Watch folder:", watch_dir)
37
+ table.add_row("💾 Database:", db_path)
38
+ console.print(table)
39
+ console.print()
40
+
41
+ # Commands help
42
+ console.print("[dim]Commands:[/dim]")
43
+ console.print(" [cyan]stats[/cyan] Show processing statistics")
44
+ console.print(" [cyan]quit[/cyan] Stop and exit")
45
+ console.print(" [dim]Or type questions about patients[/dim]")
46
+ console.print()
47
+
48
+
49
+ def _print_prompt():
50
+ """Print the input prompt with visual separators."""
51
+ console.print("─" * 80, style="dim")
52
+ console.print("> ", end="", style="bold green")
53
+ sys.stdout.flush() # Ensure prompt is displayed before input() blocks
54
+
55
+
56
+ def cmd_watch(args):
57
+ """Start watching directory for intake forms."""
58
+ _print_header(args.watch_dir, args.db)
59
+
60
+ console.print("[dim]Starting agent...[/dim]")
61
+
62
+ agent = MedicalIntakeAgent(
63
+ watch_dir=args.watch_dir,
64
+ db_path=args.db,
65
+ vlm_model=args.vlm_model,
66
+ )
67
+
68
+ console.print("[green]✓ Ready![/green] Drop intake forms to process them.\n")
69
+ sys.stdout.flush() # Ensure Ready message appears before prompt
70
+
71
+ try:
72
+ while True:
73
+ try:
74
+ _print_prompt()
75
+ user_input = input().strip()
76
+ except EOFError:
77
+ break
78
+
79
+ if not user_input:
80
+ continue
81
+
82
+ if user_input.lower() in ("quit", "exit", "q"):
83
+ break
84
+
85
+ console.print("─" * 80, style="dim")
86
+
87
+ if user_input.lower() == "stats":
88
+ cmd_stats_inline(agent)
89
+ else:
90
+ # Process the query
91
+ agent.process_query(user_input)
92
+ print()
93
+
94
+ except KeyboardInterrupt:
95
+ print()
96
+ finally:
97
+ console.print("[dim]Stopping agent...[/dim]")
98
+ agent.stop()
99
+ console.print("[green]✓ Stopped[/green]")
100
+
101
+
102
+ def cmd_stats_inline(agent):
103
+ """Show stats inline during watch mode."""
104
+ try:
105
+ stats = agent.get_stats()
106
+ _print_stats_table(stats)
107
+ except Exception as e:
108
+ console.print(f"[red]Error getting stats: {e}[/red]")
109
+
110
+
111
+ def cmd_process(args):
112
+ """Process a single intake form file."""
113
+ if not Path(args.file).exists():
114
+ console.print(f"[red]Error: File not found: {args.file}[/red]")
115
+ return 1
116
+
117
+ console.print(f"[dim]Processing: {args.file}[/dim]")
118
+
119
+ agent = MedicalIntakeAgent(
120
+ watch_dir=args.watch_dir,
121
+ db_path=args.db,
122
+ vlm_model=args.vlm_model,
123
+ auto_start_watching=False,
124
+ )
125
+
126
+ try:
127
+ # pylint: disable=protected-access
128
+ patient_data = agent._process_intake_form(args.file)
129
+
130
+ if patient_data:
131
+ # Agent already prints success and patient details
132
+ return 0
133
+ else:
134
+ console.print(f"[red]Failed to process: {args.file}[/red]")
135
+ return 1
136
+
137
+ finally:
138
+ agent.stop()
139
+
140
+
141
+ def cmd_query(args):
142
+ """Query patient database."""
143
+ agent = MedicalIntakeAgent(
144
+ watch_dir=args.watch_dir,
145
+ db_path=args.db,
146
+ auto_start_watching=False,
147
+ )
148
+
149
+ try:
150
+ agent.process_query(args.question)
151
+ return 0
152
+ finally:
153
+ agent.stop()
154
+
155
+
156
+ def _print_stats_table(stats: dict):
157
+ """Print statistics using Rich formatting."""
158
+ console.print()
159
+
160
+ # Time savings highlight
161
+ time_table = Table(show_header=False, box=None, padding=(0, 1))
162
+ time_table.add_column(style="bold green")
163
+ time_table.add_column(style="green")
164
+ time_table.add_row(
165
+ f"⏱️ {stats['time_saved_minutes']} min saved",
166
+ f"({stats['time_saved_percent']} faster)",
167
+ )
168
+ console.print(Panel(time_table, title="Time Savings", border_style="green"))
169
+
170
+ # Main stats grid
171
+ grid = Table.grid(expand=True, padding=(0, 2))
172
+ grid.add_column()
173
+ grid.add_column()
174
+
175
+ # Patients table
176
+ patients = Table(show_header=False, box=None)
177
+ patients.add_column(style="dim")
178
+ patients.add_column(style="bold")
179
+ patients.add_row("Total", str(stats["total_patients"]))
180
+ patients.add_row("New", str(stats["new_patients"]))
181
+ patients.add_row("Returning", str(stats["returning_patients"]))
182
+ patients.add_row("Today", str(stats["processed_today"]))
183
+
184
+ # Processing table
185
+ processing = Table(show_header=False, box=None)
186
+ processing.add_column(style="dim")
187
+ processing.add_column(style="bold")
188
+ processing.add_row("Processed", str(stats["files_processed"]))
189
+ processing.add_row("Success", str(stats["extraction_success"]))
190
+ processing.add_row("Failed", str(stats["extraction_failed"]))
191
+ processing.add_row("Rate", stats["success_rate"])
192
+
193
+ grid.add_row(
194
+ Panel(patients, title="👥 Patients", border_style="cyan"),
195
+ Panel(processing, title="📋 Processing", border_style="cyan"),
196
+ )
197
+ console.print(grid)
198
+
199
+ # Alerts (if any)
200
+ if stats.get("unacknowledged_alerts", 0) > 0:
201
+ console.print(
202
+ f"[bold red]🚨 {stats['unacknowledged_alerts']} unacknowledged alert(s)[/bold red]"
203
+ )
204
+ console.print()
205
+
206
+
207
+ def cmd_stats(args):
208
+ """Show processing statistics."""
209
+ agent = MedicalIntakeAgent(
210
+ watch_dir=args.watch_dir,
211
+ db_path=args.db,
212
+ auto_start_watching=False,
213
+ silent_mode=True,
214
+ )
215
+
216
+ try:
217
+ stats = agent.get_stats()
218
+ _print_stats_table(stats)
219
+ return 0
220
+ finally:
221
+ agent.stop()
222
+
223
+
224
+ def cmd_reset(args):
225
+ """Reset by deleting the database file."""
226
+ import os
227
+
228
+ from rich.prompt import Confirm
229
+
230
+ db_path = Path(args.db)
231
+
232
+ # Check if database exists
233
+ if not db_path.exists():
234
+ console.print("[dim]Database file does not exist. Nothing to reset.[/dim]")
235
+ return 0
236
+
237
+ # Get stats before deletion to show what will be deleted
238
+ total_patients = 0
239
+ agent = None
240
+ try:
241
+ agent = MedicalIntakeAgent(
242
+ watch_dir=args.watch_dir,
243
+ db_path=args.db,
244
+ auto_start_watching=False,
245
+ silent_mode=True,
246
+ )
247
+ stats = agent.get_stats()
248
+ total_patients = stats.get("total_patients", 0)
249
+ except Exception:
250
+ pass # If we can't read stats, proceed anyway
251
+ finally:
252
+ if agent:
253
+ agent.stop()
254
+
255
+ # Confirmation prompt unless --force is used
256
+ if not args.force:
257
+ console.print()
258
+ console.print(
259
+ "[bold yellow]⚠️ WARNING:[/bold yellow] This will permanently delete:"
260
+ )
261
+ if total_patients > 0:
262
+ console.print(f" • {total_patients} patient record(s)")
263
+ console.print(" • All associated alerts and intake sessions")
264
+ console.print(f" • Database file: {db_path}")
265
+ console.print()
266
+
267
+ if not Confirm.ask("[bold red]Are you sure you want to continue?[/bold red]"):
268
+ console.print("[dim]Cancelled.[/dim]")
269
+ return 0
270
+
271
+ # Delete the database file
272
+ try:
273
+ os.remove(db_path)
274
+ console.print()
275
+ console.print("[bold green]✓ Database deleted successfully[/bold green]")
276
+ console.print(f" Removed: {db_path}")
277
+ console.print()
278
+ console.print(
279
+ "[dim]A fresh database will be created when you next run the agent.[/dim]"
280
+ )
281
+ return 0
282
+ except Exception as e:
283
+ console.print(f"[red]Error deleting database: {e}[/red]")
284
+ return 1
285
+
286
+
287
+ def cmd_init(args):
288
+ """Initialize EMR agent by downloading and loading required models."""
289
+ import time
290
+
291
+ from gaia.llm.lemonade_client import LemonadeClient
292
+
293
+ console.print()
294
+ console.print(
295
+ Panel.fit(
296
+ "[bold cyan]EMR Agent Setup[/bold cyan]\n"
297
+ "[dim]Downloading and loading required models[/dim]",
298
+ border_style="cyan",
299
+ )
300
+ )
301
+ console.print()
302
+
303
+ # Required models for EMR agent
304
+ vlm_model = args.vlm_model # Default: Qwen3-VL-4B-Instruct-GGUF
305
+ llm_model = "Qwen3-Coder-30B-A3B-Instruct-GGUF" # For chat/query processing
306
+ embed_model = "nomic-embed-text-v2-moe-GGUF" # For similarity search
307
+
308
+ REQUIRED_CONTEXT_SIZE = 32768
309
+
310
+ # Step 1: Check Lemonade server and context size
311
+ console.print("[bold]Step 1:[/bold] Checking Lemonade server...")
312
+ try:
313
+ client = LemonadeClient(model=vlm_model)
314
+ health = client.health_check()
315
+ if health.get("status") == "ok":
316
+ console.print(" [green]✓[/green] Lemonade server is running")
317
+
318
+ # Check context size
319
+ context_size = health.get("context_size", 0)
320
+ if context_size >= REQUIRED_CONTEXT_SIZE:
321
+ console.print(
322
+ f" [green]✓[/green] Context size: [cyan]{context_size:,}[/cyan] tokens (recommended: {REQUIRED_CONTEXT_SIZE:,})"
323
+ )
324
+ elif context_size > 0:
325
+ console.print(
326
+ f" [yellow]⚠[/yellow] Context size: [yellow]{context_size:,}[/yellow] tokens"
327
+ )
328
+ console.print(
329
+ f" [yellow]Warning:[/yellow] Context size should be at least [cyan]{REQUIRED_CONTEXT_SIZE:,}[/cyan] for reliable form processing"
330
+ )
331
+ console.print(
332
+ " [dim]To fix: Right-click Lemonade tray → Settings → Context Size → 32768[/dim]"
333
+ )
334
+ else:
335
+ console.print(
336
+ " [dim]Context size: Not reported (will check after model load)[/dim]"
337
+ )
338
+ else:
339
+ console.print(" [red]✗[/red] Lemonade server not responding")
340
+ console.print()
341
+ console.print("[yellow]Please start Lemonade server first:[/yellow]")
342
+ console.print(" 1. Open Lemonade from the system tray")
343
+ console.print(" 2. Or run: [cyan]lemonade-server[/cyan]")
344
+ return 1
345
+ except Exception as e:
346
+ console.print(f" [red]✗[/red] Cannot connect to Lemonade: {e}")
347
+ console.print()
348
+ console.print("[yellow]Please start Lemonade server first:[/yellow]")
349
+ console.print(" 1. Open Lemonade from the system tray")
350
+ console.print(" 2. Or run: [cyan]lemonade-server[/cyan]")
351
+ return 1
352
+
353
+ # Step 2: Check required models
354
+ console.print()
355
+ console.print("[bold]Step 2:[/bold] Checking required models...")
356
+
357
+ try:
358
+ models_response = client.list_models()
359
+ available_models = models_response.get("data", [])
360
+ downloaded_model_ids = [m.get("id", "") for m in available_models]
361
+
362
+ # Check each required model
363
+ required_models = [
364
+ ("VLM", vlm_model, "Form extraction"),
365
+ ("LLM", llm_model, "Chat/query processing"),
366
+ ("Embedding", embed_model, "Similarity search"),
367
+ ]
368
+
369
+ models_to_download = []
370
+ for model_type, model_name, _purpose in required_models:
371
+ is_downloaded = model_name in downloaded_model_ids
372
+ if is_downloaded:
373
+ console.print(
374
+ f" [green]✓[/green] {model_type}: [cyan]{model_name}[/cyan]"
375
+ )
376
+ else:
377
+ console.print(
378
+ f" [dim]○[/dim] {model_type}: [cyan]{model_name}[/cyan] [dim](not downloaded)[/dim]"
379
+ )
380
+ models_to_download.append((model_type, model_name))
381
+
382
+ if models_to_download:
383
+ console.print()
384
+ console.print(
385
+ f" [yellow]⚠️ {len(models_to_download)} model(s) need to be downloaded[/yellow]"
386
+ )
387
+
388
+ except Exception as e:
389
+ console.print(f" [red]✗[/red] Failed to check models: {e}")
390
+ return 1
391
+
392
+ # Step 3: Load all required models
393
+ console.print()
394
+ console.print("[bold]Step 3:[/bold] Loading required models...")
395
+ console.print(" [dim]Loading models into memory for fast inference...[/dim]")
396
+ console.print()
397
+
398
+ models_loaded = {}
399
+
400
+ # Load VLM model first (most important for form processing)
401
+ for model_type, model_name in [
402
+ ("VLM", vlm_model),
403
+ ("LLM", llm_model),
404
+ ("Embedding", embed_model),
405
+ ]:
406
+ console.print(f" Loading {model_type}: [cyan]{model_name}[/cyan]...")
407
+
408
+ try:
409
+ start_time = time.time()
410
+ client.load_model(model_name, timeout=1800, auto_download=True)
411
+ elapsed = time.time() - start_time
412
+ models_loaded[model_type] = True
413
+ console.print(f" [green]✓[/green] {model_type} loaded ({elapsed:.1f}s)")
414
+ except Exception as e:
415
+ error_msg = str(e)
416
+ models_loaded[model_type] = False
417
+
418
+ # Check for common errors
419
+ if "being used by another process" in error_msg:
420
+ console.print(
421
+ f" [yellow]![/yellow] {model_type}: File locked, try again later"
422
+ )
423
+ elif (
424
+ "not found" in error_msg.lower()
425
+ or "does not exist" in error_msg.lower()
426
+ ):
427
+ console.print(
428
+ f" [yellow]![/yellow] {model_type}: Model not available in registry"
429
+ )
430
+ else:
431
+ console.print(f" [yellow]![/yellow] {model_type}: {error_msg[:50]}...")
432
+
433
+ # Check if critical models loaded
434
+ if not models_loaded.get("VLM"):
435
+ console.print()
436
+ console.print(
437
+ "[red]✗ VLM model failed to load - form processing will not work[/red]"
438
+ )
439
+ return 1
440
+
441
+ # Clear VLM context to ensure fresh memory allocation
442
+ console.print()
443
+ console.print(" [dim]Clearing VLM context for clean memory...[/dim]")
444
+ try:
445
+ client.unload_model()
446
+ client.load_model(vlm_model, timeout=300, auto_download=True)
447
+ console.print(" [green]✓[/green] VLM context cleared")
448
+ except Exception as e:
449
+ console.print(f" [dim]Context clear skipped: {e}[/dim]")
450
+
451
+ # Step 4: Verify models and check context size
452
+ console.print()
453
+ console.print("[bold]Step 4:[/bold] Verifying models are ready...")
454
+
455
+ vlm_ready = False
456
+ llm_ready = False
457
+ embed_ready = False
458
+ final_context_size = 0
459
+
460
+ try:
461
+ # Check health for context size
462
+ health = client.health_check()
463
+ final_context_size = health.get("context_size", 0)
464
+
465
+ # Check each model
466
+ vlm_ready = client.check_model_loaded(vlm_model)
467
+ llm_ready = client.check_model_loaded(llm_model)
468
+ embed_ready = client.check_model_loaded(embed_model)
469
+
470
+ if vlm_ready:
471
+ console.print(" [green]✓[/green] VLM: Ready for form extraction")
472
+ else:
473
+ console.print(" [yellow]![/yellow] VLM: Will load on first use")
474
+
475
+ if llm_ready:
476
+ console.print(" [green]✓[/green] LLM: Ready for chat queries")
477
+ else:
478
+ console.print(" [dim]○[/dim] LLM: Will load on first use")
479
+
480
+ if embed_ready:
481
+ console.print(" [green]✓[/green] Embedding: Ready for search")
482
+ else:
483
+ console.print(" [dim]○[/dim] Embedding: Will load on first use")
484
+
485
+ # Report context size
486
+ if final_context_size >= REQUIRED_CONTEXT_SIZE:
487
+ console.print(
488
+ f" [green]✓[/green] Context size: [cyan]{final_context_size:,}[/cyan] tokens"
489
+ )
490
+ elif final_context_size > 0:
491
+ console.print(
492
+ f" [yellow]⚠[/yellow] Context size: [yellow]{final_context_size:,}[/yellow] tokens (need {REQUIRED_CONTEXT_SIZE:,})"
493
+ )
494
+
495
+ except Exception as e:
496
+ console.print(f" [yellow]![/yellow] Could not verify: {e}")
497
+
498
+ # Step 5: Show all downloaded and loaded models
499
+ console.print()
500
+ console.print("[bold]Step 5:[/bold] Model inventory...")
501
+
502
+ try:
503
+ models_response = client.list_models()
504
+ all_models = models_response.get("data", [])
505
+
506
+ # Categorize models
507
+ vlm_models = []
508
+ llm_models = []
509
+ embed_models = []
510
+
511
+ for m in all_models:
512
+ model_id = m.get("id", "")
513
+ model_lower = model_id.lower()
514
+
515
+ if "vl" in model_lower or "vision" in model_lower or "vlm" in model_lower:
516
+ vlm_models.append(model_id)
517
+ elif (
518
+ "embed" in model_lower
519
+ or "bge" in model_lower
520
+ or "e5" in model_lower
521
+ or "nomic" in model_lower
522
+ ):
523
+ embed_models.append(model_id)
524
+ else:
525
+ llm_models.append(model_id)
526
+
527
+ # Show categorized models
528
+ if vlm_models:
529
+ console.print(
530
+ f" [cyan]VLM Models:[/cyan] {', '.join(vlm_models[:3])}"
531
+ + (f" (+{len(vlm_models)-3} more)" if len(vlm_models) > 3 else "")
532
+ )
533
+ if llm_models:
534
+ console.print(
535
+ f" [cyan]LLM Models:[/cyan] {', '.join(llm_models[:3])}"
536
+ + (f" (+{len(llm_models)-3} more)" if len(llm_models) > 3 else "")
537
+ )
538
+ if embed_models:
539
+ console.print(
540
+ f" [cyan]Embedding Models:[/cyan] {', '.join(embed_models[:3])}"
541
+ + (f" (+{len(embed_models)-3} more)" if len(embed_models) > 3 else "")
542
+ )
543
+
544
+ console.print(f" [dim]Total models available: {len(all_models)}[/dim]")
545
+
546
+ except Exception as e:
547
+ console.print(f" [dim]Could not list models: {e}[/dim]")
548
+
549
+ # Success summary
550
+ console.print()
551
+
552
+ # Build model status lines
553
+ model_status_lines = []
554
+
555
+ # VLM status
556
+ if vlm_ready:
557
+ model_status_lines.append(
558
+ f"[green]✓[/green] VLM: [cyan]{vlm_model}[/cyan] - Ready"
559
+ )
560
+ else:
561
+ model_status_lines.append(
562
+ f"[yellow]![/yellow] VLM: [cyan]{vlm_model}[/cyan] - Will load on first use"
563
+ )
564
+
565
+ # LLM status
566
+ if llm_ready:
567
+ model_status_lines.append(
568
+ f"[green]✓[/green] LLM: [cyan]{llm_model}[/cyan] - Ready"
569
+ )
570
+ else:
571
+ model_status_lines.append(
572
+ f"[dim]○[/dim] LLM: [cyan]{llm_model}[/cyan] - Will load on first use"
573
+ )
574
+
575
+ # Embedding status
576
+ if embed_ready:
577
+ model_status_lines.append(
578
+ f"[green]✓[/green] Embedding: [cyan]{embed_model}[/cyan] - Ready"
579
+ )
580
+ else:
581
+ model_status_lines.append(
582
+ f"[dim]○[/dim] Embedding: [cyan]{embed_model}[/cyan] - Will load on first use"
583
+ )
584
+
585
+ # Context size status
586
+ if final_context_size >= REQUIRED_CONTEXT_SIZE:
587
+ model_status_lines.append(
588
+ f"[green]✓[/green] Context size: {final_context_size:,} tokens"
589
+ )
590
+ elif final_context_size > 0:
591
+ model_status_lines.append(
592
+ f"[yellow]⚠[/yellow] Context size: {final_context_size:,} tokens (need {REQUIRED_CONTEXT_SIZE:,})"
593
+ )
594
+
595
+ # Count ready models
596
+ ready_count = sum([vlm_ready, llm_ready, embed_ready])
597
+
598
+ console.print(
599
+ Panel.fit(
600
+ f"[bold green]✓ EMR Agent initialized ({ready_count}/3 models ready)[/bold green]\n\n"
601
+ + "\n".join(model_status_lines)
602
+ + "\n\n"
603
+ "[dim]You can now run:[/dim]\n"
604
+ " [cyan]gaia-emr dashboard[/cyan] - Start the web dashboard\n"
605
+ " [cyan]gaia-emr watch[/cyan] - Watch folder for intake forms\n"
606
+ " [cyan]gaia-emr process <file>[/cyan] - Process a single file",
607
+ border_style="green",
608
+ )
609
+ )
610
+ console.print()
611
+
612
+ # Context size warning if needed
613
+ if 0 < final_context_size < REQUIRED_CONTEXT_SIZE:
614
+ console.print(
615
+ Panel.fit(
616
+ "[yellow]⚠️ Context Size Warning[/yellow]\n\n"
617
+ f"Current context size ({final_context_size:,}) may be too small for processing intake forms.\n"
618
+ "Large images can require 4,000-8,000+ tokens.\n\n"
619
+ "[bold]To fix:[/bold]\n"
620
+ " 1. Right-click Lemonade tray icon → Settings\n"
621
+ " 2. Set Context Size to [cyan]32768[/cyan]\n"
622
+ " 3. Click Apply and restart the model",
623
+ border_style="yellow",
624
+ )
625
+ )
626
+ console.print()
627
+
628
+ return 0
629
+
630
+
631
+ def cmd_test(args):
632
+ """Test VLM extraction pipeline on a single file."""
633
+ import io
634
+ import json
635
+ import time
636
+
637
+ from PIL import Image
638
+
639
+ from gaia.llm.vlm_client import VLMClient
640
+
641
+ file_path = Path(args.file)
642
+ if not file_path.exists():
643
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
644
+ return 1
645
+
646
+ console.print()
647
+ console.print(
648
+ Panel.fit(
649
+ "[bold cyan]EMR Agent - VLM Pipeline Test[/bold cyan]\n"
650
+ f"[dim]Testing extraction on: {file_path.name}[/dim]",
651
+ border_style="cyan",
652
+ )
653
+ )
654
+ console.print()
655
+
656
+ # Step 1: Read and analyze file
657
+ console.print("[bold]Step 1:[/bold] Reading file...")
658
+ try:
659
+ raw_bytes = file_path.read_bytes()
660
+ file_size_kb = len(raw_bytes) / 1024
661
+ console.print(f" File size: {file_size_kb:.1f} KB")
662
+
663
+ # Get image dimensions
664
+ img = Image.open(io.BytesIO(raw_bytes))
665
+ orig_width, orig_height = img.size
666
+ console.print(f" Dimensions: {orig_width}x{orig_height} pixels")
667
+
668
+ # Auto-rotate based on EXIF orientation
669
+ try:
670
+ from PIL import ExifTags
671
+
672
+ exif = img._getexif() # pylint: disable=protected-access
673
+ if exif:
674
+ for tag, value in exif.items():
675
+ if ExifTags.TAGS.get(tag) == "Orientation":
676
+ if value == 3:
677
+ img = img.rotate(180, expand=True)
678
+ console.print(" [dim]Auto-rotated 180°[/dim]")
679
+ elif value == 6:
680
+ img = img.rotate(270, expand=True)
681
+ console.print(" [dim]Auto-rotated 90° CW[/dim]")
682
+ elif value == 8:
683
+ img = img.rotate(90, expand=True)
684
+ console.print(" [dim]Auto-rotated 90° CCW[/dim]")
685
+ orig_width, orig_height = img.size
686
+ break
687
+ except Exception:
688
+ pass # No EXIF or rotation info
689
+ except Exception as e:
690
+ console.print(f" [red]✗[/red] Failed to read file: {e}")
691
+ return 1
692
+
693
+ # Step 2: Optimize image (same as agent)
694
+ console.print()
695
+ console.print("[bold]Step 2:[/bold] Optimizing image...")
696
+ max_dimension = args.max_dimension
697
+ jpeg_quality = args.jpeg_quality
698
+
699
+ try:
700
+ if orig_width > max_dimension or orig_height > max_dimension:
701
+ scale = min(max_dimension / orig_width, max_dimension / orig_height)
702
+ new_width = int(orig_width * scale)
703
+ new_height = int(orig_height * scale)
704
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
705
+ console.print(
706
+ f" Resized: {orig_width}x{orig_height} → {new_width}x{new_height}"
707
+ )
708
+ else:
709
+ new_width, new_height = orig_width, orig_height
710
+ console.print(f" No resize needed (under {max_dimension}px)")
711
+
712
+ # Convert to RGB and JPEG
713
+ if img.mode in ("RGBA", "P"):
714
+ img = img.convert("RGB")
715
+
716
+ output = io.BytesIO()
717
+ img.save(output, format="JPEG", quality=jpeg_quality, optimize=True)
718
+ image_bytes = output.getvalue()
719
+
720
+ opt_size_kb = len(image_bytes) / 1024
721
+ reduction = (1 - opt_size_kb / file_size_kb) * 100
722
+ console.print(
723
+ f" Optimized: {file_size_kb:.0f}KB → {opt_size_kb:.0f}KB ({reduction:.0f}% smaller)"
724
+ )
725
+
726
+ # Estimate image tokens (rough: ~1 token per 14x14 pixel patch)
727
+ est_img_tokens = (new_width // 14) * (new_height // 14)
728
+ console.print(f" Est. image tokens: ~{est_img_tokens:,}")
729
+ except Exception as e:
730
+ console.print(f" [red]✗[/red] Failed to optimize: {e}")
731
+ return 1
732
+
733
+ # Step 3: Initialize VLM
734
+ console.print()
735
+ console.print("[bold]Step 3:[/bold] Initializing VLM...")
736
+ try:
737
+ vlm = VLMClient(vlm_model=args.vlm_model)
738
+
739
+ # Clear context if requested (unload and reload model)
740
+ if getattr(args, "clear_context", False):
741
+ console.print(" [dim]Clearing VLM context (unload + reload)...[/dim]")
742
+ try:
743
+ vlm.client.unload_model()
744
+ vlm.client.load_model(args.vlm_model, timeout=300, auto_download=True)
745
+ console.print(" [green]✓[/green] Context cleared")
746
+ except Exception as e:
747
+ console.print(f" [yellow]⚠[/yellow] Could not clear context: {e}")
748
+
749
+ console.print(f" [green]✓[/green] VLM ready: [cyan]{vlm.vlm_model}[/cyan]")
750
+ except Exception as e:
751
+ console.print(f" [red]✗[/red] Failed to initialize VLM: {e}")
752
+ return 1
753
+
754
+ # Step 4: Extract data with auto-retry on memory errors
755
+ console.print()
756
+ console.print("[bold]Step 4:[/bold] Extracting patient data...")
757
+
758
+ extraction_prompt = """Extract ALL patient information from this medical intake form.
759
+
760
+ Return a JSON object with these fields (use null for missing/unclear):
761
+ {
762
+ "form_date": "YYYY-MM-DD (date form was filled, today's date)",
763
+ "first_name": "...",
764
+ "last_name": "...",
765
+ "date_of_birth": "YYYY-MM-DD",
766
+ "age": "patient's age if listed",
767
+ "gender": "Male/Female/Other",
768
+ "preferred_pronouns": "he/him, she/her, they/them if listed",
769
+ "ssn": "XXX-XX-XXXX (social security number)",
770
+ "marital_status": "Single/Married/Divorced/Widowed/Partnered",
771
+ "spouse_name": "spouse's name if listed",
772
+ "phone": "home phone number",
773
+ "mobile_phone": "cell/mobile phone number",
774
+ "work_phone": "work phone number",
775
+ "email": "...",
776
+ "address": "street address",
777
+ "city": "...",
778
+ "state": "...",
779
+ "zip_code": "...",
780
+ "preferred_language": "English/Spanish/etc if listed",
781
+ "race": "if listed",
782
+ "ethnicity": "if listed",
783
+ "contact_preference": "preferred contact method if listed",
784
+ "emergency_contact_name": "name of emergency contact person",
785
+ "emergency_contact_relationship": "relationship to patient (e.g. Mom, Spouse, Friend)",
786
+ "emergency_contact_phone": "emergency contact's phone number",
787
+ "referring_physician": "name of referring physician/doctor",
788
+ "referring_physician_phone": "phone number next to referring physician",
789
+ "primary_care_physician": "PCP name if different from referring",
790
+ "preferred_pharmacy": "pharmacy name if listed",
791
+ "employment_status": "Employed/Self Employed/Unemployed/Retired/Student/Disabled/Military",
792
+ "occupation": "job title if listed",
793
+ "employer": "employer/company name",
794
+ "employer_address": "employer address if listed",
795
+ "insurance_provider": "insurance company name",
796
+ "insurance_id": "policy number",
797
+ "insurance_group_number": "group number",
798
+ "insured_name": "name of insured person (may differ from patient)",
799
+ "insured_dob": "YYYY-MM-DD",
800
+ "insurance_phone": "insurance contact number",
801
+ "billing_address": "billing address if different from home",
802
+ "guarantor_name": "person responsible for payment if listed",
803
+ "reason_for_visit": "chief complaint or reason for visit",
804
+ "date_of_injury": "YYYY-MM-DD (date of injury or onset of symptoms)",
805
+ "pain_location": "where pain is located if listed",
806
+ "pain_onset": "when pain began (e.g. three months ago)",
807
+ "pain_cause": "what caused the pain/condition",
808
+ "pain_progression": "Improved/Worsened/Stayed the same",
809
+ "work_related_injury": "Yes/No",
810
+ "car_accident": "Yes/No",
811
+ "medical_conditions": "existing medical conditions",
812
+ "allergies": "known allergies",
813
+ "medications": "current medications",
814
+ "signature_date": "YYYY-MM-DD (date signed)"
815
+ }
816
+
817
+ IMPORTANT: Return ONLY the JSON object, no other text."""
818
+
819
+ # Retry loop with progressively smaller images on memory errors
820
+ max_retries = 3
821
+ current_img = img
822
+ current_bytes = image_bytes
823
+ current_width, current_height = new_width, new_height
824
+ current_size_kb = opt_size_kb
825
+
826
+ for attempt in range(max_retries):
827
+ est_img_tokens = (current_width // 14) * (current_height // 14)
828
+ console.print(
829
+ f" Image: {current_width}x{current_height}, {current_size_kb:.0f}KB (~{est_img_tokens:,} tokens)"
830
+ )
831
+
832
+ if attempt == 0:
833
+ console.print(" [dim]This may take 30-60 seconds...[/dim]")
834
+ else:
835
+ console.print(
836
+ f" [dim]Retry {attempt}/{max_retries-1} with smaller image...[/dim]"
837
+ )
838
+
839
+ try:
840
+ start_time = time.time()
841
+ raw_text = vlm.extract_from_image(
842
+ image_bytes=current_bytes,
843
+ prompt=extraction_prompt,
844
+ )
845
+ extraction_time = time.time() - start_time
846
+
847
+ # Check for memory-related errors
848
+ if (
849
+ "failed to process image" in raw_text
850
+ or "memory slot" in raw_text.lower()
851
+ ):
852
+ if attempt < max_retries - 1:
853
+ console.print(
854
+ " [yellow]⚠[/yellow] Memory error, reducing image size..."
855
+ )
856
+ # Reduce image to 75% of current size
857
+ scale = 0.75
858
+ current_width = int(current_width * scale)
859
+ current_height = int(current_height * scale)
860
+ current_img = current_img.resize(
861
+ (current_width, current_height), Image.Resampling.LANCZOS
862
+ )
863
+ output = io.BytesIO()
864
+ current_img.save(
865
+ output, format="JPEG", quality=jpeg_quality, optimize=True
866
+ )
867
+ current_bytes = output.getvalue()
868
+ current_size_kb = len(current_bytes) / 1024
869
+ continue
870
+ else:
871
+ console.print(f" [red]✗[/red] {raw_text}")
872
+ console.print()
873
+ console.print("[yellow]Suggestions:[/yellow]")
874
+ console.print(" 1. Try with smaller image: --max-dimension 640")
875
+ console.print(" 2. Restart Lemonade Server to clear memory")
876
+ console.print(" 3. Reload the VLM model in Lemonade")
877
+ return 1
878
+
879
+ if raw_text.startswith("[VLM extraction failed:"):
880
+ console.print(f" [red]✗[/red] {raw_text}")
881
+ return 1
882
+
883
+ # Success!
884
+ console.print(
885
+ f" [green]✓[/green] Extraction complete ({len(raw_text)} chars, {extraction_time:.1f}s)"
886
+ )
887
+
888
+ # Estimate tokens/sec (output tokens only)
889
+ est_output_tokens = len(raw_text) / 4
890
+ tps = est_output_tokens / extraction_time if extraction_time > 0 else 0
891
+ console.print(
892
+ f" Output: ~{est_output_tokens:.0f} tokens at ~{tps:.1f} TPS"
893
+ )
894
+
895
+ # Total throughput including prompt processing
896
+ total_tokens = (
897
+ est_img_tokens + 200 + est_output_tokens
898
+ ) # img + prompt + output
899
+ total_tps = total_tokens / extraction_time if extraction_time > 0 else 0
900
+ console.print(
901
+ f" Total throughput: ~{total_tps:.0f} TPS (incl. {est_img_tokens:,} image tokens)"
902
+ )
903
+
904
+ # Update dimensions for final report
905
+ new_width, new_height = current_width, current_height
906
+ break
907
+
908
+ except Exception as e:
909
+ console.print(f" [red]✗[/red] Extraction failed: {e}")
910
+ return 1
911
+ else:
912
+ # All retries exhausted
913
+ console.print(" [red]✗[/red] All retries failed")
914
+ return 1
915
+
916
+ # Step 5: Parse JSON
917
+ console.print()
918
+ console.print("[bold]Step 5:[/bold] Parsing JSON...")
919
+ try:
920
+ # Try direct parse
921
+ patient_data = json.loads(raw_text)
922
+ console.print(" [green]✓[/green] JSON parsed successfully")
923
+ except json.JSONDecodeError:
924
+ # Try to find JSON in text
925
+ try:
926
+ start = raw_text.find("{")
927
+ end = raw_text.rfind("}") + 1
928
+ if start >= 0 and end > start:
929
+ patient_data = json.loads(raw_text[start:end])
930
+ console.print(" [green]✓[/green] JSON extracted from response")
931
+ else:
932
+ console.print(" [red]✗[/red] No JSON found in response")
933
+ console.print()
934
+ console.print("[bold]Raw VLM Output:[/bold]")
935
+ console.print(raw_text[:500])
936
+ return 1
937
+ except json.JSONDecodeError as e:
938
+ console.print(f" [red]✗[/red] JSON parse failed: {e}")
939
+ return 1
940
+
941
+ # Display results
942
+ console.print()
943
+ console.print(
944
+ Panel.fit(
945
+ "[bold green]✓ Extraction Successful[/bold green]",
946
+ border_style="green",
947
+ )
948
+ )
949
+
950
+ # Show extracted fields
951
+ console.print()
952
+ console.print("[bold]Extracted Fields:[/bold]")
953
+ fields_found = 0
954
+ for key, value in patient_data.items():
955
+ if value and value != "null":
956
+ fields_found += 1
957
+ console.print(f" [cyan]{key}:[/cyan] {value}")
958
+
959
+ console.print()
960
+ console.print(f"[dim]Fields extracted: {fields_found}/53[/dim]")
961
+ console.print()
962
+
963
+ # Timing breakdown section
964
+ console.print("[bold]⏱️ TIMING BREAKDOWN[/bold]")
965
+ console.print("-" * 40)
966
+ console.print(f" Model: {args.vlm_model}")
967
+ console.print(f" Image dimensions: {new_width}x{new_height}")
968
+ console.print(f" VLM extraction: {extraction_time:.2f}s")
969
+ console.print(f" Est. image tokens: ~{est_img_tokens:,}")
970
+ console.print(f" Est. output tokens: ~{int(est_output_tokens)}")
971
+ console.print(f" Est. tokens/sec: ~{tps:.1f} TPS")
972
+ console.print()
973
+
974
+ # Success summary
975
+ if patient_data.get("first_name") and patient_data.get("last_name"):
976
+ console.print(
977
+ f" Patient: {patient_data.get('first_name', '')} {patient_data.get('last_name', '')}"
978
+ )
979
+ console.print(" [green]✓ Pipeline test PASSED[/green]")
980
+ else:
981
+ console.print(" [yellow]⚠ Missing required name fields[/yellow]")
982
+ console.print(" [red]✗ Pipeline test FAILED[/red]")
983
+ console.print()
984
+
985
+ return 0
986
+
987
+
988
+ def _launch_electron(url: str, delay: float = 1.5) -> bool:
989
+ """
990
+ Launch Electron app to display the dashboard.
991
+
992
+ Returns True if Electron was launched successfully, False otherwise.
993
+ """
994
+ import os
995
+ import platform
996
+ import shutil
997
+ import subprocess
998
+ import time
999
+
1000
+ time.sleep(delay) # Wait for server to start
1001
+
1002
+ # Find the Electron wrapper directory
1003
+ electron_dir = Path(__file__).parent / "dashboard" / "electron"
1004
+ main_js = electron_dir / "main.js"
1005
+
1006
+ if not main_js.exists():
1007
+ logger.warning(f"Electron wrapper not found at {electron_dir}")
1008
+ return False
1009
+
1010
+ # On Windows, use npm.cmd and npx.cmd
1011
+ is_windows = platform.system() == "Windows"
1012
+ npm_cmd = "npm.cmd" if is_windows else "npm"
1013
+ npx_cmd = "npx.cmd" if is_windows else "npx"
1014
+
1015
+ # Check if npx/electron is available
1016
+ npx_path = shutil.which(npx_cmd)
1017
+ if not npx_path:
1018
+ logger.warning(f"{npx_cmd} not found in PATH, cannot launch Electron")
1019
+ return False
1020
+
1021
+ try:
1022
+ # Check if node_modules exists, if not run npm install first
1023
+ node_modules = electron_dir / "node_modules"
1024
+ if not node_modules.exists():
1025
+ console.print("[dim]Installing Electron dependencies...[/dim]")
1026
+ subprocess.run(
1027
+ [npm_cmd, "install"],
1028
+ cwd=str(electron_dir),
1029
+ capture_output=True,
1030
+ check=True,
1031
+ shell=is_windows, # Use shell on Windows for .cmd files
1032
+ )
1033
+
1034
+ # Launch Electron with the dashboard URL
1035
+ env = os.environ.copy()
1036
+ env["EMR_DASHBOARD_URL"] = url
1037
+
1038
+ subprocess.Popen(
1039
+ [npx_cmd, "electron", "."],
1040
+ cwd=str(electron_dir),
1041
+ env=env,
1042
+ stdout=subprocess.DEVNULL,
1043
+ stderr=subprocess.DEVNULL,
1044
+ shell=is_windows, # Use shell on Windows for .cmd files
1045
+ )
1046
+ return True
1047
+ except Exception as e:
1048
+ logger.warning(f"Failed to launch Electron: {e}")
1049
+ return False
1050
+
1051
+
1052
+ def cmd_dashboard(args):
1053
+ """Start web dashboard."""
1054
+ import threading
1055
+ import time
1056
+ import webbrowser
1057
+
1058
+ def open_browser(url: str, delay: float = 1.5):
1059
+ """Open browser after a short delay to allow server to start."""
1060
+ time.sleep(delay)
1061
+ webbrowser.open(url)
1062
+
1063
+ def open_electron_or_browser(url: str, use_browser: bool, delay: float = 1.5):
1064
+ """Open Electron app, falling back to browser if needed."""
1065
+ if use_browser:
1066
+ open_browser(url, delay)
1067
+ else:
1068
+ if not _launch_electron(url, delay):
1069
+ console.print(
1070
+ "[yellow]Electron not available, opening in browser instead...[/yellow]"
1071
+ )
1072
+ open_browser(url, delay)
1073
+
1074
+ try:
1075
+ from gaia.agents.emr.dashboard.server import run_dashboard
1076
+
1077
+ console.print()
1078
+ console.print(
1079
+ Panel.fit(
1080
+ "[bold cyan]EMR Dashboard[/bold cyan]\n"
1081
+ "[dim]Real-time Patient Processing Monitor[/dim]",
1082
+ border_style="cyan",
1083
+ )
1084
+ )
1085
+
1086
+ url = f"http://localhost:{args.port}"
1087
+
1088
+ table = Table(show_header=False, box=None, padding=(0, 2))
1089
+ table.add_column(style="dim")
1090
+ table.add_column()
1091
+ table.add_row("📁 Watch folder:", args.watch_dir)
1092
+ table.add_row("💾 Database:", args.db)
1093
+ table.add_row("🌐 URL:", f"[bold cyan]{url}[/bold cyan]")
1094
+ console.print(table)
1095
+ console.print("\n[dim]Press Ctrl+C to stop[/dim]\n")
1096
+
1097
+ # Auto-open unless --no-open flag is set
1098
+ if not getattr(args, "no_open", False):
1099
+ use_browser = getattr(args, "browser", False)
1100
+ if use_browser:
1101
+ console.print("[dim]Opening dashboard in browser...[/dim]\n")
1102
+ else:
1103
+ console.print("[dim]Opening dashboard in Electron app...[/dim]\n")
1104
+
1105
+ open_thread = threading.Thread(
1106
+ target=open_electron_or_browser,
1107
+ args=(url, use_browser),
1108
+ daemon=True,
1109
+ )
1110
+ open_thread.start()
1111
+
1112
+ run_dashboard(
1113
+ watch_dir=args.watch_dir,
1114
+ db_path=args.db,
1115
+ host=args.host,
1116
+ port=args.port,
1117
+ )
1118
+ except ImportError:
1119
+ console.print("[red]Error: Dashboard dependencies not installed[/red]")
1120
+ console.print("Install with: [cyan]pip install 'amd-gaia[api]'[/cyan]")
1121
+ return 1
1122
+ except KeyboardInterrupt:
1123
+ console.print("\n[dim]Shutting down dashboard...[/dim]")
1124
+ return 0
1125
+
1126
+
1127
+ def _add_common_args(parser):
1128
+ """Add common arguments to a parser."""
1129
+ parser.add_argument(
1130
+ "--watch-dir",
1131
+ default="./intake_forms",
1132
+ help="Directory to watch for intake forms (default: ./intake_forms)",
1133
+ )
1134
+ parser.add_argument(
1135
+ "--db",
1136
+ default="./data/patients.db",
1137
+ help="Path to patient database (default: ./data/patients.db)",
1138
+ )
1139
+ parser.add_argument(
1140
+ "--vlm-model",
1141
+ default="Qwen3-VL-4B-Instruct-GGUF",
1142
+ help="VLM model for extraction (default: Qwen3-VL-4B-Instruct-GGUF)",
1143
+ )
1144
+ parser.add_argument(
1145
+ "--debug",
1146
+ action="store_true",
1147
+ help="Enable debug logging",
1148
+ )
1149
+
1150
+
1151
+ def main():
1152
+ """Main CLI entry point."""
1153
+ parser = argparse.ArgumentParser(
1154
+ description="Medical Intake Agent CLI",
1155
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1156
+ )
1157
+
1158
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
1159
+
1160
+ # Watch command
1161
+ parser_watch = subparsers.add_parser(
1162
+ "watch",
1163
+ help="Watch directory for new intake forms",
1164
+ )
1165
+ _add_common_args(parser_watch)
1166
+ parser_watch.set_defaults(func=cmd_watch)
1167
+
1168
+ # Process command
1169
+ parser_process = subparsers.add_parser(
1170
+ "process",
1171
+ help="Process a single intake form",
1172
+ )
1173
+ _add_common_args(parser_process)
1174
+ parser_process.add_argument("file", help="Path to intake form file")
1175
+ parser_process.set_defaults(func=cmd_process)
1176
+
1177
+ # Query command
1178
+ parser_query = subparsers.add_parser(
1179
+ "query",
1180
+ help="Query patient database",
1181
+ )
1182
+ _add_common_args(parser_query)
1183
+ parser_query.add_argument("question", help="Question to ask")
1184
+ parser_query.set_defaults(func=cmd_query)
1185
+
1186
+ # Stats command
1187
+ parser_stats = subparsers.add_parser(
1188
+ "stats",
1189
+ help="Show processing statistics",
1190
+ )
1191
+ _add_common_args(parser_stats)
1192
+ parser_stats.set_defaults(func=cmd_stats)
1193
+
1194
+ # Reset command
1195
+ parser_reset = subparsers.add_parser(
1196
+ "reset",
1197
+ help="Clear all patient data from the database",
1198
+ )
1199
+ _add_common_args(parser_reset)
1200
+ parser_reset.add_argument(
1201
+ "--force",
1202
+ "-f",
1203
+ action="store_true",
1204
+ help="Skip confirmation prompt",
1205
+ )
1206
+ parser_reset.set_defaults(func=cmd_reset)
1207
+
1208
+ # Init command
1209
+ parser_init = subparsers.add_parser(
1210
+ "init",
1211
+ help="Download and setup required VLM models",
1212
+ )
1213
+ parser_init.add_argument(
1214
+ "--vlm-model",
1215
+ default="Qwen3-VL-4B-Instruct-GGUF",
1216
+ help="VLM model to download (default: Qwen3-VL-4B-Instruct-GGUF)",
1217
+ )
1218
+ parser_init.add_argument(
1219
+ "--debug",
1220
+ action="store_true",
1221
+ help="Enable debug logging",
1222
+ )
1223
+ parser_init.set_defaults(func=cmd_init)
1224
+
1225
+ # Test command
1226
+ parser_test = subparsers.add_parser(
1227
+ "test",
1228
+ help="Test VLM extraction pipeline on a single file",
1229
+ )
1230
+ parser_test.add_argument(
1231
+ "file",
1232
+ help="Path to intake form image (PNG, JPG, PDF)",
1233
+ )
1234
+ parser_test.add_argument(
1235
+ "--vlm-model",
1236
+ default="Qwen3-VL-4B-Instruct-GGUF",
1237
+ help="VLM model to use (default: Qwen3-VL-4B-Instruct-GGUF)",
1238
+ )
1239
+ parser_test.add_argument(
1240
+ "--max-dimension",
1241
+ type=int,
1242
+ default=1024,
1243
+ help="Max image dimension in pixels (default: 1024)",
1244
+ )
1245
+ parser_test.add_argument(
1246
+ "--jpeg-quality",
1247
+ type=int,
1248
+ default=85,
1249
+ help="JPEG compression quality (default: 85)",
1250
+ )
1251
+ parser_test.add_argument(
1252
+ "--clear-context",
1253
+ action="store_true",
1254
+ help="Clear VLM context before processing (fixes memory errors)",
1255
+ )
1256
+ parser_test.add_argument(
1257
+ "--debug",
1258
+ action="store_true",
1259
+ help="Enable debug logging",
1260
+ )
1261
+ parser_test.set_defaults(func=cmd_test)
1262
+
1263
+ # Dashboard command
1264
+ parser_dashboard = subparsers.add_parser(
1265
+ "dashboard",
1266
+ help="Start web dashboard",
1267
+ )
1268
+ _add_common_args(parser_dashboard)
1269
+ parser_dashboard.add_argument(
1270
+ "--host",
1271
+ default="127.0.0.1",
1272
+ help="Dashboard host (default: 127.0.0.1)",
1273
+ )
1274
+ parser_dashboard.add_argument(
1275
+ "--port",
1276
+ type=int,
1277
+ default=8080,
1278
+ help="Dashboard port (default: 8080)",
1279
+ )
1280
+ parser_dashboard.add_argument(
1281
+ "--no-open",
1282
+ action="store_true",
1283
+ help="Don't automatically open dashboard",
1284
+ )
1285
+ parser_dashboard.add_argument(
1286
+ "--browser",
1287
+ action="store_true",
1288
+ help="Open in web browser instead of Electron app",
1289
+ )
1290
+ parser_dashboard.set_defaults(func=cmd_dashboard)
1291
+
1292
+ args = parser.parse_args()
1293
+
1294
+ # Run command
1295
+ if not args.command:
1296
+ parser.print_help()
1297
+ return 0
1298
+
1299
+ # Configure logging - WARNING by default, DEBUG with --debug flag
1300
+ if getattr(args, "debug", False):
1301
+ logging.basicConfig(
1302
+ level=logging.DEBUG,
1303
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
1304
+ datefmt="%H:%M:%S",
1305
+ )
1306
+ else:
1307
+ # Suppress all logs from gaia modules for cleaner output
1308
+ logging.basicConfig(level=logging.WARNING)
1309
+ for logger_name in [
1310
+ "gaia",
1311
+ "gaia.llm",
1312
+ "gaia.database",
1313
+ "gaia.agents",
1314
+ "gaia.utils",
1315
+ ]:
1316
+ logging.getLogger(logger_name).setLevel(logging.WARNING)
1317
+
1318
+ return args.func(args)
1319
+
1320
+
1321
+ if __name__ == "__main__":
1322
+ sys.exit(main())