frabbani commited on
Commit
9ce6086
·
1 Parent(s): f27d89b

Add pre-visit report generation

Browse files
Files changed (4) hide show
  1. Dockerfile +2 -1
  2. report_generator.py +304 -0
  3. server.py +57 -2
  4. static/index.html +649 -28
Dockerfile CHANGED
@@ -15,7 +15,8 @@ RUN pip install --no-cache-dir -r requirements.txt
15
  # Copy application code
16
  COPY server.py .
17
  COPY agent.py .
18
- COPY agent_v2.py .
 
19
  COPY tools.py .
20
  COPY init_db_hybrid.py .
21
 
 
15
  # Copy application code
16
  COPY server.py .
17
  COPY agent.py .
18
+ COPY agent_v2.py .
19
+ COPY report_generator.py .
20
  COPY tools.py .
21
  COPY init_db_hybrid.py .
22
 
report_generator.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pre-Visit Report Generator
4
+
5
+ Creates a concise, one-page summary for healthcare providers including:
6
+ - Patient's main concerns
7
+ - How they're feeling
8
+ - Relevant medical context
9
+ - Attachments (voice recordings, charts)
10
+ """
11
+
12
+ import os
13
+ import json
14
+ from datetime import datetime
15
+ from typing import List, Dict, Optional
16
+ from dataclasses import dataclass, field
17
+ import httpx
18
+
19
+ LLAMA_SERVER_URL = os.getenv("LLAMA_SERVER_URL", "http://localhost:8081")
20
+
21
+ LLM_HEADERS = {
22
+ "Content-Type": "application/json",
23
+ "ngrok-skip-browser-warning": "true"
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class ReportAttachment:
29
+ """Attachment for the report (voice recording, chart, etc.)"""
30
+ type: str # "audio", "chart", "image"
31
+ title: str
32
+ data: Optional[str] = None # base64 or URL
33
+ summary: Optional[str] = None # Text summary of the attachment
34
+
35
+
36
+ @dataclass
37
+ class PreVisitReport:
38
+ """Structured pre-visit report for healthcare provider."""
39
+ patient_name: str
40
+ patient_age: int
41
+ patient_gender: str
42
+ generated_at: str
43
+
44
+ # Core content
45
+ chief_concerns: List[str] = field(default_factory=list)
46
+ patient_feelings: str = ""
47
+ symptom_summary: str = ""
48
+
49
+ # Medical context
50
+ relevant_conditions: List[str] = field(default_factory=list)
51
+ current_medications: List[str] = field(default_factory=list)
52
+ recent_vitals: Dict[str, str] = field(default_factory=dict)
53
+
54
+ # Attachments
55
+ attachments: List[Dict] = field(default_factory=list)
56
+
57
+ # Conversation references
58
+ key_quotes: List[str] = field(default_factory=list)
59
+
60
+ def to_dict(self) -> Dict:
61
+ return {
62
+ "patient_name": self.patient_name,
63
+ "patient_age": self.patient_age,
64
+ "patient_gender": self.patient_gender,
65
+ "generated_at": self.generated_at,
66
+ "chief_concerns": self.chief_concerns,
67
+ "patient_feelings": self.patient_feelings,
68
+ "symptom_summary": self.symptom_summary,
69
+ "relevant_conditions": self.relevant_conditions,
70
+ "current_medications": self.current_medications,
71
+ "recent_vitals": self.recent_vitals,
72
+ "attachments": self.attachments,
73
+ "key_quotes": self.key_quotes
74
+ }
75
+
76
+
77
+ async def call_llm(prompt: str, max_tokens: int = 1024) -> str:
78
+ """Call LLM for report generation."""
79
+ async with httpx.AsyncClient(timeout=120.0) as client:
80
+ response = await client.post(
81
+ f"{LLAMA_SERVER_URL}/completion",
82
+ headers=LLM_HEADERS,
83
+ json={
84
+ "prompt": prompt,
85
+ "n_predict": max_tokens,
86
+ "temperature": 0.3,
87
+ "stop": ["<end_of_turn>", "</s>", "<|im_end|>"],
88
+ "stream": False
89
+ }
90
+ )
91
+ response.raise_for_status()
92
+ result = response.json()
93
+ return result.get("content", "").strip()
94
+
95
+
96
+ async def generate_report(
97
+ patient_info: Dict,
98
+ conversation_history: List[Dict],
99
+ tool_results: List[Dict],
100
+ attachments: List[Dict] = None
101
+ ) -> PreVisitReport:
102
+ """
103
+ Generate a pre-visit report from conversation and medical data.
104
+
105
+ Args:
106
+ patient_info: Patient demographics
107
+ conversation_history: List of {"role": "user"|"assistant", "content": "..."}
108
+ tool_results: List of {"tool": "...", "facts": "..."}
109
+ attachments: List of {"type": "audio"|"chart", "title": "...", "summary": "..."}
110
+
111
+ Returns:
112
+ PreVisitReport object
113
+ """
114
+
115
+ # Format conversation for analysis
116
+ conversation_text = ""
117
+ for msg in conversation_history:
118
+ role = "Patient" if msg.get("role") == "user" else "Assistant"
119
+ conversation_text += f"{role}: {msg.get('content', '')}\n\n"
120
+
121
+ # Format tool results
122
+ medical_context = ""
123
+ for result in tool_results:
124
+ tool = result.get("tool", "").replace("get_", "").replace("_", " ")
125
+ facts = result.get("facts", "")
126
+ if facts:
127
+ medical_context += f"[{tool.upper()}]\n{facts}\n\n"
128
+
129
+ # Format attachments
130
+ attachment_text = ""
131
+ if attachments:
132
+ for att in attachments:
133
+ attachment_text += f"- {att.get('type', 'file').upper()}: {att.get('title', 'Untitled')}\n"
134
+ if att.get('summary'):
135
+ attachment_text += f" Summary: {att.get('summary')}\n"
136
+
137
+ # Generate report using LLM
138
+ prompt = f"""<start_of_turn>user
139
+ You are a medical assistant creating a concise PRE-VISIT SUMMARY for a healthcare provider.
140
+
141
+ PATIENT INFO:
142
+ - Name: {patient_info.get('name', 'Unknown')}
143
+ - Age: {patient_info.get('age', 'Unknown')}
144
+ - Gender: {patient_info.get('gender', 'Unknown')}
145
+
146
+ CONVERSATION WITH PATIENT:
147
+ {conversation_text}
148
+
149
+ MEDICAL RECORD DATA REFERENCED:
150
+ {medical_context if medical_context else "No medical records were referenced."}
151
+
152
+ ATTACHMENTS:
153
+ {attachment_text if attachment_text else "None"}
154
+
155
+ Create a structured pre-visit report in JSON format with these fields:
156
+ {{
157
+ "chief_concerns": ["list of 1-3 main reasons for visit"],
158
+ "patient_feelings": "Brief description of how the patient is feeling emotionally/physically",
159
+ "symptom_summary": "Concise summary of any symptoms mentioned (2-3 sentences max)",
160
+ "relevant_conditions": ["list of relevant existing conditions mentioned"],
161
+ "current_medications": ["list of relevant medications mentioned"],
162
+ "recent_vitals": {{"vital_name": "value"}},
163
+ "key_quotes": ["1-2 important direct quotes from patient"]
164
+ }}
165
+
166
+ Keep it CONCISE - this should fit on one page. Focus on what the doctor needs to know.
167
+ Output ONLY the JSON, no other text.
168
+ <end_of_turn>
169
+ <start_of_turn>model
170
+ """
171
+
172
+ response = await call_llm(prompt, max_tokens=1000)
173
+
174
+ # Parse the response
175
+ try:
176
+ # Clean up response
177
+ response = response.strip()
178
+ if response.startswith("```json"):
179
+ response = response[7:]
180
+ if response.startswith("```"):
181
+ response = response[3:]
182
+ if response.endswith("```"):
183
+ response = response[:-3]
184
+
185
+ report_data = json.loads(response.strip())
186
+ except json.JSONDecodeError:
187
+ # Fallback if JSON parsing fails
188
+ report_data = {
189
+ "chief_concerns": ["Unable to parse - please review conversation"],
190
+ "patient_feelings": "See conversation history",
191
+ "symptom_summary": "See conversation history",
192
+ "relevant_conditions": [],
193
+ "current_medications": [],
194
+ "recent_vitals": {},
195
+ "key_quotes": []
196
+ }
197
+
198
+ # Create report object
199
+ report = PreVisitReport(
200
+ patient_name=patient_info.get('name', 'Unknown'),
201
+ patient_age=patient_info.get('age', 0),
202
+ patient_gender=patient_info.get('gender', 'Unknown'),
203
+ generated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
204
+ chief_concerns=report_data.get('chief_concerns', []),
205
+ patient_feelings=report_data.get('patient_feelings', ''),
206
+ symptom_summary=report_data.get('symptom_summary', ''),
207
+ relevant_conditions=report_data.get('relevant_conditions', []),
208
+ current_medications=report_data.get('current_medications', []),
209
+ recent_vitals=report_data.get('recent_vitals', {}),
210
+ attachments=attachments or [],
211
+ key_quotes=report_data.get('key_quotes', [])
212
+ )
213
+
214
+ return report
215
+
216
+
217
+ def format_report_html(report: PreVisitReport) -> str:
218
+ """Format report as HTML for display."""
219
+
220
+ concerns_html = "".join([f"<li>{c}</li>" for c in report.chief_concerns]) or "<li>None specified</li>"
221
+ conditions_html = "".join([f"<li>{c}</li>" for c in report.relevant_conditions]) or "<li>None noted</li>"
222
+ medications_html = "".join([f"<li>{m}</li>" for m in report.current_medications]) or "<li>None noted</li>"
223
+
224
+ vitals_html = ""
225
+ for vital, value in report.recent_vitals.items():
226
+ vitals_html += f"<div class='vital-item'><span class='vital-name'>{vital}:</span> <span class='vital-value'>{value}</span></div>"
227
+ if not vitals_html:
228
+ vitals_html = "<div class='vital-item'>No vitals referenced</div>"
229
+
230
+ quotes_html = ""
231
+ for quote in report.key_quotes:
232
+ quotes_html += f'<blockquote>"{quote}"</blockquote>'
233
+
234
+ attachments_html = ""
235
+ for att in report.attachments:
236
+ icon = "🎤" if att.get('type') == 'audio' else "📊" if att.get('type') == 'chart' else "📎"
237
+ attachments_html += f"""
238
+ <div class='attachment-item'>
239
+ <span class='attachment-icon'>{icon}</span>
240
+ <span class='attachment-title'>{att.get('title', 'Attachment')}</span>
241
+ {f"<div class='attachment-summary'>{att.get('summary', '')}</div>" if att.get('summary') else ""}
242
+ </div>
243
+ """
244
+
245
+ html = f"""
246
+ <div class="previsit-report">
247
+ <div class="report-header">
248
+ <h2>Pre-Visit Summary</h2>
249
+ <div class="report-meta">
250
+ <div class="patient-info">
251
+ <strong>{report.patient_name}</strong> · {report.patient_age}y · {report.patient_gender}
252
+ </div>
253
+ <div class="report-date">Generated: {report.generated_at}</div>
254
+ </div>
255
+ </div>
256
+
257
+ <div class="report-section">
258
+ <h3>📋 Chief Concerns</h3>
259
+ <ul class="concerns-list">{concerns_html}</ul>
260
+ </div>
261
+
262
+ <div class="report-section">
263
+ <h3>💭 How Patient Feels</h3>
264
+ <p>{report.patient_feelings or "Not specified"}</p>
265
+ </div>
266
+
267
+ {f'''<div class="report-section">
268
+ <h3>🩺 Symptoms</h3>
269
+ <p>{report.symptom_summary}</p>
270
+ </div>''' if report.symptom_summary else ""}
271
+
272
+ <div class="report-columns">
273
+ <div class="report-section half">
274
+ <h3>📁 Relevant Conditions</h3>
275
+ <ul>{conditions_html}</ul>
276
+ </div>
277
+ <div class="report-section half">
278
+ <h3>💊 Current Medications</h3>
279
+ <ul>{medications_html}</ul>
280
+ </div>
281
+ </div>
282
+
283
+ {f'''<div class="report-section">
284
+ <h3>📊 Recent Vitals</h3>
285
+ <div class="vitals-grid">{vitals_html}</div>
286
+ </div>''' if report.recent_vitals else ""}
287
+
288
+ {f'''<div class="report-section">
289
+ <h3>💬 Patient Quotes</h3>
290
+ {quotes_html}
291
+ </div>''' if report.key_quotes else ""}
292
+
293
+ {f'''<div class="report-section">
294
+ <h3>📎 Attachments</h3>
295
+ <div class="attachments-list">{attachments_html}</div>
296
+ </div>''' if report.attachments else ""}
297
+
298
+ <div class="report-footer">
299
+ <p><em>This summary was auto-generated from a patient conversation. Please verify all information.</em></p>
300
+ </div>
301
+ </div>
302
+ """
303
+
304
+ return html
server.py CHANGED
@@ -2,14 +2,13 @@
2
  """
3
  MedGemma Pre-Visit Assessment Server (HuggingFace Spaces Version)
4
  """
5
- import numpy
6
  import os
7
  import json
8
  import sqlite3
9
  from datetime import datetime
10
  from typing import Optional
11
  from contextlib import asynccontextmanager
12
- from agent_v2 import run_agent_v2
13
 
14
  import httpx
15
  from fastapi import FastAPI, HTTPException
@@ -427,6 +426,7 @@ async def health_check():
427
  # Agent endpoint (v2 with discovery, planning, fact extraction)
428
  # ============================================================================
429
 
 
430
 
431
  @app.post("/api/agent/chat")
432
  async def agent_chat_endpoint(request: ChatRequest):
@@ -509,6 +509,61 @@ async def analyze_audio(audio: UploadFile = File(...)):
509
  except Exception as e:
510
  return {"success": False, "error": str(e)}
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  if __name__ == "__main__":
513
  import uvicorn
514
  port = int(os.getenv("PORT", "7860"))
 
2
  """
3
  MedGemma Pre-Visit Assessment Server (HuggingFace Spaces Version)
4
  """
5
+
6
  import os
7
  import json
8
  import sqlite3
9
  from datetime import datetime
10
  from typing import Optional
11
  from contextlib import asynccontextmanager
 
12
 
13
  import httpx
14
  from fastapi import FastAPI, HTTPException
 
426
  # Agent endpoint (v2 with discovery, planning, fact extraction)
427
  # ============================================================================
428
 
429
+ from agent_v2 import run_agent_v2
430
 
431
  @app.post("/api/agent/chat")
432
  async def agent_chat_endpoint(request: ChatRequest):
 
509
  except Exception as e:
510
  return {"success": False, "error": str(e)}
511
 
512
+
513
+ # ============================================================================
514
+ # Pre-Visit Report Generation
515
+ # ============================================================================
516
+
517
+ from report_generator import generate_report, format_report_html, PreVisitReport
518
+
519
+ class ReportRequest(BaseModel):
520
+ patient_id: str
521
+ conversation: list # List of {"role": "user"|"assistant", "content": "..."}
522
+ tool_results: list = [] # List of {"tool": "...", "facts": "..."}
523
+ attachments: list = [] # List of {"type": "audio"|"chart", "title": "...", "summary": "..."}
524
+
525
+ @app.post("/api/report/generate")
526
+ async def generate_report_endpoint(request: ReportRequest):
527
+ """Generate a pre-visit summary report from conversation."""
528
+ try:
529
+ # Get patient info
530
+ conn = get_db()
531
+ cursor = conn.execute("SELECT * FROM patients WHERE id = ?", (request.patient_id,))
532
+ patient = cursor.fetchone()
533
+ conn.close()
534
+
535
+ if not patient:
536
+ raise HTTPException(status_code=404, detail="Patient not found")
537
+
538
+ from datetime import datetime
539
+ birth = datetime.strptime(patient["birth_date"], "%Y-%m-%d")
540
+ age = (datetime.now() - birth).days // 365
541
+
542
+ patient_info = {
543
+ "name": f"{patient['given_name']} {patient['family_name']}",
544
+ "age": age,
545
+ "gender": patient['gender']
546
+ }
547
+
548
+ # Generate report
549
+ report = await generate_report(
550
+ patient_info=patient_info,
551
+ conversation_history=request.conversation,
552
+ tool_results=request.tool_results,
553
+ attachments=request.attachments
554
+ )
555
+
556
+ # Return both structured data and HTML
557
+ return {
558
+ "success": True,
559
+ "report": report.to_dict(),
560
+ "html": format_report_html(report)
561
+ }
562
+
563
+ except Exception as e:
564
+ return {"success": False, "error": str(e)}
565
+
566
+
567
  if __name__ == "__main__":
568
  import uvicorn
569
  port = int(os.getenv("PORT", "7860"))
static/index.html CHANGED
@@ -483,6 +483,404 @@
483
  font-size: 12px;
484
  color: #a5d6a7;
485
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  </style>
487
  </head>
488
  <body>
@@ -520,41 +918,90 @@
520
  </div>
521
  </div>
522
 
523
- <div class="chat-viewport" id="chatMessages">
524
- <div class="message-row assistant">
525
- <div class="bubble">
526
- Hello! I'm ready to help. You can ask me to visualize your health data, or use the microphone to analyze respiratory sounds.
 
 
 
 
527
  </div>
528
- </div>
529
- </div>
530
 
531
- <div class="input-area">
532
-
533
- <div class="chips-scroll">
534
- <div class="chip" data-q="Show me my blood pressure chart">📈 BP Chart</div>
535
- <div class="chip" data-q="Summarize my active medications">💊 Meds</div>
536
- <div class="chip" data-q="How is my cholesterol trending?">📊 Cholesterol</div>
537
- <div class="chip" data-q="Analyze my cough risk">🎤 Cough Check</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  </div>
539
 
540
- <div class="input-group" id="mainInputGroup">
541
- <input type="text" id="chatInput" placeholder="Ask a health question..." disabled>
542
-
543
- <div class="recording-overlay" id="recordingOverlay">
544
- <span style="color: var(--danger); font-weight: 600; font-size: 14px;">Recording...</span>
545
- <div class="wave-viz"></div>
 
 
 
 
 
 
 
 
 
 
546
  </div>
 
 
 
 
 
 
 
 
547
 
548
- <button class="btn-icon btn-record" id="recordBtn" title="Record Audio" disabled>
549
- 🎤
550
- </button>
551
- <button class="btn-icon btn-send" id="chatSend" disabled>
552
-
553
- </button>
 
 
 
 
 
 
 
 
554
  </div>
555
-
556
- <div style="text-align: center; margin-top: 10px; font-size: 11px; color: var(--text-muted);">
557
- ⚠️ AI responses can be inaccurate. Consult a doctor.
558
  </div>
559
  </div>
560
  </div>
@@ -566,6 +1013,12 @@
566
  let patientId = null;
567
  let chartCounter = 0;
568
 
 
 
 
 
 
 
569
  // DOM Elements
570
  const chatMessages = document.getElementById('chatMessages');
571
  const chatInput = document.getElementById('chatInput');
@@ -689,6 +1142,9 @@
689
  div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div>`;
690
  chatMessages.appendChild(div);
691
  scrollToBottom();
 
 
 
692
  }
693
 
694
  // UPDATED: Now processes Markdown AND removes "ANSWER:" prefix
@@ -705,6 +1161,9 @@
705
  div.innerHTML = `<div class="bubble markdown-body">${htmlContent}</div>`;
706
  chatMessages.appendChild(div);
707
  scrollToBottom();
 
 
 
708
  }
709
 
710
  // Streaming answer support
@@ -736,6 +1195,13 @@
736
  function endStreamingAnswer() {
737
  if (streamingBubble) {
738
  streamingBubble.classList.remove('streaming');
 
 
 
 
 
 
 
739
  streamingBubble = null;
740
  streamingText = "";
741
  }
@@ -859,6 +1325,9 @@
859
  `;
860
  chatMessages.appendChild(div);
861
  scrollToBottom();
 
 
 
862
  }
863
 
864
  // ==========================================
@@ -868,6 +1337,13 @@
868
  chartCounter++;
869
  const canvasId = `canvas-${chartCounter}`;
870
 
 
 
 
 
 
 
 
871
  const div = document.createElement('div');
872
  div.className = 'message-row widget-row';
873
  div.innerHTML = `
@@ -1107,6 +1583,13 @@
1107
  const score = data.respiratory_health_score || data.overall_score || 0;
1108
  const modelName = result.model || 'Unknown';
1109
 
 
 
 
 
 
 
 
1110
  const scoreColor = score < 60 ? 'var(--danger)' : score < 80 ? 'var(--warning)' : 'var(--success)';
1111
 
1112
  const html = `
@@ -1148,6 +1631,144 @@
1148
  chatMessages.insertAdjacentHTML('beforeend', html);
1149
  scrollToBottom();
1150
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  </script>
1152
  </body>
1153
  </html>
 
483
  font-size: 12px;
484
  color: #a5d6a7;
485
  }
486
+
487
+ /* ==========================================
488
+ PRE-VISIT REPORT PANEL & MODAL
489
+ ========================================== */
490
+
491
+ /* Main content wrapper for side-by-side layout */
492
+ .main-content-wrapper {
493
+ display: flex;
494
+ flex: 1;
495
+ overflow: hidden;
496
+ }
497
+
498
+ .chat-container {
499
+ flex: 1;
500
+ display: flex;
501
+ flex-direction: column;
502
+ overflow: hidden;
503
+ }
504
+
505
+ /* Right sidebar - Report Panel */
506
+ .report-panel {
507
+ width: 0;
508
+ background: var(--bg-card);
509
+ border-left: 1px solid var(--border-color);
510
+ transition: width 0.3s ease;
511
+ overflow: hidden;
512
+ display: flex;
513
+ flex-direction: column;
514
+ }
515
+
516
+ .report-panel.open {
517
+ width: 320px;
518
+ }
519
+
520
+ .report-panel-header {
521
+ padding: 15px;
522
+ border-bottom: 1px solid var(--border-color);
523
+ display: flex;
524
+ justify-content: space-between;
525
+ align-items: center;
526
+ }
527
+
528
+ .report-panel-header h3 {
529
+ font-size: 14px;
530
+ color: var(--text-main);
531
+ margin: 0;
532
+ }
533
+
534
+ .report-panel-close {
535
+ background: none;
536
+ border: none;
537
+ color: var(--text-muted);
538
+ cursor: pointer;
539
+ font-size: 18px;
540
+ padding: 4px;
541
+ }
542
+
543
+ .report-panel-body {
544
+ flex: 1;
545
+ overflow-y: auto;
546
+ padding: 15px;
547
+ }
548
+
549
+ .report-preview {
550
+ background: linear-gradient(135deg, rgba(94, 114, 228, 0.1) 0%, rgba(94, 114, 228, 0.05) 100%);
551
+ border: 1px solid rgba(94, 114, 228, 0.3);
552
+ border-radius: var(--radius-md);
553
+ padding: 15px;
554
+ cursor: pointer;
555
+ transition: all 0.2s;
556
+ }
557
+
558
+ .report-preview:hover {
559
+ border-color: var(--primary);
560
+ transform: translateY(-2px);
561
+ }
562
+
563
+ .report-preview-placeholder {
564
+ text-align: center;
565
+ color: var(--text-muted);
566
+ padding: 30px 15px;
567
+ }
568
+
569
+ .report-preview-placeholder .icon {
570
+ font-size: 32px;
571
+ margin-bottom: 10px;
572
+ }
573
+
574
+ .report-preview h4 {
575
+ font-size: 13px;
576
+ color: var(--primary);
577
+ margin: 0 0 8px 0;
578
+ }
579
+
580
+ .report-preview-concerns {
581
+ font-size: 12px;
582
+ color: var(--text-main);
583
+ margin-bottom: 8px;
584
+ }
585
+
586
+ .report-preview-meta {
587
+ font-size: 11px;
588
+ color: var(--text-muted);
589
+ }
590
+
591
+ .report-panel-actions {
592
+ padding: 15px;
593
+ border-top: 1px solid var(--border-color);
594
+ }
595
+
596
+ .btn-generate-report {
597
+ width: 100%;
598
+ padding: 12px;
599
+ background: var(--primary);
600
+ color: white;
601
+ border: none;
602
+ border-radius: var(--radius-sm);
603
+ font-weight: 600;
604
+ font-size: 13px;
605
+ cursor: pointer;
606
+ transition: background 0.2s;
607
+ }
608
+
609
+ .btn-generate-report:hover {
610
+ background: var(--primary-dark);
611
+ }
612
+
613
+ .btn-generate-report:disabled {
614
+ background: var(--secondary);
615
+ cursor: not-allowed;
616
+ }
617
+
618
+ .btn-generate-report.loading {
619
+ position: relative;
620
+ color: transparent;
621
+ }
622
+
623
+ .btn-generate-report.loading::after {
624
+ content: '';
625
+ position: absolute;
626
+ width: 16px;
627
+ height: 16px;
628
+ top: 50%;
629
+ left: 50%;
630
+ margin: -8px 0 0 -8px;
631
+ border: 2px solid white;
632
+ border-top-color: transparent;
633
+ border-radius: 50%;
634
+ animation: spin 0.8s linear infinite;
635
+ }
636
+
637
+ @keyframes spin {
638
+ to { transform: rotate(360deg); }
639
+ }
640
+
641
+ /* Toggle button for report panel */
642
+ .btn-toggle-report {
643
+ position: fixed;
644
+ right: 20px;
645
+ bottom: 100px;
646
+ width: 50px;
647
+ height: 50px;
648
+ border-radius: 50%;
649
+ background: var(--primary);
650
+ color: white;
651
+ border: none;
652
+ font-size: 20px;
653
+ cursor: pointer;
654
+ box-shadow: var(--shadow-soft);
655
+ z-index: 100;
656
+ transition: all 0.2s;
657
+ }
658
+
659
+ .btn-toggle-report:hover {
660
+ transform: scale(1.1);
661
+ box-shadow: var(--shadow-hover);
662
+ }
663
+
664
+ .btn-toggle-report.has-report {
665
+ background: var(--success);
666
+ }
667
+
668
+ /* Report Modal/Popup */
669
+ .report-modal-overlay {
670
+ position: fixed;
671
+ top: 0;
672
+ left: 0;
673
+ right: 0;
674
+ bottom: 0;
675
+ background: rgba(0, 0, 0, 0.7);
676
+ display: flex;
677
+ justify-content: center;
678
+ align-items: center;
679
+ z-index: 1000;
680
+ opacity: 0;
681
+ visibility: hidden;
682
+ transition: all 0.3s;
683
+ }
684
+
685
+ .report-modal-overlay.open {
686
+ opacity: 1;
687
+ visibility: visible;
688
+ }
689
+
690
+ .report-modal {
691
+ background: var(--bg-card);
692
+ border-radius: var(--radius-lg);
693
+ width: 90%;
694
+ max-width: 700px;
695
+ max-height: 85vh;
696
+ overflow: hidden;
697
+ display: flex;
698
+ flex-direction: column;
699
+ transform: scale(0.9);
700
+ transition: transform 0.3s;
701
+ }
702
+
703
+ .report-modal-overlay.open .report-modal {
704
+ transform: scale(1);
705
+ }
706
+
707
+ .report-modal-header {
708
+ padding: 20px;
709
+ border-bottom: 1px solid var(--border-color);
710
+ display: flex;
711
+ justify-content: space-between;
712
+ align-items: center;
713
+ }
714
+
715
+ .report-modal-header h2 {
716
+ font-size: 18px;
717
+ margin: 0;
718
+ color: var(--text-main);
719
+ }
720
+
721
+ .report-modal-actions {
722
+ display: flex;
723
+ gap: 10px;
724
+ }
725
+
726
+ .btn-modal-action {
727
+ padding: 8px 16px;
728
+ border-radius: var(--radius-sm);
729
+ font-size: 13px;
730
+ font-weight: 600;
731
+ cursor: pointer;
732
+ border: none;
733
+ transition: all 0.2s;
734
+ }
735
+
736
+ .btn-download {
737
+ background: var(--success);
738
+ color: white;
739
+ }
740
+
741
+ .btn-close-modal {
742
+ background: var(--secondary);
743
+ color: var(--text-main);
744
+ }
745
+
746
+ .report-modal-body {
747
+ flex: 1;
748
+ overflow-y: auto;
749
+ padding: 20px;
750
+ }
751
+
752
+ /* Report Content Styles */
753
+ .previsit-report {
754
+ font-size: 14px;
755
+ color: var(--text-main);
756
+ }
757
+
758
+ .previsit-report .report-header {
759
+ margin-bottom: 20px;
760
+ padding-bottom: 15px;
761
+ border-bottom: 2px solid var(--primary);
762
+ }
763
+
764
+ .previsit-report .report-header h2 {
765
+ font-size: 20px;
766
+ color: var(--primary);
767
+ margin: 0 0 8px 0;
768
+ }
769
+
770
+ .previsit-report .report-meta {
771
+ display: flex;
772
+ justify-content: space-between;
773
+ font-size: 13px;
774
+ }
775
+
776
+ .previsit-report .patient-info {
777
+ color: var(--text-main);
778
+ }
779
+
780
+ .previsit-report .report-date {
781
+ color: var(--text-muted);
782
+ }
783
+
784
+ .previsit-report .report-section {
785
+ margin-bottom: 16px;
786
+ }
787
+
788
+ .previsit-report .report-section h3 {
789
+ font-size: 14px;
790
+ color: var(--primary);
791
+ margin: 0 0 8px 0;
792
+ }
793
+
794
+ .previsit-report .report-section p {
795
+ margin: 0;
796
+ line-height: 1.5;
797
+ }
798
+
799
+ .previsit-report .report-section ul {
800
+ margin: 0;
801
+ padding-left: 20px;
802
+ }
803
+
804
+ .previsit-report .report-section li {
805
+ margin: 4px 0;
806
+ }
807
+
808
+ .previsit-report .report-columns {
809
+ display: flex;
810
+ gap: 20px;
811
+ }
812
+
813
+ .previsit-report .report-section.half {
814
+ flex: 1;
815
+ }
816
+
817
+ .previsit-report .vitals-grid {
818
+ display: grid;
819
+ grid-template-columns: repeat(2, 1fr);
820
+ gap: 8px;
821
+ }
822
+
823
+ .previsit-report .vital-item {
824
+ background: var(--secondary);
825
+ padding: 8px 12px;
826
+ border-radius: var(--radius-sm);
827
+ }
828
+
829
+ .previsit-report .vital-name {
830
+ color: var(--text-muted);
831
+ }
832
+
833
+ .previsit-report .vital-value {
834
+ color: var(--text-main);
835
+ font-weight: 600;
836
+ }
837
+
838
+ .previsit-report blockquote {
839
+ background: var(--secondary);
840
+ border-left: 3px solid var(--primary);
841
+ margin: 8px 0;
842
+ padding: 10px 15px;
843
+ font-style: italic;
844
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
845
+ }
846
+
847
+ .previsit-report .attachments-list {
848
+ display: flex;
849
+ flex-direction: column;
850
+ gap: 8px;
851
+ }
852
+
853
+ .previsit-report .attachment-item {
854
+ background: var(--secondary);
855
+ padding: 10px 12px;
856
+ border-radius: var(--radius-sm);
857
+ display: flex;
858
+ align-items: center;
859
+ gap: 10px;
860
+ }
861
+
862
+ .previsit-report .attachment-icon {
863
+ font-size: 20px;
864
+ }
865
+
866
+ .previsit-report .attachment-title {
867
+ font-weight: 600;
868
+ }
869
+
870
+ .previsit-report .attachment-summary {
871
+ font-size: 12px;
872
+ color: var(--text-muted);
873
+ margin-top: 4px;
874
+ }
875
+
876
+ .previsit-report .report-footer {
877
+ margin-top: 20px;
878
+ padding-top: 15px;
879
+ border-top: 1px solid var(--border-color);
880
+ font-size: 12px;
881
+ color: var(--text-muted);
882
+ text-align: center;
883
+ }
884
  </style>
885
  </head>
886
  <body>
 
918
  </div>
919
  </div>
920
 
921
+ <div class="main-content-wrapper">
922
+ <div class="chat-container">
923
+ <div class="chat-viewport" id="chatMessages">
924
+ <div class="message-row assistant">
925
+ <div class="bubble">
926
+ Hello! I'm ready to help. You can ask me to visualize your health data, or use the microphone to analyze respiratory sounds.
927
+ </div>
928
+ </div>
929
  </div>
 
 
930
 
931
+ <div class="input-area">
932
+
933
+ <div class="chips-scroll">
934
+ <div class="chip" data-q="Show me my blood pressure chart">📈 BP Chart</div>
935
+ <div class="chip" data-q="Summarize my active medications">💊 Meds</div>
936
+ <div class="chip" data-q="How is my cholesterol trending?">📊 Cholesterol</div>
937
+ <div class="chip" data-q="Analyze my cough risk">🎤 Cough Check</div>
938
+ </div>
939
+
940
+ <div class="input-group" id="mainInputGroup">
941
+ <input type="text" id="chatInput" placeholder="Ask a health question..." disabled>
942
+
943
+ <div class="recording-overlay" id="recordingOverlay">
944
+ <span style="color: var(--danger); font-weight: 600; font-size: 14px;">Recording...</span>
945
+ <div class="wave-viz"></div>
946
+ </div>
947
+
948
+ <button class="btn-icon btn-record" id="recordBtn" title="Record Audio" disabled>
949
+ 🎤
950
+ </button>
951
+ <button class="btn-icon btn-send" id="chatSend" disabled>
952
+
953
+ </button>
954
+ </div>
955
+
956
+ <div style="text-align: center; margin-top: 10px; font-size: 11px; color: var(--text-muted);">
957
+ ⚠️ AI responses can be inaccurate. Consult a doctor.
958
+ </div>
959
+ </div>
960
  </div>
961
 
962
+ <!-- Report Panel (Right Sidebar) -->
963
+ <div class="report-panel" id="reportPanel">
964
+ <div class="report-panel-header">
965
+ <h3>📋 Pre-Visit Report</h3>
966
+ <button class="report-panel-close" onclick="toggleReportPanel()"></button>
967
+ </div>
968
+ <div class="report-panel-body">
969
+ <div class="report-preview-placeholder" id="reportPlaceholder">
970
+ <div class="icon">📝</div>
971
+ <p>Chat with me about your health concerns, then generate a summary to share with your doctor.</p>
972
+ </div>
973
+ <div class="report-preview" id="reportPreview" style="display: none;" onclick="openReportModal()">
974
+ <h4>Pre-Visit Summary Ready</h4>
975
+ <div class="report-preview-concerns" id="previewConcerns"></div>
976
+ <div class="report-preview-meta" id="previewMeta"></div>
977
+ </div>
978
  </div>
979
+ <div class="report-panel-actions">
980
+ <button class="btn-generate-report" id="btnGenerateReport" onclick="generateReport()">
981
+ 📋 Generate Report
982
+ </button>
983
+ </div>
984
+ </div>
985
+ </div>
986
+ </div>
987
 
988
+ <!-- Toggle Report Button -->
989
+ <button class="btn-toggle-report" id="btnToggleReport" onclick="toggleReportPanel()" title="Pre-Visit Report">
990
+ 📋
991
+ </button>
992
+
993
+ <!-- Report Modal -->
994
+ <div class="report-modal-overlay" id="reportModalOverlay" onclick="closeReportModal(event)">
995
+ <div class="report-modal" onclick="event.stopPropagation()">
996
+ <div class="report-modal-header">
997
+ <h2>📋 Pre-Visit Summary</h2>
998
+ <div class="report-modal-actions">
999
+ <button class="btn-modal-action btn-download" onclick="downloadReport()">⬇ Download</button>
1000
+ <button class="btn-modal-action btn-close-modal" onclick="closeReportModal()">Close</button>
1001
+ </div>
1002
  </div>
1003
+ <div class="report-modal-body" id="reportModalBody">
1004
+ <!-- Report content will be inserted here -->
 
1005
  </div>
1006
  </div>
1007
  </div>
 
1013
  let patientId = null;
1014
  let chartCounter = 0;
1015
 
1016
+ // Report tracking state
1017
+ let conversationHistory = [];
1018
+ let collectedToolResults = [];
1019
+ let collectedAttachments = [];
1020
+ let currentReport = null;
1021
+
1022
  // DOM Elements
1023
  const chatMessages = document.getElementById('chatMessages');
1024
  const chatInput = document.getElementById('chatInput');
 
1142
  div.innerHTML = `<div class="bubble">${escapeHtml(content)}</div>`;
1143
  chatMessages.appendChild(div);
1144
  scrollToBottom();
1145
+
1146
+ // Track for report
1147
+ trackConversation('user', content);
1148
  }
1149
 
1150
  // UPDATED: Now processes Markdown AND removes "ANSWER:" prefix
 
1161
  div.innerHTML = `<div class="bubble markdown-body">${htmlContent}</div>`;
1162
  chatMessages.appendChild(div);
1163
  scrollToBottom();
1164
+
1165
+ // Track for report
1166
+ trackConversation('assistant', cleanContent);
1167
  }
1168
 
1169
  // Streaming answer support
 
1195
  function endStreamingAnswer() {
1196
  if (streamingBubble) {
1197
  streamingBubble.classList.remove('streaming');
1198
+
1199
+ // Track the completed streamed answer for report
1200
+ if (streamingText.trim()) {
1201
+ const cleanContent = streamingText.replace(/^ANSWER:\s*/i, '');
1202
+ trackConversation('assistant', cleanContent);
1203
+ }
1204
+
1205
  streamingBubble = null;
1206
  streamingText = "";
1207
  }
 
1325
  `;
1326
  chatMessages.appendChild(div);
1327
  scrollToBottom();
1328
+
1329
+ // Track for report
1330
+ trackToolResult(toolName, facts);
1331
  }
1332
 
1333
  // ==========================================
 
1337
  chartCounter++;
1338
  const canvasId = `canvas-${chartCounter}`;
1339
 
1340
+ // Track as attachment for report
1341
+ let summary = chartData.title;
1342
+ if (chartData.summary && Array.isArray(chartData.summary)) {
1343
+ summary += ': ' + chartData.summary.slice(0, 2).join('; ');
1344
+ }
1345
+ trackAttachment('chart', chartData.title, summary);
1346
+
1347
  const div = document.createElement('div');
1348
  div.className = 'message-row widget-row';
1349
  div.innerHTML = `
 
1583
  const score = data.respiratory_health_score || data.overall_score || 0;
1584
  const modelName = result.model || 'Unknown';
1585
 
1586
+ // Track as attachment for report
1587
+ const summary = `Respiratory Health Score: ${Math.round(score)}/100. ` +
1588
+ `Cough: ${data.cough_level || 'None'}. ` +
1589
+ `Breathing: ${data.breathing_quality || 'Normal'}.` +
1590
+ (result.covid_risk ? ` COVID Risk: ${result.covid_risk.risk_level || 'Unknown'}.` : '');
1591
+ trackAttachment('audio', `Respiratory Analysis (${modelName})`, summary);
1592
+
1593
  const scoreColor = score < 60 ? 'var(--danger)' : score < 80 ? 'var(--warning)' : 'var(--success)';
1594
 
1595
  const html = `
 
1631
  chatMessages.insertAdjacentHTML('beforeend', html);
1632
  scrollToBottom();
1633
  }
1634
+
1635
+ // ==========================================
1636
+ // PRE-VISIT REPORT FUNCTIONS
1637
+ // ==========================================
1638
+
1639
+ function toggleReportPanel() {
1640
+ const panel = document.getElementById('reportPanel');
1641
+ const btn = document.getElementById('btnToggleReport');
1642
+ panel.classList.toggle('open');
1643
+
1644
+ if (panel.classList.contains('open')) {
1645
+ btn.style.display = 'none';
1646
+ } else {
1647
+ btn.style.display = 'block';
1648
+ }
1649
+ }
1650
+
1651
+ function openReportModal() {
1652
+ if (!currentReport) return;
1653
+ const overlay = document.getElementById('reportModalOverlay');
1654
+ const body = document.getElementById('reportModalBody');
1655
+ body.innerHTML = currentReport.html;
1656
+ overlay.classList.add('open');
1657
+ }
1658
+
1659
+ function closeReportModal(event) {
1660
+ if (event && event.target !== event.currentTarget) return;
1661
+ document.getElementById('reportModalOverlay').classList.remove('open');
1662
+ }
1663
+
1664
+ async function generateReport() {
1665
+ if (!patientId) return;
1666
+
1667
+ const btn = document.getElementById('btnGenerateReport');
1668
+ btn.classList.add('loading');
1669
+ btn.disabled = true;
1670
+
1671
+ try {
1672
+ const response = await fetch('/api/report/generate', {
1673
+ method: 'POST',
1674
+ headers: { 'Content-Type': 'application/json' },
1675
+ body: JSON.stringify({
1676
+ patient_id: patientId,
1677
+ conversation: conversationHistory,
1678
+ tool_results: collectedToolResults,
1679
+ attachments: collectedAttachments
1680
+ })
1681
+ });
1682
+
1683
+ const data = await response.json();
1684
+
1685
+ if (data.success) {
1686
+ currentReport = data;
1687
+ updateReportPreview(data.report);
1688
+
1689
+ // Update toggle button
1690
+ document.getElementById('btnToggleReport').classList.add('has-report');
1691
+
1692
+ // Open panel if not already open
1693
+ const panel = document.getElementById('reportPanel');
1694
+ if (!panel.classList.contains('open')) {
1695
+ toggleReportPanel();
1696
+ }
1697
+ } else {
1698
+ alert('Failed to generate report: ' + (data.error || 'Unknown error'));
1699
+ }
1700
+ } catch (error) {
1701
+ console.error('Report generation error:', error);
1702
+ alert('Failed to generate report. Please try again.');
1703
+ } finally {
1704
+ btn.classList.remove('loading');
1705
+ btn.disabled = false;
1706
+ }
1707
+ }
1708
+
1709
+ function updateReportPreview(report) {
1710
+ document.getElementById('reportPlaceholder').style.display = 'none';
1711
+ const preview = document.getElementById('reportPreview');
1712
+ preview.style.display = 'block';
1713
+
1714
+ const concerns = report.chief_concerns || [];
1715
+ document.getElementById('previewConcerns').textContent =
1716
+ concerns.length > 0 ? concerns.slice(0, 2).join(', ') : 'No specific concerns noted';
1717
+
1718
+ const attachCount = (report.attachments || []).length;
1719
+ document.getElementById('previewMeta').textContent =
1720
+ `${report.generated_at} • ${attachCount} attachment${attachCount !== 1 ? 's' : ''}`;
1721
+ }
1722
+
1723
+ function downloadReport() {
1724
+ if (!currentReport) return;
1725
+
1726
+ // Create a printable HTML document
1727
+ const printContent = `
1728
+ <!DOCTYPE html>
1729
+ <html>
1730
+ <head>
1731
+ <title>Pre-Visit Summary - ${currentReport.report.patient_name}</title>
1732
+ <style>
1733
+ body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
1734
+ h2 { color: #5e72e4; border-bottom: 2px solid #5e72e4; padding-bottom: 10px; }
1735
+ h3 { color: #5e72e4; font-size: 14px; margin-top: 20px; }
1736
+ .report-meta { display: flex; justify-content: space-between; color: #666; margin-bottom: 20px; }
1737
+ ul { margin: 5px 0; }
1738
+ blockquote { background: #f5f5f5; border-left: 3px solid #5e72e4; margin: 10px 0; padding: 10px 15px; font-style: italic; }
1739
+ .vitals-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
1740
+ .vital-item { background: #f5f5f5; padding: 8px; border-radius: 4px; }
1741
+ .attachment-item { background: #f5f5f5; padding: 10px; margin: 5px 0; border-radius: 4px; }
1742
+ .footer { margin-top: 30px; padding-top: 15px; border-top: 1px solid #ddd; font-size: 12px; color: #666; text-align: center; }
1743
+ @media print { body { padding: 0; } }
1744
+ </style>
1745
+ </head>
1746
+ <body>
1747
+ ${currentReport.html}
1748
+ </body>
1749
+ </html>
1750
+ `;
1751
+
1752
+ // Open in new window for printing/saving
1753
+ const printWindow = window.open('', '_blank');
1754
+ printWindow.document.write(printContent);
1755
+ printWindow.document.close();
1756
+ }
1757
+
1758
+ // Track conversation for report
1759
+ function trackConversation(role, content) {
1760
+ conversationHistory.push({ role, content });
1761
+ }
1762
+
1763
+ // Track tool results for report
1764
+ function trackToolResult(tool, facts) {
1765
+ collectedToolResults.push({ tool, facts });
1766
+ }
1767
+
1768
+ // Track attachments for report
1769
+ function trackAttachment(type, title, summary) {
1770
+ collectedAttachments.push({ type, title, summary });
1771
+ }
1772
  </script>
1773
  </body>
1774
  </html>