priyansh-saxena1 commited on
Commit
808ef75
·
1 Parent(s): e7ee8c9

fix: imporve responses flow

Browse files
Files changed (3) hide show
  1. app/graph.py +108 -28
  2. app/static/index.html +94 -11
  3. tests/test_e2e.py +63 -21
app/graph.py CHANGED
@@ -2,7 +2,7 @@ from typing import Optional, TypedDict, Annotated
2
  from langgraph.graph import StateGraph, START, END
3
  from langgraph.checkpoint.memory import MemorySaver
4
  import os
5
- from app.llm import get_llm
6
 
7
  _MOCK = lambda: os.environ.get("MOCK_LLM", "true").lower() == "true"
8
 
@@ -18,11 +18,11 @@ Rules:
18
 
19
 
20
  def _ask(prompt: str) -> str:
 
21
  llm = get_llm()
22
  try:
23
  return llm.ask(prompt, system=SYSTEM_PROMPT)
24
  except TypeError:
25
- # fallback if system param not supported
26
  return llm.ask(prompt)
27
 
28
 
@@ -46,14 +46,15 @@ class IntakeState(TypedDict):
46
 
47
  HPI_FIELDS = ["onset", "location", "duration", "character", "severity", "aggravating", "relieving"]
48
 
 
49
  HPI_QUESTIONS = {
50
- "onset": "When did your symptoms first start?",
51
- "location": "Where exactly do you feel the pain or discomfort?",
52
- "duration": "How long does each episode last? Is it constant or intermittent?",
53
- "character": "Can you describe what the pain feels like?",
54
- "severity": "On a scale of 1 to 10, how severe is your pain?",
55
- "aggravating": "What makes your symptoms worse?",
56
- "relieving": "What helps relieve your symptoms?"
57
  }
58
 
59
  HPI_FIELD_CONTEXT = {
@@ -81,37 +82,74 @@ CC_KEYWORDS_TO_ROS = {
81
 
82
  DEFAULT_ROS = ["constitutional", "cardiac", "respiratory"]
83
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  def get_relevant_ros_systems(cc: str) -> list[str]:
86
  cc_lower = cc.lower()
 
87
  for keyword, systems in CC_KEYWORDS_TO_ROS.items():
88
  if keyword in cc_lower:
89
- return systems
90
- return DEFAULT_ROS
 
 
91
 
92
 
93
- import re
 
 
 
 
 
94
 
95
 
96
  def extract_hpi_value(answer: str, field: str) -> str:
97
  answer = answer.strip()
98
  if field == "severity":
99
- match = re.search(r'(\d{1,2})\s*(?:out of|/)?\s*10', answer, re.IGNORECASE)
100
  if match:
101
  return f"{match.group(1)}/10"
 
 
 
 
102
  return answer
103
 
104
 
105
  def _is_vague_answer(answer: str) -> bool:
106
- vague_phrases = ["i don't know", "not sure", "dont know", "idk", "maybe", "i guess"]
107
- answer_lower = answer.lower()
108
- return any(phrase in answer_lower for phrase in vague_phrases)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  # -------------------- NODES --------------------
112
 
113
  GREETINGS = {"hello", "hi", "hey", "start", "begin", "ok", "okay", "yes", "sure"}
114
 
 
115
  def intake_node(state: IntakeState) -> dict:
116
  messages = state.get("messages", [])
117
  last_idx = state.get("last_processed_message_index", 0)
@@ -139,7 +177,7 @@ def intake_node(state: IntakeState) -> dict:
139
 
140
  cc = content
141
  if _MOCK():
142
- reply = f"I understand you're experiencing {cc}. Let me ask a few questions."
143
  else:
144
  reply = _ask(
145
  f"Patient's chief complaint is: '{cc}'. "
@@ -178,7 +216,7 @@ def hpi_node(state: IntakeState) -> dict:
178
 
179
  if next_field is None:
180
  return {
181
- "messages": [{"role": "assistant", "content": "Now Ill ask about other symptoms."}],
182
  "current_node": "ros",
183
  "last_processed_message_index": len(messages),
184
  "vague_retry_field": None,
@@ -196,7 +234,7 @@ def hpi_node(state: IntakeState) -> dict:
196
  field_context = HPI_FIELD_CONTEXT[next_field]
197
 
198
  if _MOCK():
199
- reply = f"Please be more specific about {field_context}."
200
  else:
201
  reply = _ask(
202
  f"Patient response about {field_context} was vague. "
@@ -217,7 +255,7 @@ def hpi_node(state: IntakeState) -> dict:
217
  next_field = HPI_FIELDS[next_idx + 1]
218
 
219
  if _MOCK():
220
- reply = HPI_QUESTIONS[next_field]
221
  else:
222
  reply = _ask(
223
  f"Complaint: {cc}. Known info: {hpi}. "
@@ -233,7 +271,7 @@ def hpi_node(state: IntakeState) -> dict:
233
  }
234
 
235
  return {
236
- "messages": [{"role": "assistant", "content": "Now Ill ask about other symptoms."}],
237
  "hpi": hpi,
238
  "current_node": "ros",
239
  "last_processed_message_index": len(messages),
@@ -241,7 +279,7 @@ def hpi_node(state: IntakeState) -> dict:
241
  }
242
 
243
  if _MOCK():
244
- reply = HPI_QUESTIONS[next_field]
245
  else:
246
  reply = _ask(
247
  f"Complaint: {cc}. Known info: {hpi}. "
@@ -268,7 +306,7 @@ def ros_node(state: IntakeState) -> dict:
268
 
269
  if current_idx >= len(ros_systems):
270
  return {
271
- "messages": [{"role": "assistant", "content": "I have enough information."}],
272
  "current_node": "brief_generator",
273
  "last_processed_message_index": len(messages),
274
  }
@@ -277,12 +315,12 @@ def ros_node(state: IntakeState) -> dict:
277
 
278
  if has_new_user_msg and pending:
279
  answer = messages[-1]["content"]
280
- ros[pending] = [f.strip() for f in answer.split(",")]
281
 
282
  next_system = ros_systems[current_idx]
283
 
284
  if _MOCK():
285
- reply = f"Any {next_system} symptoms? Mention present and absent."
286
  else:
287
  reply = _ask(
288
  f"Ask about {next_system} symptoms. One short question. "
@@ -306,18 +344,60 @@ from datetime import datetime, timezone
306
  from app.schemas import HPI as HPIModel, ClinicalBrief as ClinicalBriefModel
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  def brief_generator_node(state: IntakeState) -> dict:
310
- hpi_obj = HPIModel(**{f: state.get("hpi", {}).get(f) or "not specified" for f in HPI_FIELDS})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
  brief = ClinicalBriefModel(
313
  chief_complaint=state.get("chief_complaint", ""),
314
  hpi=hpi_obj,
315
- ros=state.get("ros", {}),
316
  generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
317
  )
318
 
319
  return {
320
- "messages": [{"role": "assistant", "content": "Intake complete. Here is your summary."}],
321
  "current_node": "done",
322
  "clinical_brief": brief.model_dump(),
323
  }
 
2
  from langgraph.graph import StateGraph, START, END
3
  from langgraph.checkpoint.memory import MemorySaver
4
  import os
5
+ import re
6
 
7
  _MOCK = lambda: os.environ.get("MOCK_LLM", "true").lower() == "true"
8
 
 
18
 
19
 
20
  def _ask(prompt: str) -> str:
21
+ from app.llm import get_llm
22
  llm = get_llm()
23
  try:
24
  return llm.ask(prompt, system=SYSTEM_PROMPT)
25
  except TypeError:
 
26
  return llm.ask(prompt)
27
 
28
 
 
46
 
47
  HPI_FIELDS = ["onset", "location", "duration", "character", "severity", "aggravating", "relieving"]
48
 
49
+ # Questions are templated — {cc} will be replaced with chief complaint
50
  HPI_QUESTIONS = {
51
+ "onset": "When did {cc} start?",
52
+ "location": "Where exactly do you feel {cc}?",
53
+ "duration": "Is {cc} constant or does it come and go? How long does each episode last?",
54
+ "character": "How would you describe {cc} sharp, dull, pressure, burning?",
55
+ "severity": "On a 110 scale, how severe is your {cc} right now?",
56
+ "aggravating": "Does anything make {cc} worse, like activity or certain foods?",
57
+ "relieving": "What helps relieve your {cc}?"
58
  }
59
 
60
  HPI_FIELD_CONTEXT = {
 
82
 
83
  DEFAULT_ROS = ["constitutional", "cardiac", "respiratory"]
84
 
85
+ ROS_SYSTEM_QUESTIONS = {
86
+ "cardiac": "Any palpitations, fluttering, or swelling in your legs or ankles?",
87
+ "respiratory": "Any shortness of breath, wheezing, or cough?",
88
+ "gi": "Any nausea, vomiting, heartburn, or abdominal pain?",
89
+ "neuro": "Any headaches, dizziness, numbness, or vision changes?",
90
+ "ent": "Any ear pain, sore throat, or sinus pressure?",
91
+ "vision": "Any blurry vision, double vision, or eye pain?",
92
+ "constitutional": "Any fever, chills, unexplained weight loss, or fatigue?",
93
+ }
94
+
95
 
96
  def get_relevant_ros_systems(cc: str) -> list[str]:
97
  cc_lower = cc.lower()
98
+ seen = []
99
  for keyword, systems in CC_KEYWORDS_TO_ROS.items():
100
  if keyword in cc_lower:
101
+ for s in systems:
102
+ if s not in seen:
103
+ seen.append(s)
104
+ return seen if seen else DEFAULT_ROS
105
 
106
 
107
+ def _fmt_question(field: str, cc: str) -> str:
108
+ """Format an HPI question, injecting the chief complaint naturally."""
109
+ q = HPI_QUESTIONS[field]
110
+ cc_short = cc.split()[0:4] # first few words of complaint
111
+ cc_str = " ".join(cc_short).lower() if cc_short else "this"
112
+ return q.format(cc=cc_str)
113
 
114
 
115
  def extract_hpi_value(answer: str, field: str) -> str:
116
  answer = answer.strip()
117
  if field == "severity":
118
+ match = re.search(r'(\d{1,2})\s*(?:out of|/|over)?\s*10', answer, re.IGNORECASE)
119
  if match:
120
  return f"{match.group(1)}/10"
121
+ # also handle bare numbers 1-10
122
+ match2 = re.search(r'\b([1-9]|10)\b', answer)
123
+ if match2:
124
+ return f"{match2.group(1)}/10"
125
  return answer
126
 
127
 
128
  def _is_vague_answer(answer: str) -> bool:
129
+ vague_phrases = ["i don't know", "not sure", "dont know", "idk", "maybe", "i guess", "not really", "not sure"]
130
+ return any(phrase in answer.lower() for phrase in vague_phrases)
131
+
132
+
133
+ def _parse_ros_answer(answer: str) -> list[str]:
134
+ """
135
+ Parse a free-text ROS answer into a list of individual findings.
136
+ Handles comma-separated, 'and'-joined, and 'no X' style negative findings.
137
+ """
138
+ # Split on commas, semicolons, and 'and'
139
+ parts = re.split(r'[,;]|\band\b', answer, flags=re.IGNORECASE)
140
+ findings = []
141
+ for part in parts:
142
+ part = part.strip()
143
+ if part:
144
+ findings.append(part)
145
+ return findings if findings else [answer.strip()]
146
 
147
 
148
  # -------------------- NODES --------------------
149
 
150
  GREETINGS = {"hello", "hi", "hey", "start", "begin", "ok", "okay", "yes", "sure"}
151
 
152
+
153
  def intake_node(state: IntakeState) -> dict:
154
  messages = state.get("messages", [])
155
  last_idx = state.get("last_processed_message_index", 0)
 
177
 
178
  cc = content
179
  if _MOCK():
180
+ reply = f"Got it {cc}. I'll ask a few quick questions to document your visit."
181
  else:
182
  reply = _ask(
183
  f"Patient's chief complaint is: '{cc}'. "
 
216
 
217
  if next_field is None:
218
  return {
219
+ "messages": [{"role": "assistant", "content": "Thank you. Now I'll ask about a few other symptoms."}],
220
  "current_node": "ros",
221
  "last_processed_message_index": len(messages),
222
  "vague_retry_field": None,
 
234
  field_context = HPI_FIELD_CONTEXT[next_field]
235
 
236
  if _MOCK():
237
+ reply = f"Could you be more specific? I need to know {field_context}."
238
  else:
239
  reply = _ask(
240
  f"Patient response about {field_context} was vague. "
 
255
  next_field = HPI_FIELDS[next_idx + 1]
256
 
257
  if _MOCK():
258
+ reply = _fmt_question(next_field, cc)
259
  else:
260
  reply = _ask(
261
  f"Complaint: {cc}. Known info: {hpi}. "
 
271
  }
272
 
273
  return {
274
+ "messages": [{"role": "assistant", "content": "Thank you. Now I'll ask about a few other symptoms."}],
275
  "hpi": hpi,
276
  "current_node": "ros",
277
  "last_processed_message_index": len(messages),
 
279
  }
280
 
281
  if _MOCK():
282
+ reply = _fmt_question(next_field, cc)
283
  else:
284
  reply = _ask(
285
  f"Complaint: {cc}. Known info: {hpi}. "
 
306
 
307
  if current_idx >= len(ros_systems):
308
  return {
309
+ "messages": [{"role": "assistant", "content": "Thank you — I have everything I need."}],
310
  "current_node": "brief_generator",
311
  "last_processed_message_index": len(messages),
312
  }
 
315
 
316
  if has_new_user_msg and pending:
317
  answer = messages[-1]["content"]
318
+ ros[pending] = _parse_ros_answer(answer)
319
 
320
  next_system = ros_systems[current_idx]
321
 
322
  if _MOCK():
323
+ reply = ROS_SYSTEM_QUESTIONS.get(next_system, f"Any {next_system} symptoms? Mention present and absent.")
324
  else:
325
  reply = _ask(
326
  f"Ask about {next_system} symptoms. One short question. "
 
344
  from app.schemas import HPI as HPIModel, ClinicalBrief as ClinicalBriefModel
345
 
346
 
347
+ def _clean_hpi_value(field: str, raw: str) -> str:
348
+ """
349
+ Convert a raw patient answer into a clean clinical phrase.
350
+ Removes filler words and informal language.
351
+ """
352
+ raw = raw.strip()
353
+
354
+ # Remove filler starters
355
+ fillers = [
356
+ r'^(yeah|yes|no|well|so|like|um|uh|i mean|i guess),?\s*',
357
+ r'^(it\'?s?\s+)',
358
+ r'^(the\s+)',
359
+ ]
360
+ for pattern in fillers:
361
+ raw = re.sub(pattern, '', raw, flags=re.IGNORECASE).strip()
362
+
363
+ if not raw:
364
+ return "not specified"
365
+
366
+ # Capitalize first letter
367
+ return raw[0].upper() + raw[1:]
368
+
369
+
370
  def brief_generator_node(state: IntakeState) -> dict:
371
+ raw_hpi = state.get("hpi", {})
372
+
373
+ # Clean each HPI field
374
+ cleaned_hpi = {f: _clean_hpi_value(f, raw_hpi.get(f) or "not specified") for f in HPI_FIELDS}
375
+
376
+ hpi_obj = HPIModel(**cleaned_hpi)
377
+
378
+ # Clean ROS — ensure each system has a proper list of findings
379
+ raw_ros = state.get("ros", {})
380
+ cleaned_ros: dict[str, list[str]] = {}
381
+ for system, findings in raw_ros.items():
382
+ clean_findings = []
383
+ for f in findings:
384
+ f = f.strip()
385
+ if f:
386
+ # Capitalize
387
+ f = f[0].upper() + f[1:]
388
+ clean_findings.append(f)
389
+ if clean_findings:
390
+ cleaned_ros[system] = clean_findings
391
 
392
  brief = ClinicalBriefModel(
393
  chief_complaint=state.get("chief_complaint", ""),
394
  hpi=hpi_obj,
395
+ ros=cleaned_ros,
396
  generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
397
  )
398
 
399
  return {
400
+ "messages": [{"role": "assistant", "content": "Intake complete. Your clinical summary is ready."}],
401
  "current_node": "done",
402
  "clinical_brief": brief.model_dump(),
403
  }
app/static/index.html CHANGED
@@ -406,11 +406,12 @@
406
  }
407
 
408
  .brief-header {
409
- padding: 16px 20px;
410
  border-bottom: 1px solid var(--border);
411
  display: flex;
412
  align-items: center;
413
  justify-content: space-between;
 
414
  }
415
 
416
  .brief-header h2 {
@@ -421,6 +422,12 @@
421
  text-transform: uppercase;
422
  }
423
 
 
 
 
 
 
 
424
  .brief-badge {
425
  font-size: 11px;
426
  font-weight: 600;
@@ -435,6 +442,23 @@
435
  color: var(--success);
436
  }
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  .brief-content {
439
  flex: 1;
440
  overflow-y: auto;
@@ -664,7 +688,15 @@
664
  <div class="brief-panel">
665
  <div class="brief-header">
666
  <h2>Clinical Brief</h2>
667
- <span class="brief-badge" id="briefBadge">Pending</span>
 
 
 
 
 
 
 
 
668
  </div>
669
  <div class="brief-content" id="briefContent">
670
  <div class="brief-empty">
@@ -769,12 +801,19 @@
769
  if (el) el.remove();
770
  }
771
 
 
 
772
  function renderBrief(brief) {
773
- const hpiLabels = {
774
- onset: 'Onset', location: 'Location', duration: 'Duration',
775
- character: 'Character', severity: 'Severity',
776
- aggravating: 'Aggravating', relieving: 'Relieving'
777
- };
 
 
 
 
 
778
 
779
  let html = `
780
  <div class="brief-section">
@@ -786,12 +825,13 @@
786
  <div class="hpi-grid">
787
  `;
788
 
789
- for (const [key, label] of Object.entries(hpiLabels)) {
790
  const val = brief.hpi[key] || 'Not specified';
 
791
  html += `
792
  <div class="hpi-row">
793
  <div class="hpi-key">${label}</div>
794
- <div class="hpi-val">${escHtml(val)}</div>
795
  </div>
796
  `;
797
  }
@@ -801,9 +841,11 @@
801
  if (brief.ros && Object.keys(brief.ros).length > 0) {
802
  html += `<div class="brief-section"><div class="brief-section-title">Review of Systems</div>`;
803
  for (const [system, findings] of Object.entries(brief.ros)) {
804
- html += `<div class="ros-system"><div class="ros-system-name">${escHtml(system)}</div><div class="ros-findings">`;
 
805
  findings.forEach(f => {
806
- const isNeg = f.toLowerCase().startsWith('no ') || f.toLowerCase().includes('none');
 
807
  html += `<span class="finding-tag ${isNeg ? 'negative' : 'positive'}">${escHtml(f)}</span>`;
808
  });
809
  html += `</div></div>`;
@@ -817,6 +859,28 @@
817
  briefContent.innerHTML = html;
818
  briefBadge.textContent = 'Complete';
819
  briefBadge.className = 'brief-badge complete';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  }
821
 
822
  function escHtml(str) {
@@ -902,6 +966,25 @@
902
  }
903
  }
904
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
  sendBtn.addEventListener('click', () => {
906
  const text = inputEl.value.trim();
907
  if (text) { inputEl.value = ''; autoResize(); sendMessage(text); }
 
406
  }
407
 
408
  .brief-header {
409
+ padding: 12px 20px;
410
  border-bottom: 1px solid var(--border);
411
  display: flex;
412
  align-items: center;
413
  justify-content: space-between;
414
+ gap: 8px;
415
  }
416
 
417
  .brief-header h2 {
 
422
  text-transform: uppercase;
423
  }
424
 
425
+ .brief-header-right {
426
+ display: flex;
427
+ align-items: center;
428
+ gap: 6px;
429
+ }
430
+
431
  .brief-badge {
432
  font-size: 11px;
433
  font-weight: 600;
 
442
  color: var(--success);
443
  }
444
 
445
+ .icon-btn {
446
+ width: 28px;
447
+ height: 28px;
448
+ border: 1px solid var(--border);
449
+ border-radius: var(--radius-sm);
450
+ background: var(--surface);
451
+ color: var(--text-secondary);
452
+ cursor: pointer;
453
+ display: flex;
454
+ align-items: center;
455
+ justify-content: center;
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;
464
  overflow-y: auto;
 
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">
 
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">
 
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
  }
 
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>`;
 
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) {
 
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); }
tests/test_e2e.py CHANGED
@@ -35,17 +35,17 @@ async def test_full_intake_flow(client):
35
  assert data["state"] in ["intake", "hpi"]
36
 
37
  responses = [
38
- "I have chest pain since this morning",
39
- "It started about 3 hours ago",
40
- "In the center of my chest",
41
- "It has been constant",
42
- "It feels like pressure",
43
- "About a 7 out of 10",
44
- "It gets worse when I walk",
45
- "Resting helps a little",
46
- "palpitations present, no syncope",
47
- "mild shortness of breath, no cough",
48
- "done",
49
  ]
50
 
51
  final_data = None
@@ -67,21 +67,24 @@ async def test_full_intake_flow(client):
67
 
68
  @pytest.mark.asyncio(loop_scope="function")
69
  async def test_hpi_reprompt(client):
 
70
  session_id = "test_vague"
71
 
72
  await client.post("/chat", json={"session_id": session_id, "message": "hello"})
73
  await client.post("/chat", json={"session_id": session_id, "message": "I have chest pain"})
74
 
75
- response = await client.post("/chat", json={"session_id": session_id, "message": "When did it start?"})
76
-
77
  vague_response = await client.post("/chat", json={"session_id": session_id, "message": "I don't know"})
78
  assert vague_response.status_code == 200
79
  data = vague_response.json()
80
- assert "specific" in data["reply"].lower() or "when" in data["reply"].lower()
 
 
81
 
82
 
83
  @pytest.mark.asyncio(loop_scope="function")
84
  async def test_ros_scoping(client):
 
85
  session_id = "test_chest_pain"
86
 
87
  await client.post("/chat", json={"session_id": session_id, "message": "hello"})
@@ -100,12 +103,12 @@ async def test_ros_scoping(client):
100
  for resp in hpi_responses:
101
  await client.post("/chat", json={"session_id": session_id, "message": resp})
102
 
103
- ros_response = await client.post("/chat", json={"session_id": session_id, "message": "ready for ROS"})
104
- ros_data = ros_response.json()
105
-
106
- await client.post("/chat", json={"session_id": session_id, "message": "cardiac:palpitations,no syncope|respiratory:shortness of breath,no cough"})
107
-
108
- final_response = await client.post("/chat", json={"session_id": session_id, "message": "done"})
109
  final_data = final_response.json()
110
 
111
  if final_data.get("brief"):
@@ -115,6 +118,7 @@ async def test_ros_scoping(client):
115
 
116
  @pytest.mark.asyncio(loop_scope="function")
117
  async def test_brief_structure(client):
 
118
  session_id = "test_brief"
119
 
120
  messages = [
@@ -127,9 +131,12 @@ async def test_brief_structure(client):
127
  "7 out of 10",
128
  "Walking worsens it",
129
  "Resting helps",
130
- "cardiac:palpitations,no syncope|respiratory:shortness of breath,no cough",
 
 
131
  ]
132
 
 
133
  for msg in messages:
134
  response = await client.post("/chat", json={"session_id": session_id, "message": msg})
135
  assert response.status_code == 200
@@ -149,3 +156,38 @@ async def test_brief_structure(client):
149
  assert validated.hpi.severity
150
  assert validated.hpi.aggravating
151
  assert validated.hpi.relieving
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  assert data["state"] in ["intake", "hpi"]
36
 
37
  responses = [
38
+ "I have chest pain since this morning", # CC (intake)
39
+ "It started about 3 hours ago", # onset
40
+ "In the center of my chest", # location
41
+ "It has been constant for an hour", # duration
42
+ "It feels like pressure", # character
43
+ "About a 7 out of 10", # severity
44
+ "It gets worse when I walk", # aggravating
45
+ "Resting helps a little", # relieving
46
+ "palpitations present, no syncope", # cardiac ROS
47
+ "mild shortness of breath, no cough", # respiratory ROS
48
+ "no nausea or vomiting", # gi ROS
49
  ]
50
 
51
  final_data = None
 
67
 
68
  @pytest.mark.asyncio(loop_scope="function")
69
  async def test_hpi_reprompt(client):
70
+ """Vague answers (I don't know) should trigger a re-prompt."""
71
  session_id = "test_vague"
72
 
73
  await client.post("/chat", json={"session_id": session_id, "message": "hello"})
74
  await client.post("/chat", json={"session_id": session_id, "message": "I have chest pain"})
75
 
76
+ # First HPI question is about onset
 
77
  vague_response = await client.post("/chat", json={"session_id": session_id, "message": "I don't know"})
78
  assert vague_response.status_code == 200
79
  data = vague_response.json()
80
+ reply_lower = data["reply"].lower()
81
+ # Should ask again — should mention specificity or the field context
82
+ assert "specific" in reply_lower or "when" in reply_lower or "start" in reply_lower
83
 
84
 
85
  @pytest.mark.asyncio(loop_scope="function")
86
  async def test_ros_scoping(client):
87
+ """For chest pain, ROS should include cardiac and respiratory systems."""
88
  session_id = "test_chest_pain"
89
 
90
  await client.post("/chat", json={"session_id": session_id, "message": "hello"})
 
103
  for resp in hpi_responses:
104
  await client.post("/chat", json={"session_id": session_id, "message": resp})
105
 
106
+ # Now in ROS answer cardiac system
107
+ await client.post("/chat", json={"session_id": session_id, "message": "palpitations, no syncope"})
108
+ # respiratory
109
+ await client.post("/chat", json={"session_id": session_id, "message": "mild shortness of breath, no cough"})
110
+ # gi
111
+ final_response = await client.post("/chat", json={"session_id": session_id, "message": "no nausea"})
112
  final_data = final_response.json()
113
 
114
  if final_data.get("brief"):
 
118
 
119
  @pytest.mark.asyncio(loop_scope="function")
120
  async def test_brief_structure(client):
121
+ """Brief should have all 7 HPI fields, chief_complaint, ros, and generated_at."""
122
  session_id = "test_brief"
123
 
124
  messages = [
 
131
  "7 out of 10",
132
  "Walking worsens it",
133
  "Resting helps",
134
+ "palpitations present, no syncope",
135
+ "shortness of breath, no cough",
136
+ "no nausea or vomiting",
137
  ]
138
 
139
+ response = None
140
  for msg in messages:
141
  response = await client.post("/chat", json={"session_id": session_id, "message": msg})
142
  assert response.status_code == 200
 
156
  assert validated.hpi.severity
157
  assert validated.hpi.aggravating
158
  assert validated.hpi.relieving
159
+ assert validated.generated_at
160
+
161
+
162
+ @pytest.mark.asyncio(loop_scope="function")
163
+ async def test_brief_cleaning(client):
164
+ """Brief generator should strip informal filler words from patient answers."""
165
+ session_id = "test_cleaning"
166
+
167
+ messages = [
168
+ "hello",
169
+ "I have chest pain",
170
+ "yeah like since yesterday evening", # filler "yeah like"
171
+ "like in my chest area", # filler "like"
172
+ "Constant",
173
+ "um tight and squeezing", # filler "um"
174
+ "7 out of 10",
175
+ "yeah walking makes it worse", # filler "yeah"
176
+ "Resting helps",
177
+ "palpitations, no syncope",
178
+ "mild shortness of breath",
179
+ "no nausea",
180
+ ]
181
+
182
+ response = None
183
+ for msg in messages:
184
+ response = await client.post("/chat", json={"session_id": session_id, "message": msg})
185
+ assert response.status_code == 200
186
+
187
+ final_data = response.json()
188
+ if final_data.get("brief"):
189
+ hpi = final_data["brief"]["hpi"]
190
+ # "yeah like since yesterday evening" → should not start with "yeah"
191
+ if hpi.get("onset"):
192
+ assert not hpi["onset"].lower().startswith("yeah"), \
193
+ f"Filler not cleaned from onset: {hpi['onset']}"