amd-gaia 0.15.0__py3-none-any.whl → 0.15.2__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 (185) hide show
  1. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/METADATA +222 -223
  2. amd_gaia-0.15.2.dist-info/RECORD +182 -0
  3. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/WHEEL +1 -1
  4. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/entry_points.txt +1 -0
  5. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/licenses/LICENSE.md +20 -20
  6. gaia/__init__.py +29 -29
  7. gaia/agents/__init__.py +19 -19
  8. gaia/agents/base/__init__.py +9 -9
  9. gaia/agents/base/agent.py +2132 -2177
  10. gaia/agents/base/api_agent.py +119 -120
  11. gaia/agents/base/console.py +1967 -1841
  12. gaia/agents/base/errors.py +237 -237
  13. gaia/agents/base/mcp_agent.py +86 -86
  14. gaia/agents/base/tools.py +88 -83
  15. gaia/agents/blender/__init__.py +7 -0
  16. gaia/agents/blender/agent.py +553 -556
  17. gaia/agents/blender/agent_simple.py +133 -135
  18. gaia/agents/blender/app.py +211 -211
  19. gaia/agents/blender/app_simple.py +41 -41
  20. gaia/agents/blender/core/__init__.py +16 -16
  21. gaia/agents/blender/core/materials.py +506 -506
  22. gaia/agents/blender/core/objects.py +316 -316
  23. gaia/agents/blender/core/rendering.py +225 -225
  24. gaia/agents/blender/core/scene.py +220 -220
  25. gaia/agents/blender/core/view.py +146 -146
  26. gaia/agents/chat/__init__.py +9 -9
  27. gaia/agents/chat/agent.py +809 -835
  28. gaia/agents/chat/app.py +1065 -1058
  29. gaia/agents/chat/session.py +508 -508
  30. gaia/agents/chat/tools/__init__.py +15 -15
  31. gaia/agents/chat/tools/file_tools.py +96 -96
  32. gaia/agents/chat/tools/rag_tools.py +1744 -1729
  33. gaia/agents/chat/tools/shell_tools.py +437 -436
  34. gaia/agents/code/__init__.py +7 -7
  35. gaia/agents/code/agent.py +549 -549
  36. gaia/agents/code/cli.py +377 -0
  37. gaia/agents/code/models.py +135 -135
  38. gaia/agents/code/orchestration/__init__.py +24 -24
  39. gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
  40. gaia/agents/code/orchestration/checklist_generator.py +713 -713
  41. gaia/agents/code/orchestration/factories/__init__.py +9 -9
  42. gaia/agents/code/orchestration/factories/base.py +63 -63
  43. gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
  44. gaia/agents/code/orchestration/factories/python_factory.py +106 -106
  45. gaia/agents/code/orchestration/orchestrator.py +841 -841
  46. gaia/agents/code/orchestration/project_analyzer.py +391 -391
  47. gaia/agents/code/orchestration/steps/__init__.py +67 -67
  48. gaia/agents/code/orchestration/steps/base.py +188 -188
  49. gaia/agents/code/orchestration/steps/error_handler.py +314 -314
  50. gaia/agents/code/orchestration/steps/nextjs.py +828 -828
  51. gaia/agents/code/orchestration/steps/python.py +307 -307
  52. gaia/agents/code/orchestration/template_catalog.py +469 -469
  53. gaia/agents/code/orchestration/workflows/__init__.py +14 -14
  54. gaia/agents/code/orchestration/workflows/base.py +80 -80
  55. gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
  56. gaia/agents/code/orchestration/workflows/python.py +94 -94
  57. gaia/agents/code/prompts/__init__.py +11 -11
  58. gaia/agents/code/prompts/base_prompt.py +77 -77
  59. gaia/agents/code/prompts/code_patterns.py +2034 -2036
  60. gaia/agents/code/prompts/nextjs_prompt.py +40 -40
  61. gaia/agents/code/prompts/python_prompt.py +109 -109
  62. gaia/agents/code/schema_inference.py +365 -365
  63. gaia/agents/code/system_prompt.py +41 -41
  64. gaia/agents/code/tools/__init__.py +42 -42
  65. gaia/agents/code/tools/cli_tools.py +1138 -1138
  66. gaia/agents/code/tools/code_formatting.py +319 -319
  67. gaia/agents/code/tools/code_tools.py +769 -769
  68. gaia/agents/code/tools/error_fixing.py +1347 -1347
  69. gaia/agents/code/tools/external_tools.py +180 -180
  70. gaia/agents/code/tools/file_io.py +845 -845
  71. gaia/agents/code/tools/prisma_tools.py +190 -190
  72. gaia/agents/code/tools/project_management.py +1016 -1016
  73. gaia/agents/code/tools/testing.py +321 -321
  74. gaia/agents/code/tools/typescript_tools.py +122 -122
  75. gaia/agents/code/tools/validation_parsing.py +461 -461
  76. gaia/agents/code/tools/validation_tools.py +806 -806
  77. gaia/agents/code/tools/web_dev_tools.py +1758 -1758
  78. gaia/agents/code/validators/__init__.py +16 -16
  79. gaia/agents/code/validators/antipattern_checker.py +241 -241
  80. gaia/agents/code/validators/ast_analyzer.py +197 -197
  81. gaia/agents/code/validators/requirements_validator.py +145 -145
  82. gaia/agents/code/validators/syntax_validator.py +171 -171
  83. gaia/agents/docker/__init__.py +7 -7
  84. gaia/agents/docker/agent.py +643 -642
  85. gaia/agents/emr/__init__.py +8 -8
  86. gaia/agents/emr/agent.py +1504 -1506
  87. gaia/agents/emr/cli.py +1322 -1322
  88. gaia/agents/emr/constants.py +475 -475
  89. gaia/agents/emr/dashboard/__init__.py +4 -4
  90. gaia/agents/emr/dashboard/server.py +1972 -1974
  91. gaia/agents/jira/__init__.py +11 -11
  92. gaia/agents/jira/agent.py +894 -894
  93. gaia/agents/jira/jql_templates.py +299 -299
  94. gaia/agents/routing/__init__.py +7 -7
  95. gaia/agents/routing/agent.py +567 -570
  96. gaia/agents/routing/system_prompt.py +75 -75
  97. gaia/agents/summarize/__init__.py +11 -0
  98. gaia/agents/summarize/agent.py +885 -0
  99. gaia/agents/summarize/prompts.py +129 -0
  100. gaia/api/__init__.py +23 -23
  101. gaia/api/agent_registry.py +238 -238
  102. gaia/api/app.py +305 -305
  103. gaia/api/openai_server.py +575 -575
  104. gaia/api/schemas.py +186 -186
  105. gaia/api/sse_handler.py +373 -373
  106. gaia/apps/__init__.py +4 -4
  107. gaia/apps/llm/__init__.py +6 -6
  108. gaia/apps/llm/app.py +184 -169
  109. gaia/apps/summarize/app.py +116 -633
  110. gaia/apps/summarize/html_viewer.py +133 -133
  111. gaia/apps/summarize/pdf_formatter.py +284 -284
  112. gaia/audio/__init__.py +2 -2
  113. gaia/audio/audio_client.py +439 -439
  114. gaia/audio/audio_recorder.py +269 -269
  115. gaia/audio/kokoro_tts.py +599 -599
  116. gaia/audio/whisper_asr.py +432 -432
  117. gaia/chat/__init__.py +16 -16
  118. gaia/chat/app.py +428 -430
  119. gaia/chat/prompts.py +522 -522
  120. gaia/chat/sdk.py +1228 -1225
  121. gaia/cli.py +5659 -5632
  122. gaia/database/__init__.py +10 -10
  123. gaia/database/agent.py +176 -176
  124. gaia/database/mixin.py +290 -290
  125. gaia/database/testing.py +64 -64
  126. gaia/eval/batch_experiment.py +2332 -2332
  127. gaia/eval/claude.py +542 -542
  128. gaia/eval/config.py +37 -37
  129. gaia/eval/email_generator.py +512 -512
  130. gaia/eval/eval.py +3179 -3179
  131. gaia/eval/groundtruth.py +1130 -1130
  132. gaia/eval/transcript_generator.py +582 -582
  133. gaia/eval/webapp/README.md +167 -167
  134. gaia/eval/webapp/package-lock.json +875 -875
  135. gaia/eval/webapp/package.json +20 -20
  136. gaia/eval/webapp/public/app.js +3402 -3402
  137. gaia/eval/webapp/public/index.html +87 -87
  138. gaia/eval/webapp/public/styles.css +3661 -3661
  139. gaia/eval/webapp/server.js +415 -415
  140. gaia/eval/webapp/test-setup.js +72 -72
  141. gaia/installer/__init__.py +23 -0
  142. gaia/installer/init_command.py +1275 -0
  143. gaia/installer/lemonade_installer.py +619 -0
  144. gaia/llm/__init__.py +10 -2
  145. gaia/llm/base_client.py +60 -0
  146. gaia/llm/exceptions.py +12 -0
  147. gaia/llm/factory.py +70 -0
  148. gaia/llm/lemonade_client.py +3421 -3221
  149. gaia/llm/lemonade_manager.py +294 -294
  150. gaia/llm/providers/__init__.py +9 -0
  151. gaia/llm/providers/claude.py +108 -0
  152. gaia/llm/providers/lemonade.py +118 -0
  153. gaia/llm/providers/openai_provider.py +79 -0
  154. gaia/llm/vlm_client.py +382 -382
  155. gaia/logger.py +189 -189
  156. gaia/mcp/agent_mcp_server.py +245 -245
  157. gaia/mcp/blender_mcp_client.py +138 -138
  158. gaia/mcp/blender_mcp_server.py +648 -648
  159. gaia/mcp/context7_cache.py +332 -332
  160. gaia/mcp/external_services.py +518 -518
  161. gaia/mcp/mcp_bridge.py +811 -550
  162. gaia/mcp/servers/__init__.py +6 -6
  163. gaia/mcp/servers/docker_mcp.py +83 -83
  164. gaia/perf_analysis.py +361 -0
  165. gaia/rag/__init__.py +10 -10
  166. gaia/rag/app.py +293 -293
  167. gaia/rag/demo.py +304 -304
  168. gaia/rag/pdf_utils.py +235 -235
  169. gaia/rag/sdk.py +2194 -2194
  170. gaia/security.py +183 -163
  171. gaia/talk/app.py +287 -289
  172. gaia/talk/sdk.py +538 -538
  173. gaia/testing/__init__.py +87 -87
  174. gaia/testing/assertions.py +330 -330
  175. gaia/testing/fixtures.py +333 -333
  176. gaia/testing/mocks.py +493 -493
  177. gaia/util.py +46 -46
  178. gaia/utils/__init__.py +33 -33
  179. gaia/utils/file_watcher.py +675 -675
  180. gaia/utils/parsing.py +223 -223
  181. gaia/version.py +100 -100
  182. amd_gaia-0.15.0.dist-info/RECORD +0 -168
  183. gaia/agents/code/app.py +0 -266
  184. gaia/llm/llm_client.py +0 -723
  185. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/top_level.txt +0 -0
gaia/agents/emr/agent.py CHANGED
@@ -1,1506 +1,1504 @@
1
- # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
- # SPDX-License-Identifier: MIT
3
-
4
- """
5
- Medical Intake Agent for processing patient intake forms.
6
-
7
- Watches a directory for new intake forms (images/PDFs), extracts patient
8
- data using VLM, and stores records in a SQLite database.
9
-
10
- NOTE: This is a demonstration/proof-of-concept application.
11
- Not intended for production use with real patient data.
12
- """
13
-
14
- import json
15
- import logging
16
- import time
17
- from pathlib import Path
18
- from typing import Any, Dict, List, Optional
19
-
20
- from gaia.agents.base import Agent
21
- from gaia.agents.base.tools import tool
22
- from gaia.database import DatabaseMixin
23
- from gaia.llm.vlm_client import detect_image_mime_type
24
- from gaia.utils import (
25
- FileWatcherMixin,
26
- compute_file_hash,
27
- detect_field_changes,
28
- extract_json_from_text,
29
- pdf_page_to_image,
30
- )
31
-
32
- from .constants import (
33
- EXTRACTION_PROMPT,
34
- PATIENT_SCHEMA,
35
- STANDARD_COLUMNS,
36
- UPDATABLE_COLUMNS,
37
- estimate_manual_entry_time,
38
- )
39
-
40
- logger = logging.getLogger(__name__)
41
-
42
-
43
- class MedicalIntakeAgent(Agent, DatabaseMixin, FileWatcherMixin):
44
- """
45
- Agent for processing medical intake forms automatically.
46
-
47
- Watches a directory for new intake forms (images/PDFs), extracts
48
- patient data using VLM (Vision Language Model), and stores the
49
- records in a SQLite database.
50
-
51
- Features:
52
- - Automatic file watching for new intake forms
53
- - VLM-powered data extraction from images
54
- - SQLite database storage with full-text search
55
- - Tools for patient lookup and management
56
- - Rich console output for processing status
57
-
58
- Example:
59
- from gaia.agents.emr import MedicalIntakeAgent
60
-
61
- agent = MedicalIntakeAgent(
62
- watch_dir="./intake_forms",
63
- db_path="./data/patients.db",
64
- )
65
-
66
- # Agent automatically processes new files in watch_dir
67
- # Query the agent about patients
68
- agent.process_query("How many patients were processed today?")
69
- agent.process_query("Find patient John Smith")
70
-
71
- # Cleanup
72
- agent.stop()
73
- """
74
-
75
- def __init__(
76
- self,
77
- watch_dir: str = "./intake_forms",
78
- db_path: str = "./data/patients.db",
79
- vlm_model: str = "Qwen3-VL-4B-Instruct-GGUF",
80
- auto_start_watching: bool = True,
81
- **kwargs,
82
- ):
83
- """
84
- Initialize the Medical Intake Agent.
85
-
86
- Args:
87
- watch_dir: Directory to watch for new intake forms
88
- db_path: Path to SQLite database for patient records
89
- vlm_model: VLM model to use for extraction
90
- auto_start_watching: Start watching immediately (default: True)
91
- **kwargs: Additional arguments for Agent base class
92
- """
93
- # Set attributes before super().__init__() as it may call _get_system_prompt()
94
- self._watch_dir = Path(watch_dir)
95
- self._db_path = db_path
96
- self._vlm_model = vlm_model
97
- self._vlm = None
98
- self._processed_files: List[Dict[str, Any]] = []
99
- self._auto_start_watching = auto_start_watching
100
-
101
- # Statistics
102
- self._stats = {
103
- "files_processed": 0,
104
- "extraction_success": 0,
105
- "extraction_failed": 0,
106
- "new_patients": 0,
107
- "returning_patients": 0,
108
- "total_processing_time_seconds": 0.0,
109
- "total_estimated_manual_seconds": 0.0,
110
- "start_time": time.time(),
111
- }
112
-
113
- # Progress callback for external monitoring (e.g., dashboard SSE)
114
- # Signature: callback(filename, step_num, total_steps, step_name, status)
115
- self._progress_callback: Optional[callable] = None
116
-
117
- # Set reasonable defaults for agent - higher max_steps for interactive use
118
- kwargs.setdefault("max_steps", 50)
119
-
120
- super().__init__(**kwargs)
121
-
122
- # Initialize database
123
- self._init_database()
124
-
125
- # Load historical stats from database (for pre-processed forms)
126
- self._load_historical_stats()
127
-
128
- # Create watch directory if needed
129
- self._watch_dir.mkdir(parents=True, exist_ok=True)
130
-
131
- # Start file watching if requested
132
- if auto_start_watching:
133
- self._start_file_watching()
134
-
135
- def _init_database(self) -> None:
136
- """Initialize the patient database."""
137
- try:
138
- # Ensure data directory exists
139
- db_dir = Path(self._db_path).parent
140
- db_dir.mkdir(parents=True, exist_ok=True)
141
-
142
- # Initialize database with schema
143
- self.init_db(self._db_path)
144
- self.execute(PATIENT_SCHEMA)
145
- logger.info(f"Database initialized: {self._db_path}")
146
- except Exception as e:
147
- logger.error(f"Failed to initialize database: {e}")
148
- raise
149
-
150
- def _load_historical_stats(self) -> None:
151
- """Load historical processing stats from database for pre-processed forms.
152
-
153
- This ensures efficiency metrics include forms processed in previous sessions,
154
- not just the current agent instance.
155
- """
156
- try:
157
- # Get aggregate stats from patients table
158
- result = self.query(
159
- """
160
- SELECT
161
- COUNT(*) as total_patients,
162
- COALESCE(SUM(processing_time_seconds), 0) as total_processing_time,
163
- COALESCE(SUM(estimated_manual_seconds), 0) as total_estimated_manual,
164
- SUM(CASE WHEN is_new_patient = 1 THEN 1 ELSE 0 END) as new_patients,
165
- SUM(CASE WHEN is_new_patient = 0 THEN 1 ELSE 0 END) as returning_patients
166
- FROM patients
167
- """
168
- )
169
-
170
- if result and result[0]:
171
- stats = result[0]
172
- self._stats["extraction_success"] = stats.get("total_patients", 0) or 0
173
- self._stats["files_processed"] = stats.get("total_patients", 0) or 0
174
- self._stats["total_processing_time_seconds"] = float(
175
- stats.get("total_processing_time", 0) or 0
176
- )
177
- self._stats["total_estimated_manual_seconds"] = float(
178
- stats.get("total_estimated_manual", 0) or 0
179
- )
180
- self._stats["new_patients"] = stats.get("new_patients", 0) or 0
181
- self._stats["returning_patients"] = (
182
- stats.get("returning_patients", 0) or 0
183
- )
184
-
185
- if self._stats["extraction_success"] > 0:
186
- logger.info(
187
- f"Loaded historical stats: {self._stats['extraction_success']} forms, "
188
- f"{self._stats['total_processing_time_seconds']:.1f}s AI time, "
189
- f"{self._stats['total_estimated_manual_seconds']:.1f}s manual time"
190
- )
191
- except Exception as e:
192
- # Don't fail if historical stats can't be loaded (e.g., schema mismatch)
193
- logger.warning(f"Could not load historical stats: {e}")
194
-
195
- def _start_file_watching(self) -> None:
196
- """Start watching the intake directory for new files."""
197
- # First, process any existing files (works even if watcher fails)
198
- self._process_existing_files()
199
-
200
- # Then set up the watcher for new files
201
- try:
202
- self.watch_directory(
203
- self._watch_dir,
204
- on_created=self._on_file_created,
205
- on_modified=self._on_file_modified,
206
- extensions=[".png", ".jpg", ".jpeg", ".pdf", ".tiff", ".bmp"],
207
- debounce_seconds=2.0,
208
- )
209
- logger.info(f"Watching for intake forms: {self._watch_dir}")
210
- except Exception as e:
211
- logger.warning(f"File watching not available: {e}")
212
-
213
- def _print_file_listing(
214
- self, files: list, processed_hashes: set
215
- ) -> tuple[int, int]:
216
- """Print a styled listing of files in the watch directory.
217
-
218
- Returns:
219
- Tuple of (new_count, processed_count)
220
- """
221
- from rich.console import Console
222
- from rich.table import Table
223
-
224
- console = Console()
225
-
226
- table = Table(
227
- title=f"📁 {self._watch_dir}", show_header=True, header_style="bold cyan"
228
- )
229
- table.add_column("File", style="white")
230
- table.add_column("Size", justify="right", style="dim")
231
- table.add_column("Hash", style="dim")
232
- table.add_column("Status", justify="center")
233
-
234
- new_count = 0
235
- processed_count = 0
236
-
237
- for f in sorted(files):
238
- try:
239
- size = f.stat().st_size
240
- if size < 1024:
241
- size_str = f"{size} B"
242
- elif size < 1024 * 1024:
243
- size_str = f"{size / 1024:.1f} KB"
244
- else:
245
- size_str = f"{size / (1024 * 1024):.1f} MB"
246
- except OSError:
247
- size_str = "?"
248
-
249
- # Compute hash for status check
250
- file_hash = compute_file_hash(f)
251
- hash_display = file_hash[:8] + "..." if file_hash else "?"
252
-
253
- if file_hash and file_hash in processed_hashes:
254
- status = "[dim]✓ processed[/dim]"
255
- processed_count += 1
256
- else:
257
- status = "[green]● new[/green]"
258
- new_count += 1
259
-
260
- table.add_row(f.name, size_str, hash_display, status)
261
-
262
- console.print(table)
263
-
264
- # Print summary
265
- summary_parts = []
266
- if new_count > 0:
267
- summary_parts.append(f"[green]{new_count} new[/green]")
268
- if processed_count > 0:
269
- summary_parts.append(f"[dim]{processed_count} already processed[/dim]")
270
- if summary_parts:
271
- console.print(f" {', '.join(summary_parts)}")
272
- console.print()
273
-
274
- return new_count, processed_count
275
-
276
- def _process_existing_files(self) -> None:
277
- """Scan and process any existing files in the watch directory."""
278
- supported_extensions = {".png", ".jpg", ".jpeg", ".pdf", ".tiff", ".bmp"}
279
-
280
- # Check directory exists
281
- if not self._watch_dir.exists():
282
- self.console.print_warning(
283
- f"Watch directory does not exist: {self._watch_dir}"
284
- )
285
- return
286
-
287
- # Use case-insensitive matching on Windows
288
- existing_files = set()
289
- try:
290
- for f in self._watch_dir.iterdir():
291
- if f.is_file() and f.suffix.lower() in supported_extensions:
292
- existing_files.add(f.absolute())
293
- except Exception as e:
294
- self.console.print_error(f"Could not scan directory: {e}")
295
- return
296
-
297
- # Get all processed file hashes from database
298
- processed_hashes = set()
299
- try:
300
- results = self.query(
301
- "SELECT DISTINCT file_hash FROM patients WHERE file_hash IS NOT NULL"
302
- )
303
- for r in results:
304
- if r.get("file_hash"):
305
- processed_hashes.add(r["file_hash"])
306
- except Exception as e:
307
- logger.debug(f"Could not query processed hashes: {e}")
308
-
309
- # Always show file listing at startup
310
- if existing_files:
311
- new_count, _processed_count = self._print_file_listing(
312
- existing_files, processed_hashes
313
- )
314
- else:
315
- self.console.print_info(f"No intake files found in {self._watch_dir}")
316
- return
317
-
318
- # Process new files
319
- if new_count > 0:
320
- self.console.print_info(f"Processing {new_count} new file(s)...")
321
- for f in sorted(existing_files):
322
- file_hash = compute_file_hash(f)
323
- if file_hash and file_hash not in processed_hashes:
324
- self._on_file_created(f)
325
-
326
- def _get_vlm(self):
327
- """Get or create VLM client (lazy initialization)."""
328
- if self._vlm is None:
329
- try:
330
- from gaia.llm.vlm_client import VLMClient
331
-
332
- self.console.print_model_loading(self._vlm_model)
333
- self._vlm = VLMClient(vlm_model=self._vlm_model)
334
- self.console.print_model_ready(self._vlm_model)
335
- logger.debug(f"VLM client initialized: {self._vlm_model}")
336
- except Exception as e:
337
- logger.error(f"Failed to initialize VLM: {e}")
338
- return None
339
- return self._vlm
340
-
341
- def _on_file_created(self, path: str) -> None:
342
- """Handle new file creation in watched directory."""
343
- file_path = Path(path)
344
-
345
- # Wait for file to be fully written (Windows file locking)
346
- time.sleep(0.5)
347
-
348
- try:
349
- size = file_path.stat().st_size
350
- except (FileNotFoundError, OSError):
351
- size = 0
352
-
353
- self.console.print_file_created(
354
- filename=file_path.name,
355
- size=size,
356
- extension=file_path.suffix,
357
- )
358
-
359
- # Process the file with retry for file locking issues
360
- max_retries = 3
361
- for attempt in range(max_retries):
362
- try:
363
- self._process_intake_form(path)
364
- break
365
- except PermissionError as e:
366
- if attempt < max_retries - 1:
367
- logger.warning(
368
- f"File locked, retrying in 2s ({attempt + 1}/{max_retries}): {e}"
369
- )
370
- time.sleep(2.0)
371
- else:
372
- logger.error(
373
- f"Failed to process file after {max_retries} attempts: {e}"
374
- )
375
- self.console.print_error(f"Could not access file: {file_path.name}")
376
-
377
- def _on_file_modified(self, path: str) -> None:
378
- """Handle file modification (re-process if needed)."""
379
- # Don't auto-reprocess modified files to avoid duplicates
380
- _ = path # Intentionally unused - modifications don't trigger reprocessing
381
-
382
- def _emit_progress(
383
- self,
384
- filename: str,
385
- step_num: int,
386
- total_steps: int,
387
- step_name: str,
388
- status: str = "running",
389
- ) -> None:
390
- """
391
- Emit progress update to console and optional callback.
392
-
393
- Args:
394
- filename: Name of file being processed
395
- step_num: Current step number (1-based)
396
- total_steps: Total number of processing steps
397
- step_name: Human-readable step name
398
- status: 'running', 'complete', or 'error'
399
- """
400
- # Update console
401
- self.console.print_processing_step(step_num, total_steps, step_name, status)
402
-
403
- # Call external callback if registered (e.g., for SSE events)
404
- if self._progress_callback:
405
- try:
406
- self._progress_callback(
407
- filename, step_num, total_steps, step_name, status
408
- )
409
- except Exception as e:
410
- logger.debug(f"Progress callback error: {e}")
411
-
412
- def _process_intake_form(self, file_path: str) -> Optional[Dict[str, Any]]:
413
- """
414
- Process an intake form and extract patient data.
415
-
416
- Args:
417
- file_path: Path to the intake form (image or PDF)
418
-
419
- Returns:
420
- Extracted patient data dict, or None if extraction failed
421
- """
422
- path = Path(file_path)
423
- start_time = time.time()
424
- self._stats["files_processed"] += 1
425
- filename = path.name
426
- total_steps = 7 # Total processing steps
427
-
428
- logger.debug(f"Processing intake form: {filename}")
429
-
430
- # Start pipeline progress display
431
- self.console.print_processing_pipeline_start(filename, total_steps)
432
-
433
- try:
434
- # Step 1: Read file
435
- self._emit_progress(filename, 1, total_steps, "Reading file")
436
- try:
437
- with open(path, "rb") as f:
438
- file_content = f.read()
439
- except (OSError, IOError) as e:
440
- logger.error(f"Could not read file: {e}")
441
- self._emit_progress(filename, 1, total_steps, "Reading file", "error")
442
- self._stats["extraction_failed"] += 1
443
- return None
444
-
445
- # Step 2: Check for duplicates
446
- self._emit_progress(filename, 2, total_steps, "Checking for duplicates")
447
- file_hash = compute_file_hash(path)
448
- if file_hash:
449
- existing = self.query(
450
- "SELECT id, first_name, last_name FROM patients WHERE file_hash = ?",
451
- (file_hash,),
452
- )
453
- if existing:
454
- patient = existing[0]
455
- name = f"{patient.get('first_name', '')} {patient.get('last_name', '')}".strip()
456
- self.console.print_info(
457
- f"Skipping duplicate file (hash: {file_hash[:8]}...) - "
458
- f"Already processed as patient: {name} (ID: {patient['id']})"
459
- )
460
- # Emit duplicate event for Live Feed
461
- self._emit_progress(
462
- filename,
463
- 2,
464
- total_steps,
465
- f"Duplicate - already processed as {name}",
466
- "duplicate",
467
- )
468
- # Show completion in console
469
- self.console.print_processing_pipeline_complete(
470
- filename,
471
- True,
472
- time.time() - start_time,
473
- name,
474
- is_duplicate=True,
475
- )
476
- return None
477
-
478
- # Step 3: Prepare and optimize image
479
- self._emit_progress(filename, 3, total_steps, "Optimizing image")
480
- image_bytes = self._read_file_as_image(path)
481
- if image_bytes is None:
482
- self._emit_progress(
483
- filename, 3, total_steps, "Optimizing image", "error"
484
- )
485
- self._stats["extraction_failed"] += 1
486
- return None
487
-
488
- # Step 4: Load VLM model
489
- self._emit_progress(filename, 4, total_steps, "Loading AI model")
490
- vlm = self._get_vlm()
491
- if vlm is None:
492
- logger.error("VLM not available")
493
- self._emit_progress(
494
- filename, 4, total_steps, "Loading AI model", "error"
495
- )
496
- self._stats["extraction_failed"] += 1
497
- return None
498
-
499
- # Step 5: Extract data with VLM
500
- self._emit_progress(filename, 5, total_steps, "Extracting patient data")
501
- mime_type = detect_image_mime_type(image_bytes)
502
- size_kb = len(image_bytes) / 1024
503
- self.console.print_extraction_start(1, 1, mime_type)
504
-
505
- extraction_start = time.time()
506
- raw_text = vlm.extract_from_image(
507
- image_bytes=image_bytes,
508
- prompt=EXTRACTION_PROMPT,
509
- )
510
- extraction_time = time.time() - extraction_start
511
-
512
- # Check for VLM extraction errors (surfaced to user)
513
- if raw_text.startswith("[VLM extraction failed:"):
514
- # Extract the error message from the marker
515
- error_msg = raw_text[1:-1] if raw_text.endswith("]") else raw_text
516
- self.console.print_error(f"❌ {error_msg}")
517
- logger.error(f"VLM extraction failed for {path.name}: {error_msg}")
518
- self._emit_progress(
519
- filename, 5, total_steps, "Extracting patient data", "error"
520
- )
521
- self._stats["extraction_failed"] += 1
522
- return None
523
-
524
- self.console.print_extraction_complete(
525
- len(raw_text), 1, extraction_time, size_kb
526
- )
527
-
528
- # Step 6: Parse extraction
529
- self._emit_progress(filename, 6, total_steps, "Parsing extracted data")
530
- patient_data = self._parse_extraction(raw_text)
531
- if patient_data is None:
532
- logger.warning(f"Failed to parse extraction for: {path.name}")
533
- self._emit_progress(
534
- filename, 6, total_steps, "Parsing extracted data", "error"
535
- )
536
- self._stats["extraction_failed"] += 1
537
- return None
538
-
539
- # Add metadata including file content and hash
540
- patient_data["source_file"] = str(path.absolute())
541
- patient_data["raw_extraction"] = raw_text
542
- patient_data["file_hash"] = file_hash
543
- patient_data["file_content"] = file_content
544
-
545
- # Check for returning patient (by name/DOB, not file hash)
546
- existing_patient = self._find_existing_patient(patient_data)
547
- is_new_patient = existing_patient is None
548
- changes_detected = []
549
-
550
- if existing_patient:
551
- # Detect changes for returning patient
552
- changes_detected = self._detect_changes(existing_patient, patient_data)
553
- patient_data["is_new_patient"] = False
554
- self._stats["returning_patients"] += 1
555
- else:
556
- patient_data["is_new_patient"] = True
557
- self._stats["new_patients"] += 1
558
-
559
- # Calculate processing time
560
- processing_time = time.time() - start_time
561
- patient_data["processing_time_seconds"] = processing_time
562
- self._stats["total_processing_time_seconds"] += processing_time
563
-
564
- # Calculate estimated manual entry time based on extracted data
565
- estimated_manual = estimate_manual_entry_time(patient_data)
566
- patient_data["estimated_manual_seconds"] = estimated_manual
567
- self._stats["total_estimated_manual_seconds"] += estimated_manual
568
-
569
- # Step 7: Save to database
570
- self._emit_progress(filename, 7, total_steps, "Saving to database")
571
- if existing_patient:
572
- patient_id = self._update_patient(existing_patient["id"], patient_data)
573
- else:
574
- patient_id = self._store_patient(patient_data)
575
-
576
- if patient_id:
577
- self._stats["extraction_success"] += 1
578
- patient_data["id"] = patient_id
579
- patient_data["changes_detected"] = changes_detected
580
-
581
- # Record intake session for audit trail
582
- self._record_intake_session(
583
- patient_id, path, processing_time, is_new_patient, changes_detected
584
- )
585
-
586
- # Create alerts for critical items
587
- self._create_alerts(patient_id, patient_data)
588
-
589
- self._processed_files.append(
590
- {
591
- "file": path.name,
592
- "patient_id": patient_id,
593
- "name": f"{patient_data.get('first_name', '')} {patient_data.get('last_name', '')}",
594
- "is_new_patient": is_new_patient,
595
- "changes_detected": changes_detected,
596
- "processing_time_seconds": processing_time,
597
- "processed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
598
- }
599
- )
600
-
601
- # Limit memory usage - keep only last 1000 entries
602
- if len(self._processed_files) > 1000:
603
- self._processed_files = self._processed_files[-1000:]
604
-
605
- # Show pipeline completion
606
- patient_name = f"{patient_data.get('first_name', '')} {patient_data.get('last_name', '')}".strip()
607
- self.console.print_processing_pipeline_complete(
608
- filename, True, processing_time, patient_name
609
- )
610
-
611
- status = "NEW" if is_new_patient else "RETURNING"
612
- self.console.print_success(
613
- f"[{status}] Patient record: {patient_data.get('first_name')} "
614
- f"{patient_data.get('last_name')} (ID: {patient_id})"
615
- )
616
-
617
- # Display extracted patient details
618
- self._print_patient_details(
619
- patient_data, changes_detected, is_new_patient
620
- )
621
-
622
- return patient_data
623
-
624
- except Exception as e:
625
- logger.error(f"Error processing {path.name}: {e}")
626
- self.console.print_processing_pipeline_complete(
627
- filename, False, time.time() - start_time
628
- )
629
- self._stats["extraction_failed"] += 1
630
-
631
- return None
632
-
633
- def _print_patient_details(
634
- self, data: Dict[str, Any], changes: List[Dict[str, Any]], is_new: bool = True
635
- ) -> None:
636
- """Print extracted patient details to console using Rich formatting."""
637
- from rich.console import Console
638
- from rich.panel import Panel
639
- from rich.table import Table
640
-
641
- console = Console()
642
-
643
- # Fields to skip in display (especially binary/large data)
644
- skip_fields = {
645
- "id",
646
- "source_file",
647
- "raw_extraction",
648
- "additional_fields",
649
- "is_new_patient",
650
- "processing_time_seconds",
651
- "changes_detected",
652
- "created_at",
653
- "updated_at",
654
- "file_content", # Binary image data
655
- "file_hash", # Hash string
656
- }
657
-
658
- # Group fields by category with icons
659
- categories = {
660
- "👤 Identity": [
661
- "first_name",
662
- "last_name",
663
- "date_of_birth",
664
- "gender",
665
- "ssn",
666
- ],
667
- "📞 Contact": [
668
- "phone",
669
- "mobile_phone",
670
- "email",
671
- "address",
672
- "city",
673
- "state",
674
- "zip_code",
675
- ],
676
- "🏥 Insurance": ["insurance_provider", "insurance_id", "insurance_group"],
677
- "💊 Medical": [
678
- "reason_for_visit",
679
- "allergies",
680
- "medications",
681
- "date_of_injury",
682
- ],
683
- "🆘 Emergency": ["emergency_contact_name", "emergency_contact_phone"],
684
- "💼 Employment": ["employer", "occupation", "work_related_injury"],
685
- "👨‍⚕️ Provider": ["referring_physician"],
686
- }
687
-
688
- # Track changed fields for highlighting
689
- changed_fields = {c["field"] for c in changes} if changes else set()
690
-
691
- # Create table for patient details
692
- table = Table(show_header=False, box=None, padding=(0, 2))
693
- table.add_column("Field", style="dim")
694
- table.add_column("Value")
695
-
696
- displayed_fields = set()
697
- field_count = 0
698
-
699
- for category, fields in categories.items():
700
- category_rows = []
701
- for field in fields:
702
- value = data.get(field)
703
- if value is not None and value != "" and value != "null":
704
- displayed_fields.add(field)
705
- field_count += 1
706
- # Handle boolean values
707
- if isinstance(value, bool):
708
- value = "Yes" if value else "No"
709
- # Style changed fields
710
- if field in changed_fields:
711
- category_rows.append(
712
- (field, f"[bold yellow]{value}[/bold yellow] *")
713
- )
714
- else:
715
- category_rows.append((field, str(value)))
716
-
717
- if category_rows:
718
- # Add category header
719
- table.add_row(f"[bold cyan]{category}[/bold cyan]", "")
720
- for field, value in category_rows:
721
- table.add_row(f" {field}", value)
722
-
723
- # Show additional fields not in categories
724
- all_category_fields = set()
725
- for fields in categories.values():
726
- all_category_fields.update(fields)
727
-
728
- extra_rows = []
729
- for key, value in data.items():
730
- if key not in all_category_fields and key not in skip_fields:
731
- if value is not None and value != "" and value != "null":
732
- displayed_fields.add(key)
733
- field_count += 1
734
- if isinstance(value, bool):
735
- value = "Yes" if value else "No"
736
- if key in changed_fields:
737
- extra_rows.append(
738
- (key, f"[bold yellow]{value}[/bold yellow] *")
739
- )
740
- else:
741
- extra_rows.append((key, str(value)))
742
-
743
- if extra_rows:
744
- table.add_row("[bold cyan]📋 Additional[/bold cyan]", "")
745
- for field, value in extra_rows:
746
- table.add_row(f" {field}", value)
747
-
748
- # Print patient details in a panel
749
- console.print(Panel(table, title="Extracted Fields", border_style="blue"))
750
-
751
- # Summary for returning patients
752
- if not is_new:
753
- if changed_fields:
754
- console.print(
755
- f"[yellow]⚠️ {len(changed_fields)} field(s) changed:[/yellow] "
756
- f"{', '.join(changed_fields)}"
757
- )
758
- else:
759
- console.print("[green] All fields identical to previous visit[/green]")
760
- else:
761
- console.print(f"[dim]{field_count} fields extracted[/dim]")
762
-
763
- # Show ready for input prompt
764
- self.console.print_ready_for_input()
765
-
766
- def _find_existing_patient(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
767
- """Check if patient already exists in database."""
768
- if not data.get("first_name") or not data.get("last_name"):
769
- return None
770
-
771
- # Match on name + DOB (most reliable)
772
- if data.get("date_of_birth"):
773
- results = self.query(
774
- """SELECT * FROM patients
775
- WHERE first_name = :fn AND last_name = :ln AND date_of_birth = :dob
776
- ORDER BY created_at DESC LIMIT 1""",
777
- {
778
- "fn": data["first_name"],
779
- "ln": data["last_name"],
780
- "dob": data["date_of_birth"],
781
- },
782
- )
783
- if results:
784
- return results[0]
785
-
786
- # Fallback: match on name only (less reliable)
787
- results = self.query(
788
- """SELECT * FROM patients
789
- WHERE first_name = :fn AND last_name = :ln
790
- ORDER BY created_at DESC LIMIT 1""",
791
- {"fn": data["first_name"], "ln": data["last_name"]},
792
- )
793
- return results[0] if results else None
794
-
795
- def _detect_changes(
796
- self, existing: Dict[str, Any], new_data: Dict[str, Any]
797
- ) -> List[Dict[str, Any]]:
798
- """Detect changes between existing patient and new data."""
799
- fields_to_compare = [
800
- "phone",
801
- "email",
802
- "address",
803
- "city",
804
- "state",
805
- "zip_code",
806
- "insurance_provider",
807
- "insurance_id",
808
- "medications",
809
- "allergies",
810
- ]
811
- return detect_field_changes(existing, new_data, fields_to_compare)
812
-
813
- def _update_patient(self, patient_id: int, data: Dict[str, Any]) -> Optional[int]:
814
- """Update existing patient record with flexible schema support."""
815
- try:
816
- # Separate standard fields from additional fields
817
- update_data = {}
818
- additional_fields = {}
819
-
820
- for key, value in data.items():
821
- if key in UPDATABLE_COLUMNS:
822
- update_data[key] = value
823
- elif key not in ["first_name", "last_name", "date_of_birth", "gender"]:
824
- # Don't override identity fields, but capture extras
825
- if value is not None and value != "":
826
- additional_fields[key] = value
827
-
828
- # Merge with existing additional_fields if any
829
- if additional_fields:
830
- # Get existing additional_fields
831
- existing = self.query(
832
- "SELECT additional_fields FROM patients WHERE id = :id",
833
- {"id": patient_id},
834
- )
835
- if existing and existing[0].get("additional_fields"):
836
- try:
837
- existing_extra = json.loads(existing[0]["additional_fields"])
838
- existing_extra.update(additional_fields)
839
- additional_fields = existing_extra
840
- except json.JSONDecodeError:
841
- pass
842
-
843
- update_data["additional_fields"] = json.dumps(additional_fields)
844
- logger.info(
845
- f"Updating {len(additional_fields)} additional fields: "
846
- f"{list(additional_fields.keys())}"
847
- )
848
-
849
- update_data["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
850
-
851
- # Use mixin's update() method with proper signature
852
- self.update(
853
- "patients",
854
- update_data,
855
- "id = :id",
856
- {"id": patient_id},
857
- )
858
- logger.info(f"Updated patient record ID: {patient_id}")
859
- return patient_id
860
-
861
- except Exception as e:
862
- logger.error(f"Failed to update patient: {e}")
863
- return None
864
-
865
- def _record_intake_session(
866
- self,
867
- patient_id: int,
868
- path: Path,
869
- processing_time: float,
870
- is_new_patient: bool,
871
- changes_detected: List[Dict[str, Any]],
872
- ) -> None:
873
- """Record intake session for audit trail."""
874
- try:
875
- self.insert(
876
- "intake_sessions",
877
- {
878
- "patient_id": patient_id,
879
- "source_file": str(path.absolute()),
880
- "processing_time_seconds": processing_time,
881
- "is_new_patient": is_new_patient,
882
- "changes_detected": (
883
- json.dumps(changes_detected) if changes_detected else None
884
- ),
885
- },
886
- )
887
- except Exception as e:
888
- logger.warning(f"Failed to record intake session: {e}")
889
-
890
- def _create_alerts(self, patient_id: int, data: Dict[str, Any]) -> None:
891
- """Create alerts for critical items (allergies, missing fields)."""
892
- try:
893
- # Critical allergy alert (avoid duplicates for returning patients)
894
- if data.get("allergies"):
895
- # Check for existing unacknowledged allergy alert
896
- existing = self.query(
897
- """SELECT id FROM alerts
898
- WHERE patient_id = :pid AND alert_type = 'allergy'
899
- AND acknowledged = FALSE""",
900
- {"pid": patient_id},
901
- )
902
- if not existing:
903
- self.insert(
904
- "alerts",
905
- {
906
- "patient_id": patient_id,
907
- "alert_type": "allergy",
908
- "priority": "critical",
909
- "message": f"Patient has allergies: {data['allergies']}",
910
- "data": json.dumps({"allergies": data["allergies"]}),
911
- },
912
- )
913
-
914
- # Check for missing critical fields
915
- critical_fields = ["phone", "date_of_birth"]
916
- missing = [f for f in critical_fields if not data.get(f)]
917
- if missing:
918
- # Check for existing unacknowledged missing_field alert
919
- existing = self.query(
920
- """SELECT id FROM alerts
921
- WHERE patient_id = :pid AND alert_type = 'missing_field'
922
- AND acknowledged = FALSE""",
923
- {"pid": patient_id},
924
- )
925
- if not existing:
926
- self.insert(
927
- "alerts",
928
- {
929
- "patient_id": patient_id,
930
- "alert_type": "missing_field",
931
- "priority": "medium",
932
- "message": f"Missing critical fields: {', '.join(missing)}",
933
- "data": json.dumps({"missing_fields": missing}),
934
- },
935
- )
936
-
937
- except Exception as e:
938
- logger.warning(f"Failed to create alerts: {e}")
939
-
940
- def _read_file_as_image(self, path: Path) -> Optional[bytes]:
941
- """Read file and convert to optimized image bytes for VLM processing.
942
-
943
- Images are automatically resized if they exceed MAX_DIMENSION to improve
944
- processing speed while maintaining sufficient quality for OCR/extraction.
945
- """
946
- suffix = path.suffix.lower()
947
-
948
- if suffix == ".pdf":
949
- # Convert PDF first page to image (already optimized in pdf_page_to_image)
950
- return self._pdf_to_image(path)
951
- elif suffix in [".png", ".jpg", ".jpeg", ".tiff", ".bmp"]:
952
- raw_bytes = path.read_bytes()
953
- return self._optimize_image(raw_bytes)
954
- else:
955
- logger.warning(f"Unsupported file type: {suffix}")
956
- return None
957
-
958
- def _optimize_image(
959
- self,
960
- image_bytes: bytes,
961
- max_dimension: int = 1024,
962
- jpeg_quality: int = 85,
963
- ) -> bytes:
964
- """
965
- Optimize image for VLM processing by resizing large images.
966
-
967
- Reduces image dimensions while maintaining quality sufficient for OCR
968
- and text extraction. This dramatically improves processing speed for
969
- high-resolution scans and photos.
970
-
971
- Images are padded to square dimensions to avoid a Vulkan backend bug
972
- in llama.cpp where the UPSCALE operator is unsupported for certain
973
- non-square aspect ratios (particularly landscape orientations).
974
-
975
- Args:
976
- image_bytes: Raw image bytes (PNG, JPEG, etc.)
977
- max_dimension: Maximum width or height (default: 1024px)
978
- jpeg_quality: JPEG compression quality 1-100 (default: 85)
979
-
980
- Returns:
981
- Optimized image bytes (JPEG format, square dimensions)
982
- """
983
- import io
984
-
985
- try:
986
- from PIL import Image, ImageOps
987
-
988
- # Load image from bytes
989
- img = Image.open(io.BytesIO(image_bytes))
990
-
991
- # Apply EXIF orientation - phone photos are often stored landscape
992
- # but have EXIF metadata indicating they should be displayed as portrait
993
- img = ImageOps.exif_transpose(img)
994
-
995
- original_width, original_height = img.size
996
- original_size_kb = len(image_bytes) / 1024
997
-
998
- # Convert to RGB early if needed (for JPEG output)
999
- if img.mode in ("RGBA", "P"):
1000
- img = img.convert("RGB")
1001
-
1002
- # Check if resizing is needed
1003
- if original_width <= max_dimension and original_height <= max_dimension:
1004
- new_width, new_height = original_width, original_height
1005
- else:
1006
- # Calculate new dimensions maintaining aspect ratio
1007
- scale = min(
1008
- max_dimension / original_width, max_dimension / original_height
1009
- )
1010
- new_width = int(original_width * scale)
1011
- new_height = int(original_height * scale)
1012
-
1013
- # Resize with high-quality LANCZOS filter
1014
- img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
1015
-
1016
- # Pad to square to avoid Vulkan UPSCALE bug with non-square images
1017
- # The bug causes timeouts with landscape orientations (e.g., 1024x768)
1018
- if new_width != new_height:
1019
- square_size = max(new_width, new_height)
1020
- # Create white square canvas
1021
- square_img = Image.new(
1022
- "RGB", (square_size, square_size), (255, 255, 255)
1023
- )
1024
- # Center the image on the canvas
1025
- x_offset = (square_size - new_width) // 2
1026
- y_offset = (square_size - new_height) // 2
1027
- square_img.paste(img, (x_offset, y_offset))
1028
- img = square_img
1029
- final_size = square_size
1030
- was_padded = True
1031
- else:
1032
- final_size = new_width
1033
- was_padded = False
1034
-
1035
- # Save as optimized JPEG
1036
- output = io.BytesIO()
1037
- img.save(output, format="JPEG", quality=jpeg_quality, optimize=True)
1038
- optimized_bytes = output.getvalue()
1039
-
1040
- optimized_size_kb = len(optimized_bytes) / 1024
1041
- reduction_pct = (1 - optimized_size_kb / original_size_kb) * 100
1042
-
1043
- # Show optimization results to user
1044
- if was_padded:
1045
- self.console.print_info(
1046
- f"Image resized: {original_width}x{original_height} "
1047
- f"{final_size}x{final_size} (padded to square, "
1048
- f"{original_size_kb:.0f}KB → {optimized_size_kb:.0f}KB, "
1049
- f"{reduction_pct:.0f}% smaller)"
1050
- )
1051
- else:
1052
- self.console.print_info(
1053
- f"Image resized: {original_width}x{original_height} "
1054
- f"{new_width}x{new_height} ({original_size_kb:.0f}KB → "
1055
- f"{optimized_size_kb:.0f}KB, {reduction_pct:.0f}% smaller)"
1056
- )
1057
-
1058
- logger.info(
1059
- f"Image optimized: {original_width}x{original_height} "
1060
- f"{final_size}x{final_size}, {original_size_kb:.0f}KB "
1061
- f"{optimized_size_kb:.0f}KB ({reduction_pct:.0f}% reduction)"
1062
- f"{' (padded to square)' if was_padded else ''}"
1063
- )
1064
-
1065
- return optimized_bytes
1066
-
1067
- except ImportError:
1068
- logger.warning("PIL not available, returning original image")
1069
- return image_bytes
1070
- except Exception as e:
1071
- logger.warning(f"Image optimization failed: {e}, using original")
1072
- return image_bytes
1073
-
1074
- def _pdf_to_image(self, pdf_path: Path) -> Optional[bytes]:
1075
- """Convert first page of PDF to image bytes."""
1076
- return pdf_page_to_image(pdf_path, page=0, scale=2.0)
1077
-
1078
- def _parse_extraction(self, raw_text: str) -> Optional[Dict[str, Any]]:
1079
- """Parse extracted text into structured patient data."""
1080
- result = extract_json_from_text(raw_text)
1081
- if result is None:
1082
- logger.warning("No valid JSON found in extraction")
1083
- return None
1084
-
1085
- # Normalize phone fields: prefer mobile_phone if phone is not set
1086
- # This handles forms where VLM extracts to mobile_phone instead of phone
1087
- if not result.get("phone"):
1088
- for phone_field in [
1089
- "mobile_phone",
1090
- "home_phone",
1091
- "work_phone",
1092
- "cell_phone",
1093
- ]:
1094
- if result.get(phone_field):
1095
- result["phone"] = result[phone_field]
1096
- logger.debug(
1097
- f"Normalized {phone_field} to phone: {result['phone']}"
1098
- )
1099
- break
1100
-
1101
- # Also check emergency_contact_phone normalization
1102
- if not result.get("emergency_contact_phone"):
1103
- for ec_phone in ["emergency_phone", "emergency_contact"]:
1104
- if result.get(ec_phone) and isinstance(result[ec_phone], str):
1105
- # Check if it looks like a phone number
1106
- if any(c.isdigit() for c in result[ec_phone]):
1107
- result["emergency_contact_phone"] = result[ec_phone]
1108
- break
1109
-
1110
- return result
1111
-
1112
- def _store_patient(self, data: Dict[str, Any]) -> Optional[int]:
1113
- """Store patient data in database with flexible schema support."""
1114
- try:
1115
- # Validate required fields
1116
- if not data.get("first_name") or not data.get("last_name"):
1117
- logger.error("Missing required fields: first_name and/or last_name")
1118
- self.console.print_error("Cannot store patient: missing name fields")
1119
- return None
1120
-
1121
- # Separate standard fields from additional fields
1122
- insert_data = {}
1123
- additional_fields = {}
1124
-
1125
- for key, value in data.items():
1126
- if key in STANDARD_COLUMNS:
1127
- insert_data[key] = value
1128
- elif value is not None and value != "":
1129
- # Store non-empty extra fields in additional_fields
1130
- additional_fields[key] = value
1131
-
1132
- # Store additional fields as JSON if any exist
1133
- if additional_fields:
1134
- insert_data["additional_fields"] = json.dumps(additional_fields)
1135
- logger.info(
1136
- f"Storing {len(additional_fields)} additional fields: "
1137
- f"{list(additional_fields.keys())}"
1138
- )
1139
-
1140
- patient_id = self.insert("patients", insert_data)
1141
- logger.info(f"Stored patient record ID: {patient_id}")
1142
- return patient_id
1143
-
1144
- except Exception as e:
1145
- logger.error(f"Failed to store patient: {e}")
1146
- self.console.print_error(f"Database error: {str(e)}")
1147
- return None
1148
-
1149
- def _get_system_prompt(self) -> str:
1150
- """Generate the system prompt for the intake agent."""
1151
- return f"""You are a Medical Intake Assistant managing patient records.
1152
-
1153
- You have access to a database of patient intake forms that were automatically processed.
1154
-
1155
- **Your Capabilities:**
1156
- - Search for patients by name, DOB, or other criteria
1157
- - View patient details and intake information
1158
- - Report on processing statistics
1159
- - Answer questions about patient data
1160
-
1161
- **Current Status:**
1162
- - Watching directory: {self._watch_dir}
1163
- - Database: {self._db_path}
1164
- - Files processed: {self._stats.get('files_processed', 0)}
1165
- - Successful extractions: {self._stats.get('extraction_success', 0)}
1166
-
1167
- **Important:**
1168
- - Always protect patient privacy
1169
- - Only report data that was extracted from forms
1170
- - If asked about a patient not in the database, say so clearly
1171
-
1172
- Use the available tools to search and retrieve patient information."""
1173
-
1174
- def _register_tools(self):
1175
- """Register patient management tools."""
1176
- agent = self
1177
-
1178
- @tool
1179
- def search_patients(
1180
- name: Optional[str] = None,
1181
- date_of_birth: Optional[str] = None,
1182
- ) -> Dict[str, Any]:
1183
- """
1184
- Search for patients by name or date of birth.
1185
-
1186
- Args:
1187
- name: Patient name (first, last, or full name)
1188
- date_of_birth: Date of birth (YYYY-MM-DD format)
1189
-
1190
- Returns:
1191
- Dict with matching patients
1192
- """
1193
- conditions = []
1194
- params = {}
1195
-
1196
- if name:
1197
- conditions.append(
1198
- "(first_name LIKE :name OR last_name LIKE :name "
1199
- "OR (first_name || ' ' || last_name) LIKE :name)"
1200
- )
1201
- params["name"] = f"%{name}%"
1202
-
1203
- if date_of_birth:
1204
- conditions.append("date_of_birth = :dob")
1205
- params["dob"] = date_of_birth
1206
-
1207
- if not conditions:
1208
- # Return recent patients if no criteria
1209
- query = """
1210
- SELECT id, first_name, last_name, date_of_birth,
1211
- phone, reason_for_visit, created_at
1212
- FROM patients
1213
- ORDER BY created_at DESC
1214
- LIMIT 10
1215
- """
1216
- params = {}
1217
- else:
1218
- query = f"""
1219
- SELECT id, first_name, last_name, date_of_birth,
1220
- phone, reason_for_visit, created_at
1221
- FROM patients
1222
- WHERE {' AND '.join(conditions)}
1223
- ORDER BY created_at DESC
1224
- """
1225
-
1226
- results = agent.query(query, params)
1227
- return {
1228
- "patients": results,
1229
- "count": len(results),
1230
- "query": {"name": name, "date_of_birth": date_of_birth},
1231
- }
1232
-
1233
- @tool
1234
- def get_patient(patient_id: int) -> Dict[str, Any]:
1235
- """
1236
- Get full details for a specific patient.
1237
-
1238
- Args:
1239
- patient_id: The patient's database ID
1240
-
1241
- Returns:
1242
- Dict with patient details
1243
- """
1244
- results = agent.query(
1245
- "SELECT * FROM patients WHERE id = :id",
1246
- {"id": patient_id},
1247
- )
1248
-
1249
- if results:
1250
- patient = results[0]
1251
- # Remove large/binary fields - don't send to LLM
1252
- patient.pop("raw_extraction", None)
1253
- patient.pop("file_content", None) # Image bytes
1254
- patient.pop("embedding", None) # Vector embedding
1255
- # Truncate file_hash for display
1256
- if patient.get("file_hash"):
1257
- patient["file_hash"] = patient["file_hash"][:12] + "..."
1258
- return {"found": True, "patient": patient}
1259
- return {"found": False, "message": f"Patient ID {patient_id} not found"}
1260
-
1261
- @tool
1262
- def list_recent_patients(limit: int = 10) -> Dict[str, Any]:
1263
- """
1264
- List recently processed patients.
1265
-
1266
- Args:
1267
- limit: Maximum number of patients to return (default: 10)
1268
-
1269
- Returns:
1270
- Dict with recent patients
1271
- """
1272
- results = agent.query(
1273
- """
1274
- SELECT id, first_name, last_name, date_of_birth,
1275
- reason_for_visit, created_at
1276
- FROM patients
1277
- ORDER BY created_at DESC
1278
- LIMIT :limit
1279
- """,
1280
- {"limit": limit},
1281
- )
1282
- return {"patients": results, "count": len(results)}
1283
-
1284
- @tool
1285
- def get_intake_stats() -> Dict[str, Any]:
1286
- """
1287
- Get statistics about intake form processing.
1288
-
1289
- Returns:
1290
- Dict with processing statistics
1291
- """
1292
- return agent.get_stats()
1293
-
1294
- @tool
1295
- def process_file(file_path: str) -> Dict[str, Any]:
1296
- """
1297
- Manually process an intake form file.
1298
-
1299
- Args:
1300
- file_path: Path to the intake form file
1301
-
1302
- Returns:
1303
- Dict with processing result
1304
- """
1305
- path = Path(file_path)
1306
- if not path.exists():
1307
- return {"success": False, "error": f"File not found: {file_path}"}
1308
-
1309
- # pylint: disable=protected-access
1310
- result = agent._process_intake_form(str(path))
1311
- if result:
1312
- return {
1313
- "success": True,
1314
- "patient_id": result.get("id"),
1315
- "name": f"{result.get('first_name', '')} {result.get('last_name', '')}",
1316
- }
1317
- return {"success": False, "error": "Failed to extract patient data"}
1318
-
1319
- def get_stats(self) -> Dict[str, Any]:
1320
- """
1321
- Get statistics about intake form processing.
1322
-
1323
- Returns:
1324
- Dict with processing statistics including:
1325
- - total_patients: Total patient count
1326
- - processed_today: Patients processed today
1327
- - new_patients: New patient count
1328
- - returning_patients: Returning patient count
1329
- - files_processed: Total files processed
1330
- - extraction_success/failed: Success/failure counts
1331
- - success_rate: Success percentage
1332
- - time_saved_minutes/percent: Time savings metrics
1333
- - avg_processing_seconds: Average processing time
1334
- - unacknowledged_alerts: Alert count
1335
- - watching_directory: Watched directory path
1336
- - uptime_seconds: Agent uptime
1337
- """
1338
- # Get total patient count
1339
- count_result = self.query("SELECT COUNT(*) as count FROM patients")
1340
- total_patients = count_result[0]["count"] if count_result else 0
1341
-
1342
- # Get today's count
1343
- today_result = self.query(
1344
- "SELECT COUNT(*) as count FROM patients WHERE date(created_at) = date('now')"
1345
- )
1346
- today_count = today_result[0]["count"] if today_result else 0
1347
-
1348
- # Get unacknowledged alerts count
1349
- alerts_result = self.query(
1350
- "SELECT COUNT(*) as count FROM alerts WHERE acknowledged = FALSE"
1351
- )
1352
- unacknowledged_alerts = alerts_result[0]["count"] if alerts_result else 0
1353
-
1354
- # Calculate time savings based on actual extracted data
1355
- # Uses estimated manual entry time calculated per-form based on fields/characters
1356
- estimated_manual_seconds = self._stats["total_estimated_manual_seconds"]
1357
- actual_processing_seconds = self._stats["total_processing_time_seconds"]
1358
- time_saved_seconds = max(
1359
- 0, estimated_manual_seconds - actual_processing_seconds
1360
- )
1361
-
1362
- # Calculate percentage improvement
1363
- if estimated_manual_seconds > 0:
1364
- time_saved_percent = (time_saved_seconds / estimated_manual_seconds) * 100
1365
- else:
1366
- time_saved_percent = 0
1367
-
1368
- # Average estimated manual time per form
1369
- successful = self._stats["extraction_success"]
1370
- avg_manual_seconds = (
1371
- estimated_manual_seconds / successful if successful > 0 else 0
1372
- )
1373
-
1374
- return {
1375
- "total_patients": total_patients,
1376
- "processed_today": today_count,
1377
- "new_patients": self._stats["new_patients"],
1378
- "returning_patients": self._stats["returning_patients"],
1379
- "files_processed": self._stats["files_processed"],
1380
- "extraction_success": self._stats["extraction_success"],
1381
- "extraction_failed": self._stats["extraction_failed"],
1382
- "success_rate": (
1383
- f"{(self._stats['extraction_success'] / self._stats['files_processed'] * 100):.1f}%"
1384
- if self._stats["files_processed"] > 0
1385
- else "N/A"
1386
- ),
1387
- # Total cumulative metrics
1388
- "time_saved_seconds": round(time_saved_seconds, 1),
1389
- "time_saved_minutes": round(time_saved_seconds / 60, 1),
1390
- "time_saved_percent": f"{time_saved_percent:.0f}%",
1391
- "total_estimated_manual_seconds": round(estimated_manual_seconds, 1),
1392
- "total_ai_processing_seconds": round(actual_processing_seconds, 1),
1393
- # Per-form averages
1394
- "avg_manual_seconds": round(avg_manual_seconds, 1),
1395
- "avg_processing_seconds": (
1396
- round(actual_processing_seconds / successful, 1)
1397
- if successful > 0
1398
- else 0
1399
- ),
1400
- # Legacy field name for backwards compatibility
1401
- "estimated_manual_seconds": round(estimated_manual_seconds, 1),
1402
- "unacknowledged_alerts": unacknowledged_alerts,
1403
- "watching_directory": str(self._watch_dir),
1404
- "uptime_seconds": int(time.time() - self._stats["start_time"]),
1405
- }
1406
-
1407
- def clear_database(self) -> Dict[str, Any]:
1408
- """
1409
- Clear all data from the database and reset statistics.
1410
-
1411
- This removes all patients, alerts, and intake sessions, providing
1412
- a clean slate for fresh processing.
1413
-
1414
- Returns:
1415
- Dict with counts of deleted records
1416
- """
1417
- logger.info("Clearing database...")
1418
- counts = {}
1419
-
1420
- try:
1421
- # Get counts before deletion
1422
- for table in ["patients", "alerts", "intake_sessions"]:
1423
- result = self.query(f"SELECT COUNT(*) as count FROM {table}")
1424
- counts[table] = result[0]["count"] if result else 0
1425
-
1426
- # Delete all records from each table
1427
- self.execute("DELETE FROM intake_sessions")
1428
- self.execute("DELETE FROM alerts")
1429
- self.execute("DELETE FROM patients")
1430
-
1431
- # Reset in-memory statistics
1432
- self._stats = {
1433
- "files_processed": 0,
1434
- "extraction_success": 0,
1435
- "extraction_failed": 0,
1436
- "new_patients": 0,
1437
- "returning_patients": 0,
1438
- "total_processing_time_seconds": 0.0,
1439
- "total_estimated_manual_seconds": 0.0,
1440
- "start_time": time.time(),
1441
- }
1442
-
1443
- # Clear processed files list
1444
- self._processed_files = []
1445
-
1446
- logger.info(
1447
- f"Database cleared: {counts.get('patients', 0)} patients, "
1448
- f"{counts.get('alerts', 0)} alerts, "
1449
- f"{counts.get('intake_sessions', 0)} sessions"
1450
- )
1451
-
1452
- return {
1453
- "success": True,
1454
- "deleted": counts,
1455
- "message": "Database cleared successfully",
1456
- }
1457
-
1458
- except Exception as e:
1459
- logger.error(f"Failed to clear database: {e}")
1460
- return {
1461
- "success": False,
1462
- "error": str(e),
1463
- "message": "Failed to clear database",
1464
- }
1465
-
1466
- def stop(self) -> None:
1467
- """Stop the agent and clean up resources."""
1468
- logger.info("Stopping Medical Intake Agent...")
1469
- errors = []
1470
-
1471
- # Stop file watchers
1472
- try:
1473
- self.stop_all_watchers()
1474
- except Exception as e:
1475
- errors.append(f"Failed to stop watchers: {e}")
1476
- logger.error(errors[-1])
1477
-
1478
- # Close database
1479
- try:
1480
- self.close_db()
1481
- except Exception as e:
1482
- errors.append(f"Failed to close database: {e}")
1483
- logger.error(errors[-1])
1484
-
1485
- # Cleanup VLM
1486
- try:
1487
- if self._vlm:
1488
- self._vlm.cleanup()
1489
- self._vlm = None
1490
- except Exception as e:
1491
- errors.append(f"Failed to cleanup VLM: {e}")
1492
- logger.error(errors[-1])
1493
-
1494
- if errors:
1495
- logger.warning(f"Cleanup completed with {len(errors)} error(s)")
1496
- else:
1497
- logger.info("Medical Intake Agent stopped")
1498
-
1499
- def __enter__(self):
1500
- """Context manager entry."""
1501
- return self
1502
-
1503
- def __exit__(self, exc_type, exc_val, exc_tb):
1504
- """Context manager exit with cleanup."""
1505
- self.stop()
1506
- return False
1
+ # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Medical Intake Agent for processing patient intake forms.
6
+
7
+ Watches a directory for new intake forms (images/PDFs), extracts patient
8
+ data using VLM, and stores records in a SQLite database.
9
+
10
+ NOTE: This is a demonstration/proof-of-concept application.
11
+ Not intended for production use with real patient data.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from gaia.agents.base import Agent
21
+ from gaia.agents.base.tools import tool
22
+ from gaia.database import DatabaseMixin
23
+ from gaia.llm.vlm_client import detect_image_mime_type
24
+ from gaia.utils import (
25
+ FileWatcherMixin,
26
+ compute_file_hash,
27
+ detect_field_changes,
28
+ extract_json_from_text,
29
+ pdf_page_to_image,
30
+ )
31
+
32
+ from .constants import (
33
+ EXTRACTION_PROMPT,
34
+ PATIENT_SCHEMA,
35
+ STANDARD_COLUMNS,
36
+ UPDATABLE_COLUMNS,
37
+ estimate_manual_entry_time,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class MedicalIntakeAgent(Agent, DatabaseMixin, FileWatcherMixin):
44
+ """
45
+ Agent for processing medical intake forms automatically.
46
+
47
+ Watches a directory for new intake forms (images/PDFs), extracts
48
+ patient data using VLM (Vision Language Model), and stores the
49
+ records in a SQLite database.
50
+
51
+ Features:
52
+ - Automatic file watching for new intake forms
53
+ - VLM-powered data extraction from images
54
+ - SQLite database storage with full-text search
55
+ - Tools for patient lookup and management
56
+ - Rich console output for processing status
57
+
58
+ Example:
59
+ from gaia.agents.emr import MedicalIntakeAgent
60
+
61
+ agent = MedicalIntakeAgent(
62
+ watch_dir="./intake_forms",
63
+ db_path="./data/patients.db",
64
+ )
65
+
66
+ # Agent automatically processes new files in watch_dir
67
+ # Query the agent about patients
68
+ agent.process_query("How many patients were processed today?")
69
+ agent.process_query("Find patient John Smith")
70
+
71
+ # Cleanup
72
+ agent.stop()
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ watch_dir: str = "./intake_forms",
78
+ db_path: str = "./data/patients.db",
79
+ vlm_model: str = "Qwen3-VL-4B-Instruct-GGUF",
80
+ auto_start_watching: bool = True,
81
+ **kwargs,
82
+ ):
83
+ """
84
+ Initialize the Medical Intake Agent.
85
+
86
+ Args:
87
+ watch_dir: Directory to watch for new intake forms
88
+ db_path: Path to SQLite database for patient records
89
+ vlm_model: VLM model to use for extraction
90
+ auto_start_watching: Start watching immediately (default: True)
91
+ **kwargs: Additional arguments for Agent base class
92
+ """
93
+ # Set attributes before super().__init__() as it may call _get_system_prompt()
94
+ self._watch_dir = Path(watch_dir)
95
+ self._db_path = db_path
96
+ self._vlm_model = vlm_model
97
+ self._vlm = None
98
+ self._processed_files: List[Dict[str, Any]] = []
99
+ self._auto_start_watching = auto_start_watching
100
+
101
+ # Statistics
102
+ self._stats = {
103
+ "files_processed": 0,
104
+ "extraction_success": 0,
105
+ "extraction_failed": 0,
106
+ "new_patients": 0,
107
+ "returning_patients": 0,
108
+ "total_processing_time_seconds": 0.0,
109
+ "total_estimated_manual_seconds": 0.0,
110
+ "start_time": time.time(),
111
+ }
112
+
113
+ # Progress callback for external monitoring (e.g., dashboard SSE)
114
+ # Signature: callback(filename, step_num, total_steps, step_name, status)
115
+ self._progress_callback: Optional[callable] = None
116
+
117
+ # Set reasonable defaults for agent - higher max_steps for interactive use
118
+ kwargs.setdefault("max_steps", 50)
119
+
120
+ super().__init__(**kwargs)
121
+
122
+ # Initialize database
123
+ self._init_database()
124
+
125
+ # Load historical stats from database (for pre-processed forms)
126
+ self._load_historical_stats()
127
+
128
+ # Create watch directory if needed
129
+ self._watch_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ # Start file watching if requested
132
+ if auto_start_watching:
133
+ self._start_file_watching()
134
+
135
+ def _init_database(self) -> None:
136
+ """Initialize the patient database."""
137
+ try:
138
+ # Ensure data directory exists
139
+ db_dir = Path(self._db_path).parent
140
+ db_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ # Initialize database with schema
143
+ self.init_db(self._db_path)
144
+ self.execute(PATIENT_SCHEMA)
145
+ logger.info(f"Database initialized: {self._db_path}")
146
+ except Exception as e:
147
+ logger.error(f"Failed to initialize database: {e}")
148
+ raise
149
+
150
+ def _load_historical_stats(self) -> None:
151
+ """Load historical processing stats from database for pre-processed forms.
152
+
153
+ This ensures efficiency metrics include forms processed in previous sessions,
154
+ not just the current agent instance.
155
+ """
156
+ try:
157
+ # Get aggregate stats from patients table
158
+ result = self.query("""
159
+ SELECT
160
+ COUNT(*) as total_patients,
161
+ COALESCE(SUM(processing_time_seconds), 0) as total_processing_time,
162
+ COALESCE(SUM(estimated_manual_seconds), 0) as total_estimated_manual,
163
+ SUM(CASE WHEN is_new_patient = 1 THEN 1 ELSE 0 END) as new_patients,
164
+ SUM(CASE WHEN is_new_patient = 0 THEN 1 ELSE 0 END) as returning_patients
165
+ FROM patients
166
+ """)
167
+
168
+ if result and result[0]:
169
+ stats = result[0]
170
+ self._stats["extraction_success"] = stats.get("total_patients", 0) or 0
171
+ self._stats["files_processed"] = stats.get("total_patients", 0) or 0
172
+ self._stats["total_processing_time_seconds"] = float(
173
+ stats.get("total_processing_time", 0) or 0
174
+ )
175
+ self._stats["total_estimated_manual_seconds"] = float(
176
+ stats.get("total_estimated_manual", 0) or 0
177
+ )
178
+ self._stats["new_patients"] = stats.get("new_patients", 0) or 0
179
+ self._stats["returning_patients"] = (
180
+ stats.get("returning_patients", 0) or 0
181
+ )
182
+
183
+ if self._stats["extraction_success"] > 0:
184
+ logger.info(
185
+ f"Loaded historical stats: {self._stats['extraction_success']} forms, "
186
+ f"{self._stats['total_processing_time_seconds']:.1f}s AI time, "
187
+ f"{self._stats['total_estimated_manual_seconds']:.1f}s manual time"
188
+ )
189
+ except Exception as e:
190
+ # Don't fail if historical stats can't be loaded (e.g., schema mismatch)
191
+ logger.warning(f"Could not load historical stats: {e}")
192
+
193
+ def _start_file_watching(self) -> None:
194
+ """Start watching the intake directory for new files."""
195
+ # First, process any existing files (works even if watcher fails)
196
+ self._process_existing_files()
197
+
198
+ # Then set up the watcher for new files
199
+ try:
200
+ self.watch_directory(
201
+ self._watch_dir,
202
+ on_created=self._on_file_created,
203
+ on_modified=self._on_file_modified,
204
+ extensions=[".png", ".jpg", ".jpeg", ".pdf", ".tiff", ".bmp"],
205
+ debounce_seconds=2.0,
206
+ )
207
+ logger.info(f"Watching for intake forms: {self._watch_dir}")
208
+ except Exception as e:
209
+ logger.warning(f"File watching not available: {e}")
210
+
211
+ def _print_file_listing(
212
+ self, files: list, processed_hashes: set
213
+ ) -> tuple[int, int]:
214
+ """Print a styled listing of files in the watch directory.
215
+
216
+ Returns:
217
+ Tuple of (new_count, processed_count)
218
+ """
219
+ from rich.console import Console
220
+ from rich.table import Table
221
+
222
+ console = Console()
223
+
224
+ table = Table(
225
+ title=f"📁 {self._watch_dir}", show_header=True, header_style="bold cyan"
226
+ )
227
+ table.add_column("File", style="white")
228
+ table.add_column("Size", justify="right", style="dim")
229
+ table.add_column("Hash", style="dim")
230
+ table.add_column("Status", justify="center")
231
+
232
+ new_count = 0
233
+ processed_count = 0
234
+
235
+ for f in sorted(files):
236
+ try:
237
+ size = f.stat().st_size
238
+ if size < 1024:
239
+ size_str = f"{size} B"
240
+ elif size < 1024 * 1024:
241
+ size_str = f"{size / 1024:.1f} KB"
242
+ else:
243
+ size_str = f"{size / (1024 * 1024):.1f} MB"
244
+ except OSError:
245
+ size_str = "?"
246
+
247
+ # Compute hash for status check
248
+ file_hash = compute_file_hash(f)
249
+ hash_display = file_hash[:8] + "..." if file_hash else "?"
250
+
251
+ if file_hash and file_hash in processed_hashes:
252
+ status = "[dim]✓ processed[/dim]"
253
+ processed_count += 1
254
+ else:
255
+ status = "[green]● new[/green]"
256
+ new_count += 1
257
+
258
+ table.add_row(f.name, size_str, hash_display, status)
259
+
260
+ console.print(table)
261
+
262
+ # Print summary
263
+ summary_parts = []
264
+ if new_count > 0:
265
+ summary_parts.append(f"[green]{new_count} new[/green]")
266
+ if processed_count > 0:
267
+ summary_parts.append(f"[dim]{processed_count} already processed[/dim]")
268
+ if summary_parts:
269
+ console.print(f" {', '.join(summary_parts)}")
270
+ console.print()
271
+
272
+ return new_count, processed_count
273
+
274
+ def _process_existing_files(self) -> None:
275
+ """Scan and process any existing files in the watch directory."""
276
+ supported_extensions = {".png", ".jpg", ".jpeg", ".pdf", ".tiff", ".bmp"}
277
+
278
+ # Check directory exists
279
+ if not self._watch_dir.exists():
280
+ self.console.print_warning(
281
+ f"Watch directory does not exist: {self._watch_dir}"
282
+ )
283
+ return
284
+
285
+ # Use case-insensitive matching on Windows
286
+ existing_files = set()
287
+ try:
288
+ for f in self._watch_dir.iterdir():
289
+ if f.is_file() and f.suffix.lower() in supported_extensions:
290
+ existing_files.add(f.absolute())
291
+ except Exception as e:
292
+ self.console.print_error(f"Could not scan directory: {e}")
293
+ return
294
+
295
+ # Get all processed file hashes from database
296
+ processed_hashes = set()
297
+ try:
298
+ results = self.query(
299
+ "SELECT DISTINCT file_hash FROM patients WHERE file_hash IS NOT NULL"
300
+ )
301
+ for r in results:
302
+ if r.get("file_hash"):
303
+ processed_hashes.add(r["file_hash"])
304
+ except Exception as e:
305
+ logger.debug(f"Could not query processed hashes: {e}")
306
+
307
+ # Always show file listing at startup
308
+ if existing_files:
309
+ new_count, _processed_count = self._print_file_listing(
310
+ existing_files, processed_hashes
311
+ )
312
+ else:
313
+ self.console.print_info(f"No intake files found in {self._watch_dir}")
314
+ return
315
+
316
+ # Process new files
317
+ if new_count > 0:
318
+ self.console.print_info(f"Processing {new_count} new file(s)...")
319
+ for f in sorted(existing_files):
320
+ file_hash = compute_file_hash(f)
321
+ if file_hash and file_hash not in processed_hashes:
322
+ self._on_file_created(f)
323
+
324
+ def _get_vlm(self):
325
+ """Get or create VLM client (lazy initialization)."""
326
+ if self._vlm is None:
327
+ try:
328
+ from gaia.llm import VLMClient
329
+
330
+ self.console.print_model_loading(self._vlm_model)
331
+ self._vlm = VLMClient(vlm_model=self._vlm_model)
332
+ self.console.print_model_ready(self._vlm_model)
333
+ logger.debug(f"VLM client initialized: {self._vlm_model}")
334
+ except Exception as e:
335
+ logger.error(f"Failed to initialize VLM: {e}")
336
+ return None
337
+ return self._vlm
338
+
339
+ def _on_file_created(self, path: str) -> None:
340
+ """Handle new file creation in watched directory."""
341
+ file_path = Path(path)
342
+
343
+ # Wait for file to be fully written (Windows file locking)
344
+ time.sleep(0.5)
345
+
346
+ try:
347
+ size = file_path.stat().st_size
348
+ except (FileNotFoundError, OSError):
349
+ size = 0
350
+
351
+ self.console.print_file_created(
352
+ filename=file_path.name,
353
+ size=size,
354
+ extension=file_path.suffix,
355
+ )
356
+
357
+ # Process the file with retry for file locking issues
358
+ max_retries = 3
359
+ for attempt in range(max_retries):
360
+ try:
361
+ self._process_intake_form(path)
362
+ break
363
+ except PermissionError as e:
364
+ if attempt < max_retries - 1:
365
+ logger.warning(
366
+ f"File locked, retrying in 2s ({attempt + 1}/{max_retries}): {e}"
367
+ )
368
+ time.sleep(2.0)
369
+ else:
370
+ logger.error(
371
+ f"Failed to process file after {max_retries} attempts: {e}"
372
+ )
373
+ self.console.print_error(f"Could not access file: {file_path.name}")
374
+
375
+ def _on_file_modified(self, path: str) -> None:
376
+ """Handle file modification (re-process if needed)."""
377
+ # Don't auto-reprocess modified files to avoid duplicates
378
+ _ = path # Intentionally unused - modifications don't trigger reprocessing
379
+
380
+ def _emit_progress(
381
+ self,
382
+ filename: str,
383
+ step_num: int,
384
+ total_steps: int,
385
+ step_name: str,
386
+ status: str = "running",
387
+ ) -> None:
388
+ """
389
+ Emit progress update to console and optional callback.
390
+
391
+ Args:
392
+ filename: Name of file being processed
393
+ step_num: Current step number (1-based)
394
+ total_steps: Total number of processing steps
395
+ step_name: Human-readable step name
396
+ status: 'running', 'complete', or 'error'
397
+ """
398
+ # Update console
399
+ self.console.print_processing_step(step_num, total_steps, step_name, status)
400
+
401
+ # Call external callback if registered (e.g., for SSE events)
402
+ if self._progress_callback:
403
+ try:
404
+ self._progress_callback(
405
+ filename, step_num, total_steps, step_name, status
406
+ )
407
+ except Exception as e:
408
+ logger.debug(f"Progress callback error: {e}")
409
+
410
+ def _process_intake_form(self, file_path: str) -> Optional[Dict[str, Any]]:
411
+ """
412
+ Process an intake form and extract patient data.
413
+
414
+ Args:
415
+ file_path: Path to the intake form (image or PDF)
416
+
417
+ Returns:
418
+ Extracted patient data dict, or None if extraction failed
419
+ """
420
+ path = Path(file_path)
421
+ start_time = time.time()
422
+ self._stats["files_processed"] += 1
423
+ filename = path.name
424
+ total_steps = 7 # Total processing steps
425
+
426
+ logger.debug(f"Processing intake form: {filename}")
427
+
428
+ # Start pipeline progress display
429
+ self.console.print_processing_pipeline_start(filename, total_steps)
430
+
431
+ try:
432
+ # Step 1: Read file
433
+ self._emit_progress(filename, 1, total_steps, "Reading file")
434
+ try:
435
+ with open(path, "rb") as f:
436
+ file_content = f.read()
437
+ except (OSError, IOError) as e:
438
+ logger.error(f"Could not read file: {e}")
439
+ self._emit_progress(filename, 1, total_steps, "Reading file", "error")
440
+ self._stats["extraction_failed"] += 1
441
+ return None
442
+
443
+ # Step 2: Check for duplicates
444
+ self._emit_progress(filename, 2, total_steps, "Checking for duplicates")
445
+ file_hash = compute_file_hash(path)
446
+ if file_hash:
447
+ existing = self.query(
448
+ "SELECT id, first_name, last_name FROM patients WHERE file_hash = ?",
449
+ (file_hash,),
450
+ )
451
+ if existing:
452
+ patient = existing[0]
453
+ name = f"{patient.get('first_name', '')} {patient.get('last_name', '')}".strip()
454
+ self.console.print_info(
455
+ f"Skipping duplicate file (hash: {file_hash[:8]}...) - "
456
+ f"Already processed as patient: {name} (ID: {patient['id']})"
457
+ )
458
+ # Emit duplicate event for Live Feed
459
+ self._emit_progress(
460
+ filename,
461
+ 2,
462
+ total_steps,
463
+ f"Duplicate - already processed as {name}",
464
+ "duplicate",
465
+ )
466
+ # Show completion in console
467
+ self.console.print_processing_pipeline_complete(
468
+ filename,
469
+ True,
470
+ time.time() - start_time,
471
+ name,
472
+ is_duplicate=True,
473
+ )
474
+ return None
475
+
476
+ # Step 3: Prepare and optimize image
477
+ self._emit_progress(filename, 3, total_steps, "Optimizing image")
478
+ image_bytes = self._read_file_as_image(path)
479
+ if image_bytes is None:
480
+ self._emit_progress(
481
+ filename, 3, total_steps, "Optimizing image", "error"
482
+ )
483
+ self._stats["extraction_failed"] += 1
484
+ return None
485
+
486
+ # Step 4: Load VLM model
487
+ self._emit_progress(filename, 4, total_steps, "Loading AI model")
488
+ vlm = self._get_vlm()
489
+ if vlm is None:
490
+ logger.error("VLM not available")
491
+ self._emit_progress(
492
+ filename, 4, total_steps, "Loading AI model", "error"
493
+ )
494
+ self._stats["extraction_failed"] += 1
495
+ return None
496
+
497
+ # Step 5: Extract data with VLM
498
+ self._emit_progress(filename, 5, total_steps, "Extracting patient data")
499
+ mime_type = detect_image_mime_type(image_bytes)
500
+ size_kb = len(image_bytes) / 1024
501
+ self.console.print_extraction_start(1, 1, mime_type)
502
+
503
+ extraction_start = time.time()
504
+ raw_text = vlm.extract_from_image(
505
+ image_bytes=image_bytes,
506
+ prompt=EXTRACTION_PROMPT,
507
+ )
508
+ extraction_time = time.time() - extraction_start
509
+
510
+ # Check for VLM extraction errors (surfaced to user)
511
+ if raw_text.startswith("[VLM extraction failed:"):
512
+ # Extract the error message from the marker
513
+ error_msg = raw_text[1:-1] if raw_text.endswith("]") else raw_text
514
+ self.console.print_error(f"❌ {error_msg}")
515
+ logger.error(f"VLM extraction failed for {path.name}: {error_msg}")
516
+ self._emit_progress(
517
+ filename, 5, total_steps, "Extracting patient data", "error"
518
+ )
519
+ self._stats["extraction_failed"] += 1
520
+ return None
521
+
522
+ self.console.print_extraction_complete(
523
+ len(raw_text), 1, extraction_time, size_kb
524
+ )
525
+
526
+ # Step 6: Parse extraction
527
+ self._emit_progress(filename, 6, total_steps, "Parsing extracted data")
528
+ patient_data = self._parse_extraction(raw_text)
529
+ if patient_data is None:
530
+ logger.warning(f"Failed to parse extraction for: {path.name}")
531
+ self._emit_progress(
532
+ filename, 6, total_steps, "Parsing extracted data", "error"
533
+ )
534
+ self._stats["extraction_failed"] += 1
535
+ return None
536
+
537
+ # Add metadata including file content and hash
538
+ patient_data["source_file"] = str(path.absolute())
539
+ patient_data["raw_extraction"] = raw_text
540
+ patient_data["file_hash"] = file_hash
541
+ patient_data["file_content"] = file_content
542
+
543
+ # Check for returning patient (by name/DOB, not file hash)
544
+ existing_patient = self._find_existing_patient(patient_data)
545
+ is_new_patient = existing_patient is None
546
+ changes_detected = []
547
+
548
+ if existing_patient:
549
+ # Detect changes for returning patient
550
+ changes_detected = self._detect_changes(existing_patient, patient_data)
551
+ patient_data["is_new_patient"] = False
552
+ self._stats["returning_patients"] += 1
553
+ else:
554
+ patient_data["is_new_patient"] = True
555
+ self._stats["new_patients"] += 1
556
+
557
+ # Calculate processing time
558
+ processing_time = time.time() - start_time
559
+ patient_data["processing_time_seconds"] = processing_time
560
+ self._stats["total_processing_time_seconds"] += processing_time
561
+
562
+ # Calculate estimated manual entry time based on extracted data
563
+ estimated_manual = estimate_manual_entry_time(patient_data)
564
+ patient_data["estimated_manual_seconds"] = estimated_manual
565
+ self._stats["total_estimated_manual_seconds"] += estimated_manual
566
+
567
+ # Step 7: Save to database
568
+ self._emit_progress(filename, 7, total_steps, "Saving to database")
569
+ if existing_patient:
570
+ patient_id = self._update_patient(existing_patient["id"], patient_data)
571
+ else:
572
+ patient_id = self._store_patient(patient_data)
573
+
574
+ if patient_id:
575
+ self._stats["extraction_success"] += 1
576
+ patient_data["id"] = patient_id
577
+ patient_data["changes_detected"] = changes_detected
578
+
579
+ # Record intake session for audit trail
580
+ self._record_intake_session(
581
+ patient_id, path, processing_time, is_new_patient, changes_detected
582
+ )
583
+
584
+ # Create alerts for critical items
585
+ self._create_alerts(patient_id, patient_data)
586
+
587
+ self._processed_files.append(
588
+ {
589
+ "file": path.name,
590
+ "patient_id": patient_id,
591
+ "name": f"{patient_data.get('first_name', '')} {patient_data.get('last_name', '')}",
592
+ "is_new_patient": is_new_patient,
593
+ "changes_detected": changes_detected,
594
+ "processing_time_seconds": processing_time,
595
+ "processed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
596
+ }
597
+ )
598
+
599
+ # Limit memory usage - keep only last 1000 entries
600
+ if len(self._processed_files) > 1000:
601
+ self._processed_files = self._processed_files[-1000:]
602
+
603
+ # Show pipeline completion
604
+ patient_name = f"{patient_data.get('first_name', '')} {patient_data.get('last_name', '')}".strip()
605
+ self.console.print_processing_pipeline_complete(
606
+ filename, True, processing_time, patient_name
607
+ )
608
+
609
+ status = "NEW" if is_new_patient else "RETURNING"
610
+ self.console.print_success(
611
+ f"[{status}] Patient record: {patient_data.get('first_name')} "
612
+ f"{patient_data.get('last_name')} (ID: {patient_id})"
613
+ )
614
+
615
+ # Display extracted patient details
616
+ self._print_patient_details(
617
+ patient_data, changes_detected, is_new_patient
618
+ )
619
+
620
+ return patient_data
621
+
622
+ except Exception as e:
623
+ logger.error(f"Error processing {path.name}: {e}")
624
+ self.console.print_processing_pipeline_complete(
625
+ filename, False, time.time() - start_time
626
+ )
627
+ self._stats["extraction_failed"] += 1
628
+
629
+ return None
630
+
631
+ def _print_patient_details(
632
+ self, data: Dict[str, Any], changes: List[Dict[str, Any]], is_new: bool = True
633
+ ) -> None:
634
+ """Print extracted patient details to console using Rich formatting."""
635
+ from rich.console import Console
636
+ from rich.panel import Panel
637
+ from rich.table import Table
638
+
639
+ console = Console()
640
+
641
+ # Fields to skip in display (especially binary/large data)
642
+ skip_fields = {
643
+ "id",
644
+ "source_file",
645
+ "raw_extraction",
646
+ "additional_fields",
647
+ "is_new_patient",
648
+ "processing_time_seconds",
649
+ "changes_detected",
650
+ "created_at",
651
+ "updated_at",
652
+ "file_content", # Binary image data
653
+ "file_hash", # Hash string
654
+ }
655
+
656
+ # Group fields by category with icons
657
+ categories = {
658
+ "👤 Identity": [
659
+ "first_name",
660
+ "last_name",
661
+ "date_of_birth",
662
+ "gender",
663
+ "ssn",
664
+ ],
665
+ "📞 Contact": [
666
+ "phone",
667
+ "mobile_phone",
668
+ "email",
669
+ "address",
670
+ "city",
671
+ "state",
672
+ "zip_code",
673
+ ],
674
+ "🏥 Insurance": ["insurance_provider", "insurance_id", "insurance_group"],
675
+ "💊 Medical": [
676
+ "reason_for_visit",
677
+ "allergies",
678
+ "medications",
679
+ "date_of_injury",
680
+ ],
681
+ "🆘 Emergency": ["emergency_contact_name", "emergency_contact_phone"],
682
+ "💼 Employment": ["employer", "occupation", "work_related_injury"],
683
+ "👨‍⚕️ Provider": ["referring_physician"],
684
+ }
685
+
686
+ # Track changed fields for highlighting
687
+ changed_fields = {c["field"] for c in changes} if changes else set()
688
+
689
+ # Create table for patient details
690
+ table = Table(show_header=False, box=None, padding=(0, 2))
691
+ table.add_column("Field", style="dim")
692
+ table.add_column("Value")
693
+
694
+ displayed_fields = set()
695
+ field_count = 0
696
+
697
+ for category, fields in categories.items():
698
+ category_rows = []
699
+ for field in fields:
700
+ value = data.get(field)
701
+ if value is not None and value != "" and value != "null":
702
+ displayed_fields.add(field)
703
+ field_count += 1
704
+ # Handle boolean values
705
+ if isinstance(value, bool):
706
+ value = "Yes" if value else "No"
707
+ # Style changed fields
708
+ if field in changed_fields:
709
+ category_rows.append(
710
+ (field, f"[bold yellow]{value}[/bold yellow] *")
711
+ )
712
+ else:
713
+ category_rows.append((field, str(value)))
714
+
715
+ if category_rows:
716
+ # Add category header
717
+ table.add_row(f"[bold cyan]{category}[/bold cyan]", "")
718
+ for field, value in category_rows:
719
+ table.add_row(f" {field}", value)
720
+
721
+ # Show additional fields not in categories
722
+ all_category_fields = set()
723
+ for fields in categories.values():
724
+ all_category_fields.update(fields)
725
+
726
+ extra_rows = []
727
+ for key, value in data.items():
728
+ if key not in all_category_fields and key not in skip_fields:
729
+ if value is not None and value != "" and value != "null":
730
+ displayed_fields.add(key)
731
+ field_count += 1
732
+ if isinstance(value, bool):
733
+ value = "Yes" if value else "No"
734
+ if key in changed_fields:
735
+ extra_rows.append(
736
+ (key, f"[bold yellow]{value}[/bold yellow] *")
737
+ )
738
+ else:
739
+ extra_rows.append((key, str(value)))
740
+
741
+ if extra_rows:
742
+ table.add_row("[bold cyan]📋 Additional[/bold cyan]", "")
743
+ for field, value in extra_rows:
744
+ table.add_row(f" {field}", value)
745
+
746
+ # Print patient details in a panel
747
+ console.print(Panel(table, title="Extracted Fields", border_style="blue"))
748
+
749
+ # Summary for returning patients
750
+ if not is_new:
751
+ if changed_fields:
752
+ console.print(
753
+ f"[yellow]⚠️ {len(changed_fields)} field(s) changed:[/yellow] "
754
+ f"{', '.join(changed_fields)}"
755
+ )
756
+ else:
757
+ console.print("[green]✓ All fields identical to previous visit[/green]")
758
+ else:
759
+ console.print(f"[dim]{field_count} fields extracted[/dim]")
760
+
761
+ # Show ready for input prompt
762
+ self.console.print_ready_for_input()
763
+
764
+ def _find_existing_patient(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
765
+ """Check if patient already exists in database."""
766
+ if not data.get("first_name") or not data.get("last_name"):
767
+ return None
768
+
769
+ # Match on name + DOB (most reliable)
770
+ if data.get("date_of_birth"):
771
+ results = self.query(
772
+ """SELECT * FROM patients
773
+ WHERE first_name = :fn AND last_name = :ln AND date_of_birth = :dob
774
+ ORDER BY created_at DESC LIMIT 1""",
775
+ {
776
+ "fn": data["first_name"],
777
+ "ln": data["last_name"],
778
+ "dob": data["date_of_birth"],
779
+ },
780
+ )
781
+ if results:
782
+ return results[0]
783
+
784
+ # Fallback: match on name only (less reliable)
785
+ results = self.query(
786
+ """SELECT * FROM patients
787
+ WHERE first_name = :fn AND last_name = :ln
788
+ ORDER BY created_at DESC LIMIT 1""",
789
+ {"fn": data["first_name"], "ln": data["last_name"]},
790
+ )
791
+ return results[0] if results else None
792
+
793
+ def _detect_changes(
794
+ self, existing: Dict[str, Any], new_data: Dict[str, Any]
795
+ ) -> List[Dict[str, Any]]:
796
+ """Detect changes between existing patient and new data."""
797
+ fields_to_compare = [
798
+ "phone",
799
+ "email",
800
+ "address",
801
+ "city",
802
+ "state",
803
+ "zip_code",
804
+ "insurance_provider",
805
+ "insurance_id",
806
+ "medications",
807
+ "allergies",
808
+ ]
809
+ return detect_field_changes(existing, new_data, fields_to_compare)
810
+
811
+ def _update_patient(self, patient_id: int, data: Dict[str, Any]) -> Optional[int]:
812
+ """Update existing patient record with flexible schema support."""
813
+ try:
814
+ # Separate standard fields from additional fields
815
+ update_data = {}
816
+ additional_fields = {}
817
+
818
+ for key, value in data.items():
819
+ if key in UPDATABLE_COLUMNS:
820
+ update_data[key] = value
821
+ elif key not in ["first_name", "last_name", "date_of_birth", "gender"]:
822
+ # Don't override identity fields, but capture extras
823
+ if value is not None and value != "":
824
+ additional_fields[key] = value
825
+
826
+ # Merge with existing additional_fields if any
827
+ if additional_fields:
828
+ # Get existing additional_fields
829
+ existing = self.query(
830
+ "SELECT additional_fields FROM patients WHERE id = :id",
831
+ {"id": patient_id},
832
+ )
833
+ if existing and existing[0].get("additional_fields"):
834
+ try:
835
+ existing_extra = json.loads(existing[0]["additional_fields"])
836
+ existing_extra.update(additional_fields)
837
+ additional_fields = existing_extra
838
+ except json.JSONDecodeError:
839
+ pass
840
+
841
+ update_data["additional_fields"] = json.dumps(additional_fields)
842
+ logger.info(
843
+ f"Updating {len(additional_fields)} additional fields: "
844
+ f"{list(additional_fields.keys())}"
845
+ )
846
+
847
+ update_data["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
848
+
849
+ # Use mixin's update() method with proper signature
850
+ self.update(
851
+ "patients",
852
+ update_data,
853
+ "id = :id",
854
+ {"id": patient_id},
855
+ )
856
+ logger.info(f"Updated patient record ID: {patient_id}")
857
+ return patient_id
858
+
859
+ except Exception as e:
860
+ logger.error(f"Failed to update patient: {e}")
861
+ return None
862
+
863
+ def _record_intake_session(
864
+ self,
865
+ patient_id: int,
866
+ path: Path,
867
+ processing_time: float,
868
+ is_new_patient: bool,
869
+ changes_detected: List[Dict[str, Any]],
870
+ ) -> None:
871
+ """Record intake session for audit trail."""
872
+ try:
873
+ self.insert(
874
+ "intake_sessions",
875
+ {
876
+ "patient_id": patient_id,
877
+ "source_file": str(path.absolute()),
878
+ "processing_time_seconds": processing_time,
879
+ "is_new_patient": is_new_patient,
880
+ "changes_detected": (
881
+ json.dumps(changes_detected) if changes_detected else None
882
+ ),
883
+ },
884
+ )
885
+ except Exception as e:
886
+ logger.warning(f"Failed to record intake session: {e}")
887
+
888
+ def _create_alerts(self, patient_id: int, data: Dict[str, Any]) -> None:
889
+ """Create alerts for critical items (allergies, missing fields)."""
890
+ try:
891
+ # Critical allergy alert (avoid duplicates for returning patients)
892
+ if data.get("allergies"):
893
+ # Check for existing unacknowledged allergy alert
894
+ existing = self.query(
895
+ """SELECT id FROM alerts
896
+ WHERE patient_id = :pid AND alert_type = 'allergy'
897
+ AND acknowledged = FALSE""",
898
+ {"pid": patient_id},
899
+ )
900
+ if not existing:
901
+ self.insert(
902
+ "alerts",
903
+ {
904
+ "patient_id": patient_id,
905
+ "alert_type": "allergy",
906
+ "priority": "critical",
907
+ "message": f"Patient has allergies: {data['allergies']}",
908
+ "data": json.dumps({"allergies": data["allergies"]}),
909
+ },
910
+ )
911
+
912
+ # Check for missing critical fields
913
+ critical_fields = ["phone", "date_of_birth"]
914
+ missing = [f for f in critical_fields if not data.get(f)]
915
+ if missing:
916
+ # Check for existing unacknowledged missing_field alert
917
+ existing = self.query(
918
+ """SELECT id FROM alerts
919
+ WHERE patient_id = :pid AND alert_type = 'missing_field'
920
+ AND acknowledged = FALSE""",
921
+ {"pid": patient_id},
922
+ )
923
+ if not existing:
924
+ self.insert(
925
+ "alerts",
926
+ {
927
+ "patient_id": patient_id,
928
+ "alert_type": "missing_field",
929
+ "priority": "medium",
930
+ "message": f"Missing critical fields: {', '.join(missing)}",
931
+ "data": json.dumps({"missing_fields": missing}),
932
+ },
933
+ )
934
+
935
+ except Exception as e:
936
+ logger.warning(f"Failed to create alerts: {e}")
937
+
938
+ def _read_file_as_image(self, path: Path) -> Optional[bytes]:
939
+ """Read file and convert to optimized image bytes for VLM processing.
940
+
941
+ Images are automatically resized if they exceed MAX_DIMENSION to improve
942
+ processing speed while maintaining sufficient quality for OCR/extraction.
943
+ """
944
+ suffix = path.suffix.lower()
945
+
946
+ if suffix == ".pdf":
947
+ # Convert PDF first page to image (already optimized in pdf_page_to_image)
948
+ return self._pdf_to_image(path)
949
+ elif suffix in [".png", ".jpg", ".jpeg", ".tiff", ".bmp"]:
950
+ raw_bytes = path.read_bytes()
951
+ return self._optimize_image(raw_bytes)
952
+ else:
953
+ logger.warning(f"Unsupported file type: {suffix}")
954
+ return None
955
+
956
+ def _optimize_image(
957
+ self,
958
+ image_bytes: bytes,
959
+ max_dimension: int = 1024,
960
+ jpeg_quality: int = 85,
961
+ ) -> bytes:
962
+ """
963
+ Optimize image for VLM processing by resizing large images.
964
+
965
+ Reduces image dimensions while maintaining quality sufficient for OCR
966
+ and text extraction. This dramatically improves processing speed for
967
+ high-resolution scans and photos.
968
+
969
+ Images are padded to square dimensions to avoid a Vulkan backend bug
970
+ in llama.cpp where the UPSCALE operator is unsupported for certain
971
+ non-square aspect ratios (particularly landscape orientations).
972
+
973
+ Args:
974
+ image_bytes: Raw image bytes (PNG, JPEG, etc.)
975
+ max_dimension: Maximum width or height (default: 1024px)
976
+ jpeg_quality: JPEG compression quality 1-100 (default: 85)
977
+
978
+ Returns:
979
+ Optimized image bytes (JPEG format, square dimensions)
980
+ """
981
+ import io
982
+
983
+ try:
984
+ from PIL import Image, ImageOps
985
+
986
+ # Load image from bytes
987
+ img = Image.open(io.BytesIO(image_bytes))
988
+
989
+ # Apply EXIF orientation - phone photos are often stored landscape
990
+ # but have EXIF metadata indicating they should be displayed as portrait
991
+ img = ImageOps.exif_transpose(img)
992
+
993
+ original_width, original_height = img.size
994
+ original_size_kb = len(image_bytes) / 1024
995
+
996
+ # Convert to RGB early if needed (for JPEG output)
997
+ if img.mode in ("RGBA", "P"):
998
+ img = img.convert("RGB")
999
+
1000
+ # Check if resizing is needed
1001
+ if original_width <= max_dimension and original_height <= max_dimension:
1002
+ new_width, new_height = original_width, original_height
1003
+ else:
1004
+ # Calculate new dimensions maintaining aspect ratio
1005
+ scale = min(
1006
+ max_dimension / original_width, max_dimension / original_height
1007
+ )
1008
+ new_width = int(original_width * scale)
1009
+ new_height = int(original_height * scale)
1010
+
1011
+ # Resize with high-quality LANCZOS filter
1012
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
1013
+
1014
+ # Pad to square to avoid Vulkan UPSCALE bug with non-square images
1015
+ # The bug causes timeouts with landscape orientations (e.g., 1024x768)
1016
+ if new_width != new_height:
1017
+ square_size = max(new_width, new_height)
1018
+ # Create white square canvas
1019
+ square_img = Image.new(
1020
+ "RGB", (square_size, square_size), (255, 255, 255)
1021
+ )
1022
+ # Center the image on the canvas
1023
+ x_offset = (square_size - new_width) // 2
1024
+ y_offset = (square_size - new_height) // 2
1025
+ square_img.paste(img, (x_offset, y_offset))
1026
+ img = square_img
1027
+ final_size = square_size
1028
+ was_padded = True
1029
+ else:
1030
+ final_size = new_width
1031
+ was_padded = False
1032
+
1033
+ # Save as optimized JPEG
1034
+ output = io.BytesIO()
1035
+ img.save(output, format="JPEG", quality=jpeg_quality, optimize=True)
1036
+ optimized_bytes = output.getvalue()
1037
+
1038
+ optimized_size_kb = len(optimized_bytes) / 1024
1039
+ reduction_pct = (1 - optimized_size_kb / original_size_kb) * 100
1040
+
1041
+ # Show optimization results to user
1042
+ if was_padded:
1043
+ self.console.print_info(
1044
+ f"Image resized: {original_width}x{original_height} → "
1045
+ f"{final_size}x{final_size} (padded to square, "
1046
+ f"{original_size_kb:.0f}KB {optimized_size_kb:.0f}KB, "
1047
+ f"{reduction_pct:.0f}% smaller)"
1048
+ )
1049
+ else:
1050
+ self.console.print_info(
1051
+ f"Image resized: {original_width}x{original_height} → "
1052
+ f"{new_width}x{new_height} ({original_size_kb:.0f}KB → "
1053
+ f"{optimized_size_kb:.0f}KB, {reduction_pct:.0f}% smaller)"
1054
+ )
1055
+
1056
+ logger.info(
1057
+ f"Image optimized: {original_width}x{original_height} → "
1058
+ f"{final_size}x{final_size}, {original_size_kb:.0f}KB → "
1059
+ f"{optimized_size_kb:.0f}KB ({reduction_pct:.0f}% reduction)"
1060
+ f"{' (padded to square)' if was_padded else ''}"
1061
+ )
1062
+
1063
+ return optimized_bytes
1064
+
1065
+ except ImportError:
1066
+ logger.warning("PIL not available, returning original image")
1067
+ return image_bytes
1068
+ except Exception as e:
1069
+ logger.warning(f"Image optimization failed: {e}, using original")
1070
+ return image_bytes
1071
+
1072
+ def _pdf_to_image(self, pdf_path: Path) -> Optional[bytes]:
1073
+ """Convert first page of PDF to image bytes."""
1074
+ return pdf_page_to_image(pdf_path, page=0, scale=2.0)
1075
+
1076
+ def _parse_extraction(self, raw_text: str) -> Optional[Dict[str, Any]]:
1077
+ """Parse extracted text into structured patient data."""
1078
+ result = extract_json_from_text(raw_text)
1079
+ if result is None:
1080
+ logger.warning("No valid JSON found in extraction")
1081
+ return None
1082
+
1083
+ # Normalize phone fields: prefer mobile_phone if phone is not set
1084
+ # This handles forms where VLM extracts to mobile_phone instead of phone
1085
+ if not result.get("phone"):
1086
+ for phone_field in [
1087
+ "mobile_phone",
1088
+ "home_phone",
1089
+ "work_phone",
1090
+ "cell_phone",
1091
+ ]:
1092
+ if result.get(phone_field):
1093
+ result["phone"] = result[phone_field]
1094
+ logger.debug(
1095
+ f"Normalized {phone_field} to phone: {result['phone']}"
1096
+ )
1097
+ break
1098
+
1099
+ # Also check emergency_contact_phone normalization
1100
+ if not result.get("emergency_contact_phone"):
1101
+ for ec_phone in ["emergency_phone", "emergency_contact"]:
1102
+ if result.get(ec_phone) and isinstance(result[ec_phone], str):
1103
+ # Check if it looks like a phone number
1104
+ if any(c.isdigit() for c in result[ec_phone]):
1105
+ result["emergency_contact_phone"] = result[ec_phone]
1106
+ break
1107
+
1108
+ return result
1109
+
1110
+ def _store_patient(self, data: Dict[str, Any]) -> Optional[int]:
1111
+ """Store patient data in database with flexible schema support."""
1112
+ try:
1113
+ # Validate required fields
1114
+ if not data.get("first_name") or not data.get("last_name"):
1115
+ logger.error("Missing required fields: first_name and/or last_name")
1116
+ self.console.print_error("Cannot store patient: missing name fields")
1117
+ return None
1118
+
1119
+ # Separate standard fields from additional fields
1120
+ insert_data = {}
1121
+ additional_fields = {}
1122
+
1123
+ for key, value in data.items():
1124
+ if key in STANDARD_COLUMNS:
1125
+ insert_data[key] = value
1126
+ elif value is not None and value != "":
1127
+ # Store non-empty extra fields in additional_fields
1128
+ additional_fields[key] = value
1129
+
1130
+ # Store additional fields as JSON if any exist
1131
+ if additional_fields:
1132
+ insert_data["additional_fields"] = json.dumps(additional_fields)
1133
+ logger.info(
1134
+ f"Storing {len(additional_fields)} additional fields: "
1135
+ f"{list(additional_fields.keys())}"
1136
+ )
1137
+
1138
+ patient_id = self.insert("patients", insert_data)
1139
+ logger.info(f"Stored patient record ID: {patient_id}")
1140
+ return patient_id
1141
+
1142
+ except Exception as e:
1143
+ logger.error(f"Failed to store patient: {e}")
1144
+ self.console.print_error(f"Database error: {str(e)}")
1145
+ return None
1146
+
1147
+ def _get_system_prompt(self) -> str:
1148
+ """Generate the system prompt for the intake agent."""
1149
+ return f"""You are a Medical Intake Assistant managing patient records.
1150
+
1151
+ You have access to a database of patient intake forms that were automatically processed.
1152
+
1153
+ **Your Capabilities:**
1154
+ - Search for patients by name, DOB, or other criteria
1155
+ - View patient details and intake information
1156
+ - Report on processing statistics
1157
+ - Answer questions about patient data
1158
+
1159
+ **Current Status:**
1160
+ - Watching directory: {self._watch_dir}
1161
+ - Database: {self._db_path}
1162
+ - Files processed: {self._stats.get('files_processed', 0)}
1163
+ - Successful extractions: {self._stats.get('extraction_success', 0)}
1164
+
1165
+ **Important:**
1166
+ - Always protect patient privacy
1167
+ - Only report data that was extracted from forms
1168
+ - If asked about a patient not in the database, say so clearly
1169
+
1170
+ Use the available tools to search and retrieve patient information."""
1171
+
1172
+ def _register_tools(self):
1173
+ """Register patient management tools."""
1174
+ agent = self
1175
+
1176
+ @tool
1177
+ def search_patients(
1178
+ name: Optional[str] = None,
1179
+ date_of_birth: Optional[str] = None,
1180
+ ) -> Dict[str, Any]:
1181
+ """
1182
+ Search for patients by name or date of birth.
1183
+
1184
+ Args:
1185
+ name: Patient name (first, last, or full name)
1186
+ date_of_birth: Date of birth (YYYY-MM-DD format)
1187
+
1188
+ Returns:
1189
+ Dict with matching patients
1190
+ """
1191
+ conditions = []
1192
+ params = {}
1193
+
1194
+ if name:
1195
+ conditions.append(
1196
+ "(first_name LIKE :name OR last_name LIKE :name "
1197
+ "OR (first_name || ' ' || last_name) LIKE :name)"
1198
+ )
1199
+ params["name"] = f"%{name}%"
1200
+
1201
+ if date_of_birth:
1202
+ conditions.append("date_of_birth = :dob")
1203
+ params["dob"] = date_of_birth
1204
+
1205
+ if not conditions:
1206
+ # Return recent patients if no criteria
1207
+ query = """
1208
+ SELECT id, first_name, last_name, date_of_birth,
1209
+ phone, reason_for_visit, created_at
1210
+ FROM patients
1211
+ ORDER BY created_at DESC
1212
+ LIMIT 10
1213
+ """
1214
+ params = {}
1215
+ else:
1216
+ query = f"""
1217
+ SELECT id, first_name, last_name, date_of_birth,
1218
+ phone, reason_for_visit, created_at
1219
+ FROM patients
1220
+ WHERE {' AND '.join(conditions)}
1221
+ ORDER BY created_at DESC
1222
+ """
1223
+
1224
+ results = agent.query(query, params)
1225
+ return {
1226
+ "patients": results,
1227
+ "count": len(results),
1228
+ "query": {"name": name, "date_of_birth": date_of_birth},
1229
+ }
1230
+
1231
+ @tool
1232
+ def get_patient(patient_id: int) -> Dict[str, Any]:
1233
+ """
1234
+ Get full details for a specific patient.
1235
+
1236
+ Args:
1237
+ patient_id: The patient's database ID
1238
+
1239
+ Returns:
1240
+ Dict with patient details
1241
+ """
1242
+ results = agent.query(
1243
+ "SELECT * FROM patients WHERE id = :id",
1244
+ {"id": patient_id},
1245
+ )
1246
+
1247
+ if results:
1248
+ patient = results[0]
1249
+ # Remove large/binary fields - don't send to LLM
1250
+ patient.pop("raw_extraction", None)
1251
+ patient.pop("file_content", None) # Image bytes
1252
+ patient.pop("embedding", None) # Vector embedding
1253
+ # Truncate file_hash for display
1254
+ if patient.get("file_hash"):
1255
+ patient["file_hash"] = patient["file_hash"][:12] + "..."
1256
+ return {"found": True, "patient": patient}
1257
+ return {"found": False, "message": f"Patient ID {patient_id} not found"}
1258
+
1259
+ @tool
1260
+ def list_recent_patients(limit: int = 10) -> Dict[str, Any]:
1261
+ """
1262
+ List recently processed patients.
1263
+
1264
+ Args:
1265
+ limit: Maximum number of patients to return (default: 10)
1266
+
1267
+ Returns:
1268
+ Dict with recent patients
1269
+ """
1270
+ results = agent.query(
1271
+ """
1272
+ SELECT id, first_name, last_name, date_of_birth,
1273
+ reason_for_visit, created_at
1274
+ FROM patients
1275
+ ORDER BY created_at DESC
1276
+ LIMIT :limit
1277
+ """,
1278
+ {"limit": limit},
1279
+ )
1280
+ return {"patients": results, "count": len(results)}
1281
+
1282
+ @tool
1283
+ def get_intake_stats() -> Dict[str, Any]:
1284
+ """
1285
+ Get statistics about intake form processing.
1286
+
1287
+ Returns:
1288
+ Dict with processing statistics
1289
+ """
1290
+ return agent.get_stats()
1291
+
1292
+ @tool
1293
+ def process_file(file_path: str) -> Dict[str, Any]:
1294
+ """
1295
+ Manually process an intake form file.
1296
+
1297
+ Args:
1298
+ file_path: Path to the intake form file
1299
+
1300
+ Returns:
1301
+ Dict with processing result
1302
+ """
1303
+ path = Path(file_path)
1304
+ if not path.exists():
1305
+ return {"success": False, "error": f"File not found: {file_path}"}
1306
+
1307
+ # pylint: disable=protected-access
1308
+ result = agent._process_intake_form(str(path))
1309
+ if result:
1310
+ return {
1311
+ "success": True,
1312
+ "patient_id": result.get("id"),
1313
+ "name": f"{result.get('first_name', '')} {result.get('last_name', '')}",
1314
+ }
1315
+ return {"success": False, "error": "Failed to extract patient data"}
1316
+
1317
+ def get_stats(self) -> Dict[str, Any]:
1318
+ """
1319
+ Get statistics about intake form processing.
1320
+
1321
+ Returns:
1322
+ Dict with processing statistics including:
1323
+ - total_patients: Total patient count
1324
+ - processed_today: Patients processed today
1325
+ - new_patients: New patient count
1326
+ - returning_patients: Returning patient count
1327
+ - files_processed: Total files processed
1328
+ - extraction_success/failed: Success/failure counts
1329
+ - success_rate: Success percentage
1330
+ - time_saved_minutes/percent: Time savings metrics
1331
+ - avg_processing_seconds: Average processing time
1332
+ - unacknowledged_alerts: Alert count
1333
+ - watching_directory: Watched directory path
1334
+ - uptime_seconds: Agent uptime
1335
+ """
1336
+ # Get total patient count
1337
+ count_result = self.query("SELECT COUNT(*) as count FROM patients")
1338
+ total_patients = count_result[0]["count"] if count_result else 0
1339
+
1340
+ # Get today's count
1341
+ today_result = self.query(
1342
+ "SELECT COUNT(*) as count FROM patients WHERE date(created_at) = date('now')"
1343
+ )
1344
+ today_count = today_result[0]["count"] if today_result else 0
1345
+
1346
+ # Get unacknowledged alerts count
1347
+ alerts_result = self.query(
1348
+ "SELECT COUNT(*) as count FROM alerts WHERE acknowledged = FALSE"
1349
+ )
1350
+ unacknowledged_alerts = alerts_result[0]["count"] if alerts_result else 0
1351
+
1352
+ # Calculate time savings based on actual extracted data
1353
+ # Uses estimated manual entry time calculated per-form based on fields/characters
1354
+ estimated_manual_seconds = self._stats["total_estimated_manual_seconds"]
1355
+ actual_processing_seconds = self._stats["total_processing_time_seconds"]
1356
+ time_saved_seconds = max(
1357
+ 0, estimated_manual_seconds - actual_processing_seconds
1358
+ )
1359
+
1360
+ # Calculate percentage improvement
1361
+ if estimated_manual_seconds > 0:
1362
+ time_saved_percent = (time_saved_seconds / estimated_manual_seconds) * 100
1363
+ else:
1364
+ time_saved_percent = 0
1365
+
1366
+ # Average estimated manual time per form
1367
+ successful = self._stats["extraction_success"]
1368
+ avg_manual_seconds = (
1369
+ estimated_manual_seconds / successful if successful > 0 else 0
1370
+ )
1371
+
1372
+ return {
1373
+ "total_patients": total_patients,
1374
+ "processed_today": today_count,
1375
+ "new_patients": self._stats["new_patients"],
1376
+ "returning_patients": self._stats["returning_patients"],
1377
+ "files_processed": self._stats["files_processed"],
1378
+ "extraction_success": self._stats["extraction_success"],
1379
+ "extraction_failed": self._stats["extraction_failed"],
1380
+ "success_rate": (
1381
+ f"{(self._stats['extraction_success'] / self._stats['files_processed'] * 100):.1f}%"
1382
+ if self._stats["files_processed"] > 0
1383
+ else "N/A"
1384
+ ),
1385
+ # Total cumulative metrics
1386
+ "time_saved_seconds": round(time_saved_seconds, 1),
1387
+ "time_saved_minutes": round(time_saved_seconds / 60, 1),
1388
+ "time_saved_percent": f"{time_saved_percent:.0f}%",
1389
+ "total_estimated_manual_seconds": round(estimated_manual_seconds, 1),
1390
+ "total_ai_processing_seconds": round(actual_processing_seconds, 1),
1391
+ # Per-form averages
1392
+ "avg_manual_seconds": round(avg_manual_seconds, 1),
1393
+ "avg_processing_seconds": (
1394
+ round(actual_processing_seconds / successful, 1)
1395
+ if successful > 0
1396
+ else 0
1397
+ ),
1398
+ # Legacy field name for backwards compatibility
1399
+ "estimated_manual_seconds": round(estimated_manual_seconds, 1),
1400
+ "unacknowledged_alerts": unacknowledged_alerts,
1401
+ "watching_directory": str(self._watch_dir),
1402
+ "uptime_seconds": int(time.time() - self._stats["start_time"]),
1403
+ }
1404
+
1405
+ def clear_database(self) -> Dict[str, Any]:
1406
+ """
1407
+ Clear all data from the database and reset statistics.
1408
+
1409
+ This removes all patients, alerts, and intake sessions, providing
1410
+ a clean slate for fresh processing.
1411
+
1412
+ Returns:
1413
+ Dict with counts of deleted records
1414
+ """
1415
+ logger.info("Clearing database...")
1416
+ counts = {}
1417
+
1418
+ try:
1419
+ # Get counts before deletion
1420
+ for table in ["patients", "alerts", "intake_sessions"]:
1421
+ result = self.query(f"SELECT COUNT(*) as count FROM {table}")
1422
+ counts[table] = result[0]["count"] if result else 0
1423
+
1424
+ # Delete all records from each table
1425
+ self.execute("DELETE FROM intake_sessions")
1426
+ self.execute("DELETE FROM alerts")
1427
+ self.execute("DELETE FROM patients")
1428
+
1429
+ # Reset in-memory statistics
1430
+ self._stats = {
1431
+ "files_processed": 0,
1432
+ "extraction_success": 0,
1433
+ "extraction_failed": 0,
1434
+ "new_patients": 0,
1435
+ "returning_patients": 0,
1436
+ "total_processing_time_seconds": 0.0,
1437
+ "total_estimated_manual_seconds": 0.0,
1438
+ "start_time": time.time(),
1439
+ }
1440
+
1441
+ # Clear processed files list
1442
+ self._processed_files = []
1443
+
1444
+ logger.info(
1445
+ f"Database cleared: {counts.get('patients', 0)} patients, "
1446
+ f"{counts.get('alerts', 0)} alerts, "
1447
+ f"{counts.get('intake_sessions', 0)} sessions"
1448
+ )
1449
+
1450
+ return {
1451
+ "success": True,
1452
+ "deleted": counts,
1453
+ "message": "Database cleared successfully",
1454
+ }
1455
+
1456
+ except Exception as e:
1457
+ logger.error(f"Failed to clear database: {e}")
1458
+ return {
1459
+ "success": False,
1460
+ "error": str(e),
1461
+ "message": "Failed to clear database",
1462
+ }
1463
+
1464
+ def stop(self) -> None:
1465
+ """Stop the agent and clean up resources."""
1466
+ logger.info("Stopping Medical Intake Agent...")
1467
+ errors = []
1468
+
1469
+ # Stop file watchers
1470
+ try:
1471
+ self.stop_all_watchers()
1472
+ except Exception as e:
1473
+ errors.append(f"Failed to stop watchers: {e}")
1474
+ logger.error(errors[-1])
1475
+
1476
+ # Close database
1477
+ try:
1478
+ self.close_db()
1479
+ except Exception as e:
1480
+ errors.append(f"Failed to close database: {e}")
1481
+ logger.error(errors[-1])
1482
+
1483
+ # Cleanup VLM
1484
+ try:
1485
+ if self._vlm:
1486
+ self._vlm.cleanup()
1487
+ self._vlm = None
1488
+ except Exception as e:
1489
+ errors.append(f"Failed to cleanup VLM: {e}")
1490
+ logger.error(errors[-1])
1491
+
1492
+ if errors:
1493
+ logger.warning(f"Cleanup completed with {len(errors)} error(s)")
1494
+ else:
1495
+ logger.info("Medical Intake Agent stopped")
1496
+
1497
+ def __enter__(self):
1498
+ """Context manager entry."""
1499
+ return self
1500
+
1501
+ def __exit__(self, exc_type, exc_val, exc_tb):
1502
+ """Context manager exit with cleanup."""
1503
+ self.stop()
1504
+ return False