priyansh-saxena1 commited on
Commit
11a7703
·
1 Parent(s): cb8adc6

fix: debug logging + real error messages + ROS guidance

Browse files
Files changed (2) hide show
  1. app/main.py +52 -39
  2. app/static/index.html +513 -383
app/main.py CHANGED
@@ -1,6 +1,8 @@
1
  import argparse
2
  import json
3
  import os
 
 
4
 
5
  from fastapi import FastAPI
6
  from pydantic import BaseModel
@@ -10,7 +12,7 @@ from app.schemas import ClinicalBrief
10
  from langgraph.types import Command
11
  from fastapi.staticfiles import StaticFiles
12
  from fastapi.responses import FileResponse
13
- import os
14
 
15
  class ChatRequest(BaseModel):
16
  session_id: str
@@ -82,52 +84,63 @@ async def health():
82
 
83
  @app.post("/chat", response_model=ChatResponse)
84
  async def chat(request: ChatRequest):
85
- import time
86
  t0 = time.time()
87
  print(f"\n[{t0:.3f}] [API] -> POST /chat received for {request.session_id}")
 
88
  config = {"configurable": {"thread_id": request.session_id}}
89
-
90
- # Get current checkpoint state
91
- snapshot = graph.get_state(config)
92
- print(f"[{time.time():.3f}] [API] Read existing state snapshot.")
93
-
94
- # Guard: if session is already complete, don't re-invoke the graph
95
- current_stage = snapshot.values.get("frontend_stage", "intake") if snapshot and snapshot.values else "intake"
96
- if current_stage == "done":
97
- print(f"[{time.time():.3f}] [API] Session already complete. Returning existing brief.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  reply = get_last_reply(request.session_id)
99
  brief_dict = get_brief(request.session_id)
 
 
 
 
 
 
 
 
 
 
100
  return ChatResponse(
101
- reply=reply or "Your intake is already complete. Please start a new session.",
102
- state="done",
103
- brief=brief_dict
104
  )
105
 
106
- # Check if graph is interrupted and waiting for input
107
- t_start_graph = time.time()
108
- if snapshot.next:
109
- print(f"[{time.time():.3f}] [API] Resuming graph from interrupt...")
110
- # First update state with the user message
111
- graph.update_state(config, {"messages": [{"role": "user", "content": request.message}]})
112
- # Then resume execution
113
- result = graph.invoke(None, config=config)
114
- else:
115
- print(f"[{time.time():.3f}] [API] Starting new graph invoke...")
116
- # New conversation - start fresh
117
- input_state = {"messages": [{"role": "user", "content": request.message}]}
118
- result = graph.invoke(input_state, config=config)
119
- print(f"[{time.time():.3f}] [API] <- Graph invoke returned in {time.time() - t_start_graph:.2f}s")
120
-
121
- t_final = time.time()
122
- current_node = get_current_node(request.session_id)
123
- reply = get_last_reply(request.session_id)
124
- brief_dict = get_brief(request.session_id)
125
-
126
- total_t = time.time() - t0
127
- print(f"[{time.time():.3f}] [API] Chat completed in {total_t:.2f}s total. Reply length: {len(reply)}")
128
-
129
- return ChatResponse(reply=reply, state=current_node, brief=brief_dict)
130
-
131
 
132
  def run_cli():
133
  print("=" * 60)
 
1
  import argparse
2
  import json
3
  import os
4
+ import time
5
+ import traceback
6
 
7
  from fastapi import FastAPI
8
  from pydantic import BaseModel
 
12
  from langgraph.types import Command
13
  from fastapi.staticfiles import StaticFiles
14
  from fastapi.responses import FileResponse
15
+
16
 
17
  class ChatRequest(BaseModel):
18
  session_id: str
 
84
 
85
  @app.post("/chat", response_model=ChatResponse)
86
  async def chat(request: ChatRequest):
 
87
  t0 = time.time()
88
  print(f"\n[{t0:.3f}] [API] -> POST /chat received for {request.session_id}")
89
+ print(f"[{t0:.3f}] [API] Message: '{request.message[:80]}'")
90
  config = {"configurable": {"thread_id": request.session_id}}
91
+
92
+ try:
93
+ # Get current checkpoint state
94
+ snapshot = graph.get_state(config)
95
+ has_state = bool(snapshot and snapshot.values)
96
+ has_next = bool(snapshot.next) if snapshot else False
97
+ print(f"[{time.time():.3f}] [API] Snapshot: has_state={has_state}, has_next={has_next}, next={snapshot.next if snapshot else 'N/A'}")
98
+
99
+ # Guard: if session is already complete, don't re-invoke the graph
100
+ current_stage = snapshot.values.get("frontend_stage", "intake") if has_state else "intake"
101
+ print(f"[{time.time():.3f}] [API] Current stage: {current_stage}")
102
+
103
+ if current_stage == "done":
104
+ print(f"[{time.time():.3f}] [API] Session already complete. Returning existing brief.")
105
+ reply = get_last_reply(request.session_id)
106
+ brief_dict = get_brief(request.session_id)
107
+ return ChatResponse(
108
+ reply=reply or "Your intake is already complete. Please start a new session.",
109
+ state="done",
110
+ brief=brief_dict
111
+ )
112
+
113
+ # Check if graph is interrupted and waiting for input
114
+ t_start_graph = time.time()
115
+ if has_next:
116
+ print(f"[{time.time():.3f}] [API] Resuming graph from interrupt (next={snapshot.next})...")
117
+ graph.update_state(config, {"messages": [{"role": "user", "content": request.message}]})
118
+ result = graph.invoke(None, config=config)
119
+ else:
120
+ print(f"[{time.time():.3f}] [API] Starting new graph invoke...")
121
+ input_state = {"messages": [{"role": "user", "content": request.message}]}
122
+ result = graph.invoke(input_state, config=config)
123
+ print(f"[{time.time():.3f}] [API] <- Graph invoke returned in {time.time() - t_start_graph:.2f}s")
124
+
125
+ current_node = get_current_node(request.session_id)
126
  reply = get_last_reply(request.session_id)
127
  brief_dict = get_brief(request.session_id)
128
+
129
+ total_t = time.time() - t0
130
+ print(f"[{time.time():.3f}] [API] Chat completed in {total_t:.2f}s. Reply='{reply[:60]}' Stage={current_node}")
131
+
132
+ return ChatResponse(reply=reply, state=current_node, brief=brief_dict)
133
+
134
+ except Exception as e:
135
+ tb = traceback.format_exc()
136
+ print(f"[{time.time():.3f}] [API] *** EXCEPTION in /chat ***")
137
+ print(tb)
138
  return ChatResponse(
139
+ reply=f"Server error: {type(e).__name__}: {str(e)[:200]}",
140
+ state="intake",
141
+ brief=None
142
  )
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  def run_cli():
146
  print("=" * 60)
app/static/index.html CHANGED
@@ -1,11 +1,18 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Clinical Intake Agent</title>
7
  <style>
8
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
 
 
 
 
 
9
 
10
  :root {
11
  --bg: #f4f6f9;
@@ -26,8 +33,8 @@
26
  --success-light: #f0fdf4;
27
  --radius: 12px;
28
  --radius-sm: 6px;
29
- --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
30
- --shadow-md: 0 4px 16px rgba(0,0,0,0.08);
31
  }
32
 
33
  body {
@@ -111,15 +118,25 @@
111
  transition: background 0.3s;
112
  }
113
 
114
- .status-dot.active { background: var(--success); }
 
 
 
115
  .status-dot.thinking {
116
  background: var(--accent);
117
  animation: pulse 1.2s infinite;
118
  }
119
 
120
  @keyframes pulse {
121
- 0%, 100% { opacity: 1; }
122
- 50% { opacity: 0.4; }
 
 
 
 
 
 
 
123
  }
124
 
125
  /* Main layout */
@@ -189,14 +206,20 @@
189
  transition: all 0.3s;
190
  }
191
 
192
- .step.active .step-label { color: var(--accent); }
 
 
 
193
  .step.active .step-num {
194
  border-color: var(--accent);
195
  background: var(--accent-light);
196
  color: var(--accent);
197
  }
198
 
199
- .step.done .step-label { color: var(--success); }
 
 
 
200
  .step.done .step-num {
201
  border-color: var(--success);
202
  background: var(--success-light);
@@ -222,9 +245,18 @@
222
  scroll-behavior: smooth;
223
  }
224
 
225
- .messages::-webkit-scrollbar { width: 4px; }
226
- .messages::-webkit-scrollbar-track { background: transparent; }
227
- .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
 
 
 
 
 
 
 
 
 
228
 
229
  /* Message bubbles */
230
  .message {
@@ -235,8 +267,15 @@
235
  }
236
 
237
  @keyframes fadeIn {
238
- from { opacity: 0; transform: translateY(4px); }
239
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
 
240
  }
241
 
242
  .message.user {
@@ -244,7 +283,9 @@
244
  flex-direction: row-reverse;
245
  }
246
 
247
- .message.agent { align-self: flex-start; }
 
 
248
 
249
  .avatar {
250
  width: 30px;
@@ -307,12 +348,27 @@
307
  animation: typing 1.2s infinite;
308
  }
309
 
310
- .typing-dot:nth-child(2) { animation-delay: 0.2s; }
311
- .typing-dot:nth-child(3) { animation-delay: 0.4s; }
 
 
 
 
 
312
 
313
  @keyframes typing {
314
- 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
315
- 30% { transform: translateY(-4px); opacity: 1; }
 
 
 
 
 
 
 
 
 
 
316
  }
317
 
318
  /* Input area */
@@ -354,8 +410,14 @@
354
  padding: 1px 0;
355
  }
356
 
357
- textarea::placeholder { color: var(--text-muted); }
358
- textarea:disabled { opacity: 0.5; cursor: not-allowed; }
 
 
 
 
 
 
359
 
360
  .send-btn {
361
  width: 34px;
@@ -372,9 +434,18 @@
372
  transition: background 0.2s, transform 0.1s;
373
  }
374
 
375
- .send-btn:hover:not(:disabled) { background: var(--accent-hover); }
376
- .send-btn:active:not(:disabled) { transform: scale(0.95); }
377
- .send-btn:disabled { background: var(--border); cursor: not-allowed; }
 
 
 
 
 
 
 
 
 
378
 
379
  .send-btn svg {
380
  width: 16px;
@@ -456,8 +527,22 @@
456
  transition: all 0.2s;
457
  flex-shrink: 0;
458
  }
459
- .icon-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
460
- .icon-btn svg { width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
  .brief-content {
463
  flex: 1;
@@ -468,8 +553,14 @@
468
  gap: 16px;
469
  }
470
 
471
- .brief-content::-webkit-scrollbar { width: 4px; }
472
- .brief-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
 
 
 
 
 
 
473
 
474
  .brief-empty {
475
  display: flex;
@@ -493,9 +584,16 @@
493
  stroke-linejoin: round;
494
  }
495
 
496
- .brief-empty p { font-size: 13px; line-height: 1.6; }
 
 
 
497
 
498
- .brief-section { display: flex; flex-direction: column; gap: 8px; }
 
 
 
 
499
 
500
  .brief-section-title {
501
  font-size: 11px;
@@ -530,7 +628,9 @@
530
  border-bottom: 1px solid var(--surface-2);
531
  }
532
 
533
- .hpi-row:last-child { border-bottom: none; }
 
 
534
 
535
  .hpi-key {
536
  font-size: 11px;
@@ -549,7 +649,11 @@
549
  flex: 1;
550
  }
551
 
552
- .ros-system { display: flex; flex-direction: column; gap: 4px; }
 
 
 
 
553
 
554
  .ros-system-name {
555
  font-size: 12px;
@@ -558,7 +662,11 @@
558
  text-transform: capitalize;
559
  }
560
 
561
- .ros-findings { display: flex; flex-wrap: wrap; gap: 4px; }
 
 
 
 
562
 
563
  .finding-tag {
564
  font-size: 11px;
@@ -610,212 +718,228 @@
610
  }
611
 
612
  @media (max-width: 768px) {
613
- .brief-panel { display: none; }
 
 
614
  }
615
  </style>
616
  </head>
 
617
  <body>
618
 
619
- <header>
620
- <div class="header-left">
621
- <div class="logo-mark">
622
- <svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
 
 
 
 
 
 
 
623
  </div>
624
- <div>
625
- <div class="header-title">Clinical Intake Agent</div>
626
- <div class="header-subtitle">Pre-visit patient intake system</div>
627
  </div>
628
- </div>
629
- <div class="status-badge">
630
- <div class="status-dot" id="statusDot"></div>
631
- <span id="statusText">Initializing</span>
632
- </div>
633
- </header>
634
-
635
- <div class="main">
636
- <div class="chat-panel">
637
- <div class="progress-bar">
638
- <div class="progress-steps">
639
- <div class="step" id="step-intake">
640
- <div class="step-label">
641
- <div class="step-num">1</div>
642
- Chief Complaint
643
  </div>
644
- </div>
645
- <div class="step-connector"></div>
646
- <div class="step" id="step-hpi">
647
- <div class="step-label">
648
- <div class="step-num">2</div>
649
- History
650
  </div>
651
- </div>
652
- <div class="step-connector"></div>
653
- <div class="step" id="step-ros">
654
- <div class="step-label">
655
- <div class="step-num">3</div>
656
- Systems Review
657
  </div>
658
- </div>
659
- <div class="step-connector"></div>
660
- <div class="step" id="step-done">
661
- <div class="step-label">
662
- <div class="step-num">4</div>
663
- Summary
664
  </div>
665
  </div>
666
  </div>
667
- </div>
668
 
669
- <div class="messages" id="messages"></div>
670
-
671
- <div class="input-area">
672
- <div class="input-wrapper">
673
- <textarea
674
- id="input"
675
- placeholder="Type your response..."
676
- rows="1"
677
- autocomplete="off"
678
- spellcheck="false"
679
- ></textarea>
680
- <button class="send-btn" id="sendBtn" disabled>
681
- <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
682
- </button>
683
  </div>
684
- <div class="input-hint">Press Enter to send &nbsp;&middot;&nbsp; Shift+Enter for new line</div>
685
  </div>
686
- </div>
687
 
688
- <div class="brief-panel">
689
- <div class="brief-header">
690
- <h2>Clinical Brief</h2>
691
- <div class="brief-header-right">
692
- <button class="icon-btn" id="copyBtn" title="Copy to clipboard" style="display:none">
693
- <svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
694
- </button>
695
- <button class="icon-btn" id="printBtn" title="Print" style="display:none">
696
- <svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
697
- </button>
698
- <span class="brief-badge" id="briefBadge">Pending</span>
 
 
 
 
 
 
 
 
699
  </div>
700
- </div>
701
- <div class="brief-content" id="briefContent">
702
- <div class="brief-empty">
703
- <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
704
- <p>The clinical brief will appear here once the intake is complete.</p>
 
 
 
 
 
 
705
  </div>
 
706
  </div>
707
- <button class="reset-btn" id="resetBtn">Start New Session</button>
708
  </div>
709
- </div>
710
-
711
- <script>
712
- const messagesEl = document.getElementById('messages');
713
- const inputEl = document.getElementById('input');
714
- const sendBtn = document.getElementById('sendBtn');
715
- const statusDot = document.getElementById('statusDot');
716
- const statusText = document.getElementById('statusText');
717
- const briefContent = document.getElementById('briefContent');
718
- const briefBadge = document.getElementById('briefBadge');
719
- const resetBtn = document.getElementById('resetBtn');
720
-
721
- let sessionId = 'session_' + Math.random().toString(36).slice(2, 11);
722
- let isWaiting = false;
723
- let isComplete = false;
724
-
725
- const STEPS = { intake: 1, hpi: 2, ros: 3, brief_generator: 4, done: 4 };
726
-
727
- function setStatus(state) {
728
- if (state === 'thinking') {
729
- statusDot.className = 'status-dot thinking';
730
- statusText.textContent = 'Processing';
731
- } else if (state === 'ready') {
732
- statusDot.className = 'status-dot active';
733
- statusText.textContent = 'Ready';
734
- } else if (state === 'complete') {
735
- statusDot.className = 'status-dot active';
736
- statusText.textContent = 'Intake complete';
737
- } else {
738
- statusDot.className = 'status-dot';
739
- statusText.textContent = 'Offline';
740
- }
741
- }
742
-
743
- function updateProgress(nodeState) {
744
- const stepMap = { intake: 'step-intake', hpi: 'step-hpi', ros: 'step-ros', done: 'step-done', brief_generator: 'step-done' };
745
- const order = ['step-intake', 'step-hpi', 'step-ros', 'step-done'];
746
- const current = stepMap[nodeState] || 'step-intake';
747
- const currentIdx = order.indexOf(current);
748
-
749
- order.forEach((id, idx) => {
750
- const el = document.getElementById(id);
751
- el.className = 'step';
752
- if (idx < currentIdx) el.classList.add('done');
753
- else if (idx === currentIdx) el.classList.add('active');
754
- });
755
- }
756
-
757
- function addMessage(role, text) {
758
- const wrap = document.createElement('div');
759
- wrap.className = `message ${role}`;
760
-
761
- const avatar = document.createElement('div');
762
- avatar.className = 'avatar';
763
- avatar.textContent = role === 'agent' ? 'AI' : 'PT';
764
-
765
- const bubble = document.createElement('div');
766
- bubble.className = 'bubble';
767
- bubble.textContent = text;
768
-
769
- wrap.appendChild(avatar);
770
- wrap.appendChild(bubble);
771
- messagesEl.appendChild(wrap);
772
- messagesEl.scrollTop = messagesEl.scrollHeight;
773
- return wrap;
774
- }
775
-
776
- function showTyping() {
777
- const wrap = document.createElement('div');
778
- wrap.className = 'message agent';
779
- wrap.id = 'typing';
780
-
781
- const avatar = document.createElement('div');
782
- avatar.className = 'avatar';
783
- avatar.textContent = 'AI';
784
-
785
- const bubble = document.createElement('div');
786
- bubble.className = 'bubble typing-indicator';
787
- for (let i = 0; i < 3; i++) {
788
- const dot = document.createElement('div');
789
- dot.className = 'typing-dot';
790
- bubble.appendChild(dot);
791
- }
792
-
793
- wrap.appendChild(avatar);
794
- wrap.appendChild(bubble);
795
- messagesEl.appendChild(wrap);
796
- messagesEl.scrollTop = messagesEl.scrollHeight;
797
- }
798
-
799
- function removeTyping() {
800
- const el = document.getElementById('typing');
801
- if (el) el.remove();
802
- }
803
-
804
- let lastBrief = null;
805
-
806
- function renderBrief(brief) {
807
- lastBrief = brief;
808
- const hpiLabels = [
809
- ['onset', 'Onset'],
810
- ['location', 'Location'],
811
- ['duration', 'Duration'],
812
- ['character', 'Character'],
813
- ['severity', 'Severity'],
814
- ['aggravating', 'Aggravating'],
815
- ['relieving', 'Relieving'],
816
- ];
817
-
818
- let html = `
819
  <div class="brief-section">
820
  <div class="brief-section-title">Chief Complaint</div>
821
  <div class="cc-value">${escHtml(brief.chief_complaint)}</div>
@@ -825,190 +949,195 @@
825
  <div class="hpi-grid">
826
  `;
827
 
828
- for (const [key, label] of hpiLabels) {
829
- const val = brief.hpi[key] || 'Not specified';
830
- const isMissing = !brief.hpi[key] || brief.hpi[key] === 'Not specified';
831
- html += `
832
  <div class="hpi-row">
833
  <div class="hpi-key">${label}</div>
834
  <div class="hpi-val" style="${isMissing ? 'color:var(--text-muted);font-style:italic' : ''}">${escHtml(val)}</div>
835
  </div>
836
  `;
837
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
 
839
- html += `</div></div>`;
 
840
 
841
- if (brief.ros && Object.keys(brief.ros).length > 0) {
842
- html += `<div class="brief-section"><div class="brief-section-title">Review of Systems</div>`;
843
- for (const [system, findings] of Object.entries(brief.ros)) {
844
- const label = system.charAt(0).toUpperCase() + system.slice(1);
845
- html += `<div class="ros-system"><div class="ros-system-name">${escHtml(label)}</div><div class="ros-findings">`;
846
- findings.forEach(f => {
847
- const fl = f.toLowerCase();
848
- const isNeg = fl.startsWith('no ') || fl.includes('none') || fl.includes('absent') || fl.includes('denied') || fl.includes('no swelling') || fl.includes('negative');
849
- html += `<span class="finding-tag ${isNeg ? 'negative' : 'positive'}">${escHtml(f)}</span>`;
850
- });
851
- html += `</div></div>`;
 
 
 
 
852
  }
853
- html += `</div>`;
854
- }
855
-
856
- const ts = brief.generated_at ? new Date(brief.generated_at).toLocaleString() : '';
857
- if (ts) html += `<div class="brief-timestamp">Generated ${ts}</div>`;
858
-
859
- briefContent.innerHTML = html;
860
- briefBadge.textContent = 'Complete';
861
- briefBadge.className = 'brief-badge complete';
862
- document.getElementById('copyBtn').style.display = 'flex';
863
- document.getElementById('printBtn').style.display = 'flex';
864
- }
865
-
866
- function briefToPlainText(brief) {
867
- const hpiLabels = ['onset','location','duration','character','severity','aggravating','relieving'];
868
- let txt = `CLINICAL BRIEF\n${'='.repeat(40)}\n`;
869
- txt += `Chief Complaint: ${brief.chief_complaint}\n\n`;
870
- txt += `History of Present Illness\n${'-'.repeat(30)}\n`;
871
- for (const key of hpiLabels) {
872
- const val = brief.hpi[key] || 'Not specified';
873
- txt += `${key.charAt(0).toUpperCase()+key.slice(1).padEnd(14)}: ${val}\n`;
874
- }
875
- if (brief.ros && Object.keys(brief.ros).length > 0) {
876
- txt += `\nReview of Systems\n${'-'.repeat(30)}\n`;
877
- for (const [sys, findings] of Object.entries(brief.ros)) {
878
- txt += `${sys.charAt(0).toUpperCase()+sys.slice(1)}: ${findings.join(', ')}\n`;
879
  }
 
 
 
880
  }
881
- const ts = brief.generated_at ? new Date(brief.generated_at).toLocaleString() : '';
882
- if (ts) txt += `\nGenerated: ${ts}`;
883
- return txt;
884
- }
885
-
886
- function escHtml(str) {
887
- return String(str)
888
- .replace(/&/g, '&amp;')
889
- .replace(/</g, '&lt;')
890
- .replace(/>/g, '&gt;')
891
- .replace(/"/g, '&quot;');
892
- }
893
-
894
- async function sendMessage(text) {
895
- if (!text.trim() || isWaiting || isComplete) return;
896
-
897
- isWaiting = true;
898
- sendBtn.disabled = true;
899
- inputEl.disabled = true;
900
- setStatus('thinking');
901
-
902
- addMessage('user', text);
903
- showTyping();
904
-
905
- try {
906
- const res = await fetch('/chat', {
907
- method: 'POST',
908
- headers: { 'Content-Type': 'application/json' },
909
- body: JSON.stringify({ session_id: sessionId, message: text })
910
- });
911
 
912
- if (!res.ok) throw new Error(`Server error ${res.status}`);
913
- const data = await res.json();
 
 
 
 
 
914
 
915
- removeTyping();
916
- addMessage('agent', data.reply);
917
- updateProgress(data.state);
918
 
919
- if (data.state === 'done' && data.brief) {
920
- renderBrief(data.brief);
921
- isComplete = true;
922
- setStatus('complete');
923
- inputEl.disabled = true;
924
- sendBtn.disabled = true;
925
- inputEl.placeholder = 'Intake complete. Start a new session to begin again.';
926
- } else {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  setStatus('ready');
928
  inputEl.disabled = false;
929
- inputEl.focus();
930
  sendBtn.disabled = false;
931
  }
932
 
933
- } catch (err) {
934
- removeTyping();
935
- addMessage('agent', 'An error occurred. Please try again.');
936
- setStatus('ready');
937
- inputEl.disabled = false;
938
- sendBtn.disabled = false;
939
  }
940
 
941
- isWaiting = false;
942
- }
 
 
 
 
 
 
 
 
943
 
944
- async function initSession() {
945
- setStatus('thinking');
946
- showTyping();
 
 
 
 
 
 
 
 
 
 
947
 
948
- try {
949
- const res = await fetch('/chat', {
950
- method: 'POST',
951
- headers: { 'Content-Type': 'application/json' },
952
- body: JSON.stringify({ session_id: sessionId, message: 'hello' })
 
 
953
  });
 
954
 
955
- const data = await res.json();
956
- removeTyping();
957
- addMessage('agent', data.reply);
958
- updateProgress(data.state);
959
- setStatus('ready');
960
- sendBtn.disabled = false;
961
- inputEl.focus();
962
- } catch {
963
- removeTyping();
964
- addMessage('agent', 'Could not connect to the server. Please refresh.');
965
- setStatus('offline');
966
- }
967
- }
968
-
969
- document.getElementById('copyBtn').addEventListener('click', () => {
970
- if (!lastBrief) return;
971
- const txt = briefToPlainText(lastBrief);
972
- navigator.clipboard.writeText(txt).then(() => {
973
- const btn = document.getElementById('copyBtn');
974
- btn.title = 'Copied!';
975
- setTimeout(() => { btn.title = 'Copy to clipboard'; }, 2000);
976
  });
977
- });
978
-
979
- document.getElementById('printBtn').addEventListener('click', () => {
980
- if (!lastBrief) return;
981
- const txt = briefToPlainText(lastBrief);
982
- const w = window.open('', '_blank');
983
- w.document.write(`<pre style="font-family:monospace;padding:24px;max-width:700px;margin:auto">${txt}</pre>`);
984
- w.document.close();
985
- w.print();
986
- });
987
-
988
- sendBtn.addEventListener('click', () => {
989
- const text = inputEl.value.trim();
990
- if (text) { inputEl.value = ''; autoResize(); sendMessage(text); }
991
- });
992
-
993
- inputEl.addEventListener('keydown', e => {
994
- if (e.key === 'Enter' && !e.shiftKey) {
995
- e.preventDefault();
996
  const text = inputEl.value.trim();
997
  if (text) { inputEl.value = ''; autoResize(); sendMessage(text); }
998
- }
999
- });
1000
 
1001
- inputEl.addEventListener('input', autoResize);
 
 
 
 
 
 
1002
 
1003
- function autoResize() {
1004
- inputEl.style.height = 'auto';
1005
- inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1006
- }
1007
 
1008
- resetBtn.addEventListener('click', () => {
1009
- sessionId = 'session_' + Math.random().toString(36).slice(2, 11);
1010
- messagesEl.innerHTML = '';
1011
- briefContent.innerHTML = `
 
 
 
 
 
1012
  <div class="brief-empty">
1013
  <svg viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1014
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -1019,19 +1148,20 @@
1019
  </svg>
1020
  <p>The clinical brief will appear here once the intake is complete.</p>
1021
  </div>`;
1022
- briefBadge.textContent = 'Pending';
1023
- briefBadge.className = 'brief-badge';
1024
- inputEl.value = '';
1025
- inputEl.placeholder = 'Type your response...';
1026
- inputEl.disabled = false;
1027
- isComplete = false;
1028
- isWaiting = false;
 
 
 
 
1029
  updateProgress('intake');
1030
  initSession();
1031
- });
1032
-
1033
- updateProgress('intake');
1034
- initSession();
1035
- </script>
1036
  </body>
 
1037
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
+
4
  <head>
5
  <meta charset="UTF-8" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
  <title>Clinical Intake Agent</title>
8
  <style>
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
 
17
  :root {
18
  --bg: #f4f6f9;
 
33
  --success-light: #f0fdf4;
34
  --radius: 12px;
35
  --radius-sm: 6px;
36
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
37
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
38
  }
39
 
40
  body {
 
118
  transition: background 0.3s;
119
  }
120
 
121
+ .status-dot.active {
122
+ background: var(--success);
123
+ }
124
+
125
  .status-dot.thinking {
126
  background: var(--accent);
127
  animation: pulse 1.2s infinite;
128
  }
129
 
130
  @keyframes pulse {
131
+
132
+ 0%,
133
+ 100% {
134
+ opacity: 1;
135
+ }
136
+
137
+ 50% {
138
+ opacity: 0.4;
139
+ }
140
  }
141
 
142
  /* Main layout */
 
206
  transition: all 0.3s;
207
  }
208
 
209
+ .step.active .step-label {
210
+ color: var(--accent);
211
+ }
212
+
213
  .step.active .step-num {
214
  border-color: var(--accent);
215
  background: var(--accent-light);
216
  color: var(--accent);
217
  }
218
 
219
+ .step.done .step-label {
220
+ color: var(--success);
221
+ }
222
+
223
  .step.done .step-num {
224
  border-color: var(--success);
225
  background: var(--success-light);
 
245
  scroll-behavior: smooth;
246
  }
247
 
248
+ .messages::-webkit-scrollbar {
249
+ width: 4px;
250
+ }
251
+
252
+ .messages::-webkit-scrollbar-track {
253
+ background: transparent;
254
+ }
255
+
256
+ .messages::-webkit-scrollbar-thumb {
257
+ background: var(--border);
258
+ border-radius: 4px;
259
+ }
260
 
261
  /* Message bubbles */
262
  .message {
 
267
  }
268
 
269
  @keyframes fadeIn {
270
+ from {
271
+ opacity: 0;
272
+ transform: translateY(4px);
273
+ }
274
+
275
+ to {
276
+ opacity: 1;
277
+ transform: translateY(0);
278
+ }
279
  }
280
 
281
  .message.user {
 
283
  flex-direction: row-reverse;
284
  }
285
 
286
+ .message.agent {
287
+ align-self: flex-start;
288
+ }
289
 
290
  .avatar {
291
  width: 30px;
 
348
  animation: typing 1.2s infinite;
349
  }
350
 
351
+ .typing-dot:nth-child(2) {
352
+ animation-delay: 0.2s;
353
+ }
354
+
355
+ .typing-dot:nth-child(3) {
356
+ animation-delay: 0.4s;
357
+ }
358
 
359
  @keyframes typing {
360
+
361
+ 0%,
362
+ 60%,
363
+ 100% {
364
+ transform: translateY(0);
365
+ opacity: 0.5;
366
+ }
367
+
368
+ 30% {
369
+ transform: translateY(-4px);
370
+ opacity: 1;
371
+ }
372
  }
373
 
374
  /* Input area */
 
410
  padding: 1px 0;
411
  }
412
 
413
+ textarea::placeholder {
414
+ color: var(--text-muted);
415
+ }
416
+
417
+ textarea:disabled {
418
+ opacity: 0.5;
419
+ cursor: not-allowed;
420
+ }
421
 
422
  .send-btn {
423
  width: 34px;
 
434
  transition: background 0.2s, transform 0.1s;
435
  }
436
 
437
+ .send-btn:hover:not(:disabled) {
438
+ background: var(--accent-hover);
439
+ }
440
+
441
+ .send-btn:active:not(:disabled) {
442
+ transform: scale(0.95);
443
+ }
444
+
445
+ .send-btn:disabled {
446
+ background: var(--border);
447
+ cursor: not-allowed;
448
+ }
449
 
450
  .send-btn svg {
451
  width: 16px;
 
527
  transition: all 0.2s;
528
  flex-shrink: 0;
529
  }
530
+
531
+ .icon-btn:hover {
532
+ border-color: var(--accent);
533
+ color: var(--accent);
534
+ background: var(--accent-light);
535
+ }
536
+
537
+ .icon-btn svg {
538
+ width: 13px;
539
+ height: 13px;
540
+ stroke: currentColor;
541
+ fill: none;
542
+ stroke-width: 2;
543
+ stroke-linecap: round;
544
+ stroke-linejoin: round;
545
+ }
546
 
547
  .brief-content {
548
  flex: 1;
 
553
  gap: 16px;
554
  }
555
 
556
+ .brief-content::-webkit-scrollbar {
557
+ width: 4px;
558
+ }
559
+
560
+ .brief-content::-webkit-scrollbar-thumb {
561
+ background: var(--border);
562
+ border-radius: 4px;
563
+ }
564
 
565
  .brief-empty {
566
  display: flex;
 
584
  stroke-linejoin: round;
585
  }
586
 
587
+ .brief-empty p {
588
+ font-size: 13px;
589
+ line-height: 1.6;
590
+ }
591
 
592
+ .brief-section {
593
+ display: flex;
594
+ flex-direction: column;
595
+ gap: 8px;
596
+ }
597
 
598
  .brief-section-title {
599
  font-size: 11px;
 
628
  border-bottom: 1px solid var(--surface-2);
629
  }
630
 
631
+ .hpi-row:last-child {
632
+ border-bottom: none;
633
+ }
634
 
635
  .hpi-key {
636
  font-size: 11px;
 
649
  flex: 1;
650
  }
651
 
652
+ .ros-system {
653
+ display: flex;
654
+ flex-direction: column;
655
+ gap: 4px;
656
+ }
657
 
658
  .ros-system-name {
659
  font-size: 12px;
 
662
  text-transform: capitalize;
663
  }
664
 
665
+ .ros-findings {
666
+ display: flex;
667
+ flex-wrap: wrap;
668
+ gap: 4px;
669
+ }
670
 
671
  .finding-tag {
672
  font-size: 11px;
 
718
  }
719
 
720
  @media (max-width: 768px) {
721
+ .brief-panel {
722
+ display: none;
723
+ }
724
  }
725
  </style>
726
  </head>
727
+
728
  <body>
729
 
730
+ <header>
731
+ <div class="header-left">
732
+ <div class="logo-mark">
733
+ <svg viewBox="0 0 24 24">
734
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
735
+ </svg>
736
+ </div>
737
+ <div>
738
+ <div class="header-title">Clinical Intake Agent</div>
739
+ <div class="header-subtitle">Pre-visit patient intake system</div>
740
+ </div>
741
  </div>
742
+ <div class="status-badge">
743
+ <div class="status-dot" id="statusDot"></div>
744
+ <span id="statusText">Initializing</span>
745
  </div>
746
+ </header>
747
+
748
+ <div class="main">
749
+ <div class="chat-panel">
750
+ <div class="progress-bar">
751
+ <div class="progress-steps">
752
+ <div class="step" id="step-intake">
753
+ <div class="step-label">
754
+ <div class="step-num">1</div>
755
+ Chief Complaint
756
+ </div>
 
 
 
 
757
  </div>
758
+ <div class="step-connector"></div>
759
+ <div class="step" id="step-hpi">
760
+ <div class="step-label">
761
+ <div class="step-num">2</div>
762
+ History
763
+ </div>
764
  </div>
765
+ <div class="step-connector"></div>
766
+ <div class="step" id="step-ros">
767
+ <div class="step-label">
768
+ <div class="step-num">3</div>
769
+ Systems Review
770
+ </div>
771
  </div>
772
+ <div class="step-connector"></div>
773
+ <div class="step" id="step-done">
774
+ <div class="step-label">
775
+ <div class="step-num">4</div>
776
+ Summary
777
+ </div>
778
  </div>
779
  </div>
780
  </div>
 
781
 
782
+ <div class="messages" id="messages"></div>
783
+
784
+ <div class="input-area">
785
+ <div class="input-wrapper">
786
+ <textarea id="input" placeholder="Type your response..." rows="1" autocomplete="off"
787
+ spellcheck="false"></textarea>
788
+ <button class="send-btn" id="sendBtn" disabled>
789
+ <svg viewBox="0 0 24 24">
790
+ <line x1="22" y1="2" x2="11" y2="13" />
791
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
792
+ </svg>
793
+ </button>
794
+ </div>
795
+ <div class="input-hint">Press Enter to send &nbsp;&middot;&nbsp; Shift+Enter for new line</div>
796
  </div>
 
797
  </div>
 
798
 
799
+ <div class="brief-panel">
800
+ <div class="brief-header">
801
+ <h2>Clinical Brief</h2>
802
+ <div class="brief-header-right">
803
+ <button class="icon-btn" id="copyBtn" title="Copy to clipboard" style="display:none">
804
+ <svg viewBox="0 0 24 24">
805
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
806
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
807
+ </svg>
808
+ </button>
809
+ <button class="icon-btn" id="printBtn" title="Print" style="display:none">
810
+ <svg viewBox="0 0 24 24">
811
+ <polyline points="6 9 6 2 18 2 18 9" />
812
+ <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
813
+ <rect x="6" y="14" width="12" height="8" />
814
+ </svg>
815
+ </button>
816
+ <span class="brief-badge" id="briefBadge">Pending</span>
817
+ </div>
818
  </div>
819
+ <div class="brief-content" id="briefContent">
820
+ <div class="brief-empty">
821
+ <svg viewBox="0 0 24 24">
822
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
823
+ <polyline points="14 2 14 8 20 8" />
824
+ <line x1="16" y1="13" x2="8" y2="13" />
825
+ <line x1="16" y1="17" x2="8" y2="17" />
826
+ <polyline points="10 9 9 9 8 9" />
827
+ </svg>
828
+ <p>The clinical brief will appear here once the intake is complete.</p>
829
+ </div>
830
  </div>
831
+ <button class="reset-btn" id="resetBtn">Start New Session</button>
832
  </div>
 
833
  </div>
834
+
835
+ <script>
836
+ const messagesEl = document.getElementById('messages');
837
+ const inputEl = document.getElementById('input');
838
+ const sendBtn = document.getElementById('sendBtn');
839
+ const statusDot = document.getElementById('statusDot');
840
+ const statusText = document.getElementById('statusText');
841
+ const briefContent = document.getElementById('briefContent');
842
+ const briefBadge = document.getElementById('briefBadge');
843
+ const resetBtn = document.getElementById('resetBtn');
844
+
845
+ let sessionId = 'session_' + Math.random().toString(36).slice(2, 11);
846
+ let isWaiting = false;
847
+ let isComplete = false;
848
+
849
+ const STEPS = { intake: 1, hpi: 2, ros: 3, brief_generator: 4, done: 4 };
850
+
851
+ function setStatus(state) {
852
+ if (state === 'thinking') {
853
+ statusDot.className = 'status-dot thinking';
854
+ statusText.textContent = 'Processing';
855
+ } else if (state === 'ready') {
856
+ statusDot.className = 'status-dot active';
857
+ statusText.textContent = 'Ready';
858
+ } else if (state === 'complete') {
859
+ statusDot.className = 'status-dot active';
860
+ statusText.textContent = 'Intake complete';
861
+ } else {
862
+ statusDot.className = 'status-dot';
863
+ statusText.textContent = 'Offline';
864
+ }
865
+ }
866
+
867
+ function updateProgress(nodeState) {
868
+ const stepMap = { intake: 'step-intake', hpi: 'step-hpi', ros: 'step-ros', done: 'step-done', brief_generator: 'step-done' };
869
+ const order = ['step-intake', 'step-hpi', 'step-ros', 'step-done'];
870
+ const current = stepMap[nodeState] || 'step-intake';
871
+ const currentIdx = order.indexOf(current);
872
+
873
+ order.forEach((id, idx) => {
874
+ const el = document.getElementById(id);
875
+ el.className = 'step';
876
+ if (idx < currentIdx) el.classList.add('done');
877
+ else if (idx === currentIdx) el.classList.add('active');
878
+ });
879
+ }
880
+
881
+ function addMessage(role, text) {
882
+ const wrap = document.createElement('div');
883
+ wrap.className = `message ${role}`;
884
+
885
+ const avatar = document.createElement('div');
886
+ avatar.className = 'avatar';
887
+ avatar.textContent = role === 'agent' ? 'AI' : 'PT';
888
+
889
+ const bubble = document.createElement('div');
890
+ bubble.className = 'bubble';
891
+ bubble.textContent = text;
892
+
893
+ wrap.appendChild(avatar);
894
+ wrap.appendChild(bubble);
895
+ messagesEl.appendChild(wrap);
896
+ messagesEl.scrollTop = messagesEl.scrollHeight;
897
+ return wrap;
898
+ }
899
+
900
+ function showTyping() {
901
+ const wrap = document.createElement('div');
902
+ wrap.className = 'message agent';
903
+ wrap.id = 'typing';
904
+
905
+ const avatar = document.createElement('div');
906
+ avatar.className = 'avatar';
907
+ avatar.textContent = 'AI';
908
+
909
+ const bubble = document.createElement('div');
910
+ bubble.className = 'bubble typing-indicator';
911
+ for (let i = 0; i < 3; i++) {
912
+ const dot = document.createElement('div');
913
+ dot.className = 'typing-dot';
914
+ bubble.appendChild(dot);
915
+ }
916
+
917
+ wrap.appendChild(avatar);
918
+ wrap.appendChild(bubble);
919
+ messagesEl.appendChild(wrap);
920
+ messagesEl.scrollTop = messagesEl.scrollHeight;
921
+ }
922
+
923
+ function removeTyping() {
924
+ const el = document.getElementById('typing');
925
+ if (el) el.remove();
926
+ }
927
+
928
+ let lastBrief = null;
929
+
930
+ function renderBrief(brief) {
931
+ lastBrief = brief;
932
+ const hpiLabels = [
933
+ ['onset', 'Onset'],
934
+ ['location', 'Location'],
935
+ ['duration', 'Duration'],
936
+ ['character', 'Character'],
937
+ ['severity', 'Severity'],
938
+ ['aggravating', 'Aggravating'],
939
+ ['relieving', 'Relieving'],
940
+ ];
941
+
942
+ let html = `
 
943
  <div class="brief-section">
944
  <div class="brief-section-title">Chief Complaint</div>
945
  <div class="cc-value">${escHtml(brief.chief_complaint)}</div>
 
949
  <div class="hpi-grid">
950
  `;
951
 
952
+ for (const [key, label] of hpiLabels) {
953
+ const val = brief.hpi[key] || 'Not specified';
954
+ const isMissing = !brief.hpi[key] || brief.hpi[key] === 'Not specified';
955
+ html += `
956
  <div class="hpi-row">
957
  <div class="hpi-key">${label}</div>
958
  <div class="hpi-val" style="${isMissing ? 'color:var(--text-muted);font-style:italic' : ''}">${escHtml(val)}</div>
959
  </div>
960
  `;
961
+ }
962
+
963
+ html += `</div></div>`;
964
+
965
+ if (brief.ros && Object.keys(brief.ros).length > 0) {
966
+ html += `<div class="brief-section"><div class="brief-section-title">Review of Systems</div>`;
967
+ for (const [system, findings] of Object.entries(brief.ros)) {
968
+ const label = system.charAt(0).toUpperCase() + system.slice(1);
969
+ html += `<div class="ros-system"><div class="ros-system-name">${escHtml(label)}</div><div class="ros-findings">`;
970
+ findings.forEach(f => {
971
+ const fl = f.toLowerCase();
972
+ const isNeg = fl.startsWith('no ') || fl.includes('none') || fl.includes('absent') || fl.includes('denied') || fl.includes('no swelling') || fl.includes('negative');
973
+ html += `<span class="finding-tag ${isNeg ? 'negative' : 'positive'}">${escHtml(f)}</span>`;
974
+ });
975
+ html += `</div></div>`;
976
+ }
977
+ html += `</div>`;
978
+ }
979
 
980
+ const ts = brief.generated_at ? new Date(brief.generated_at).toLocaleString() : '';
981
+ if (ts) html += `<div class="brief-timestamp">Generated ${ts}</div>`;
982
 
983
+ briefContent.innerHTML = html;
984
+ briefBadge.textContent = 'Complete';
985
+ briefBadge.className = 'brief-badge complete';
986
+ document.getElementById('copyBtn').style.display = 'flex';
987
+ document.getElementById('printBtn').style.display = 'flex';
988
+ }
989
+
990
+ function briefToPlainText(brief) {
991
+ const hpiLabels = ['onset', 'location', 'duration', 'character', 'severity', 'aggravating', 'relieving'];
992
+ let txt = `CLINICAL BRIEF\n${'='.repeat(40)}\n`;
993
+ txt += `Chief Complaint: ${brief.chief_complaint}\n\n`;
994
+ txt += `History of Present Illness\n${'-'.repeat(30)}\n`;
995
+ for (const key of hpiLabels) {
996
+ const val = brief.hpi[key] || 'Not specified';
997
+ txt += `${key.charAt(0).toUpperCase() + key.slice(1).padEnd(14)}: ${val}\n`;
998
  }
999
+ if (brief.ros && Object.keys(brief.ros).length > 0) {
1000
+ txt += `\nReview of Systems\n${'-'.repeat(30)}\n`;
1001
+ for (const [sys, findings] of Object.entries(brief.ros)) {
1002
+ txt += `${sys.charAt(0).toUpperCase() + sys.slice(1)}: ${findings.join(', ')}\n`;
1003
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1004
  }
1005
+ const ts = brief.generated_at ? new Date(brief.generated_at).toLocaleString() : '';
1006
+ if (ts) txt += `\nGenerated: ${ts}`;
1007
+ return txt;
1008
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
 
1010
+ function escHtml(str) {
1011
+ return String(str)
1012
+ .replace(/&/g, '&amp;')
1013
+ .replace(/</g, '&lt;')
1014
+ .replace(/>/g, '&gt;')
1015
+ .replace(/"/g, '&quot;');
1016
+ }
1017
 
1018
+ async function sendMessage(text) {
1019
+ if (!text.trim() || isWaiting || isComplete) return;
 
1020
 
1021
+ isWaiting = true;
1022
+ sendBtn.disabled = true;
1023
+ inputEl.disabled = true;
1024
+ setStatus('thinking');
1025
+
1026
+ addMessage('user', text);
1027
+ showTyping();
1028
+
1029
+ try {
1030
+ const res = await fetch('/chat', {
1031
+ method: 'POST',
1032
+ headers: { 'Content-Type': 'application/json' },
1033
+ body: JSON.stringify({ session_id: sessionId, message: text })
1034
+ });
1035
+
1036
+ if (!res.ok) throw new Error(`Server error ${res.status}`);
1037
+ const data = await res.json();
1038
+
1039
+ removeTyping();
1040
+ addMessage('agent', data.reply);
1041
+ updateProgress(data.state);
1042
+
1043
+ if (data.state === 'done' && data.brief) {
1044
+ renderBrief(data.brief);
1045
+ isComplete = true;
1046
+ setStatus('complete');
1047
+ inputEl.disabled = true;
1048
+ sendBtn.disabled = true;
1049
+ inputEl.placeholder = 'Intake complete. Start a new session to begin again.';
1050
+ } else {
1051
+ setStatus('ready');
1052
+ inputEl.disabled = false;
1053
+ inputEl.focus();
1054
+ sendBtn.disabled = false;
1055
+ }
1056
+
1057
+ } catch (err) {
1058
+ console.error('[Chat Error]', err.name, err.message);
1059
+ removeTyping();
1060
+ const isNetwork = err.name === 'TypeError' || err.message.includes('fetch') || err.message.includes('Failed');
1061
+ const errorMsg = isNetwork
1062
+ ? 'Network error — the tunnel may have dropped. Refresh and try again.'
1063
+ : `Error: ${err.message}`;
1064
+ addMessage('agent', errorMsg);
1065
  setStatus('ready');
1066
  inputEl.disabled = false;
 
1067
  sendBtn.disabled = false;
1068
  }
1069
 
1070
+ isWaiting = false;
 
 
 
 
 
1071
  }
1072
 
1073
+ async function initSession() {
1074
+ setStatus('thinking');
1075
+ showTyping();
1076
+
1077
+ try {
1078
+ const res = await fetch('/chat', {
1079
+ method: 'POST',
1080
+ headers: { 'Content-Type': 'application/json' },
1081
+ body: JSON.stringify({ session_id: sessionId, message: 'hello' })
1082
+ });
1083
 
1084
+ const data = await res.json();
1085
+ removeTyping();
1086
+ addMessage('agent', data.reply);
1087
+ updateProgress(data.state);
1088
+ setStatus('ready');
1089
+ sendBtn.disabled = false;
1090
+ inputEl.focus();
1091
+ } catch {
1092
+ removeTyping();
1093
+ addMessage('agent', 'Could not connect to the server. Please refresh.');
1094
+ setStatus('offline');
1095
+ }
1096
+ }
1097
 
1098
+ document.getElementById('copyBtn').addEventListener('click', () => {
1099
+ if (!lastBrief) return;
1100
+ const txt = briefToPlainText(lastBrief);
1101
+ navigator.clipboard.writeText(txt).then(() => {
1102
+ const btn = document.getElementById('copyBtn');
1103
+ btn.title = 'Copied!';
1104
+ setTimeout(() => { btn.title = 'Copy to clipboard'; }, 2000);
1105
  });
1106
+ });
1107
 
1108
+ document.getElementById('printBtn').addEventListener('click', () => {
1109
+ if (!lastBrief) return;
1110
+ const txt = briefToPlainText(lastBrief);
1111
+ const w = window.open('', '_blank');
1112
+ w.document.write(`<pre style="font-family:monospace;padding:24px;max-width:700px;margin:auto">${txt}</pre>`);
1113
+ w.document.close();
1114
+ w.print();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1115
  });
1116
+
1117
+ sendBtn.addEventListener('click', () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
  const text = inputEl.value.trim();
1119
  if (text) { inputEl.value = ''; autoResize(); sendMessage(text); }
1120
+ });
 
1121
 
1122
+ inputEl.addEventListener('keydown', e => {
1123
+ if (e.key === 'Enter' && !e.shiftKey) {
1124
+ e.preventDefault();
1125
+ const text = inputEl.value.trim();
1126
+ if (text) { inputEl.value = ''; autoResize(); sendMessage(text); }
1127
+ }
1128
+ });
1129
 
1130
+ inputEl.addEventListener('input', autoResize);
 
 
 
1131
 
1132
+ function autoResize() {
1133
+ inputEl.style.height = 'auto';
1134
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1135
+ }
1136
+
1137
+ resetBtn.addEventListener('click', () => {
1138
+ sessionId = 'session_' + Math.random().toString(36).slice(2, 11);
1139
+ messagesEl.innerHTML = '';
1140
+ briefContent.innerHTML = `
1141
  <div class="brief-empty">
1142
  <svg viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
1143
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 
1148
  </svg>
1149
  <p>The clinical brief will appear here once the intake is complete.</p>
1150
  </div>`;
1151
+ briefBadge.textContent = 'Pending';
1152
+ briefBadge.className = 'brief-badge';
1153
+ inputEl.value = '';
1154
+ inputEl.placeholder = 'Type your response...';
1155
+ inputEl.disabled = false;
1156
+ isComplete = false;
1157
+ isWaiting = false;
1158
+ updateProgress('intake');
1159
+ initSession();
1160
+ });
1161
+
1162
  updateProgress('intake');
1163
  initSession();
1164
+ </script>
 
 
 
 
1165
  </body>
1166
+
1167
  </html>