karthikmulugu08 commited on
Commit
efee69d
·
verified ·
1 Parent(s): daaa9eb

Fix: regex extraction on original text + noise filter

Browse files
Files changed (1) hide show
  1. safety_checker.py +128 -64
safety_checker.py CHANGED
@@ -1,8 +1,13 @@
1
  """
2
  ClinIQ — Proactive Drug Safety Checker.
3
 
4
- Single-call design: one prompt extracts medications/allergies AND identifies
5
- safety concerns simultaneously. This is faster and avoids context overflow.
 
 
 
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -11,20 +16,23 @@ from dataclasses import dataclass, field
11
  from typing import Any, List, Optional
12
 
13
 
14
- def _extract_json(text: str) -> Optional[Any]:
15
- """Robustly extract JSON from model output, fixing common issues."""
16
- # 1. Try direct parse first
17
- try:
18
- return json.loads(text.strip())
19
- except Exception:
20
- pass
 
 
 
 
 
21
 
22
- # 2. Extract outermost {...} block
23
  start = text.find('{')
24
  if start == -1:
25
- return None
26
-
27
- # Walk to find matching closing brace
28
  depth = 0
29
  for i, ch in enumerate(text[start:], start):
30
  if ch == '{':
@@ -32,34 +40,25 @@ def _extract_json(text: str) -> Optional[Any]:
32
  elif ch == '}':
33
  depth -= 1
34
  if depth == 0:
35
- candidate = text[start:i+1]
36
- try:
37
- return json.loads(candidate)
38
- except Exception:
39
- # Try fixing common issues
40
- fixed = _fix_json(candidate)
41
- try:
42
- return json.loads(fixed)
43
- except Exception:
44
- pass
45
- break
46
- return None
47
 
48
 
49
- def _fix_json(s: str) -> str:
50
- """Fix common JSON errors from LLM output."""
51
- # Remove trailing commas before } or ]
52
  s = re.sub(r',\s*([}\]])', r'\1', s)
53
- # Replace single quotes with double quotes (rough)
54
- s = re.sub(r"'([^']*)':", r'"\1":', s)
55
- # Remove control chars
56
  s = re.sub(r'[\x00-\x1f\x7f]', ' ', s)
57
- return s
 
 
58
 
59
 
 
 
60
  @dataclass
61
  class SafetyAlert:
62
- level: str # DANGER | WARNING | INFO
63
  title: str
64
  detail: str
65
  recommendation: str
@@ -67,57 +66,121 @@ class SafetyAlert:
67
 
68
  @dataclass
69
  class SafetyReport:
70
- medications: List[dict] = field(default_factory=list)
71
- allergies: List[dict] = field(default_factory=list)
72
- alerts: List[SafetyAlert] = field(default_factory=list)
73
- overall_safety: str = "SAFE"
74
- error: Optional[str] = None
75
 
76
  @property
77
  def has_danger(self) -> bool:
78
  return any(a.level == "DANGER" for a in self.alerts)
79
 
80
 
81
- # Single combined prompt — extract AND analyse in one call
82
- _COMBINED_PROMPT = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  "<|im_start|>system\n"
84
- "You are a clinical pharmacist AI. Output ONLY valid JSON.<|im_end|>\n"
85
  "<|im_start|>user\n"
86
- "Analyse documents and find:\n"
87
- "1. All medications listed\n"
88
- "2. All allergies listed\n"
89
- "3. DANGER/WARNING/INFO safety alerts (drug-allergy contraindications, interactions)\n\n"
90
- "Documents:\n{context}\n\n"
91
- 'Output ONLY JSON: {{"medications":[{{"name":"X","dose":"X","frequency":"X"}}],'
92
- '"allergies":[{{"substance":"X","reaction":"X","severity":"X"}}],'
93
- '"alerts":[{{"level":"DANGER","title":"X","detail":"X","recommendation":"X"}}],'
94
- '"overall_safety":"SAFE|REVIEW_NEEDED|URGENT"}}\n'
 
 
 
95
  "<|im_end|>\n<|im_start|>assistant\n"
96
  )
97
 
98
 
 
 
99
  def run_safety_check(context: str, call_model_fn) -> SafetyReport:
100
  report = SafetyReport()
101
  if not context.strip():
102
  return report
103
 
104
- # Strip non-ASCII chars (em-dashes etc. corrupt llama-server JSON parsing)
105
- import re
106
  context = re.sub(r'[^\x00-\x7F]', '-', context)
107
- # Keep well under the safe limit — 2500 chars of context
108
- trimmed = context[:2500]
109
 
110
- prompt = _COMBINED_PROMPT.format(context=trimmed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  try:
113
- raw = call_model_fn(prompt, 1000, False)
114
-
115
- data = _extract_json(raw)
116
- if data is None:
117
- report.error = f"Could not parse model output: {raw[:200]}"
118
- return report
119
- report.medications = data.get("medications", [])
120
- report.allergies = data.get("allergies", [])
121
  report.overall_safety = data.get("overall_safety", "REVIEW_NEEDED")
122
  report.alerts = [
123
  SafetyAlert(
@@ -127,9 +190,10 @@ def run_safety_check(context: str, call_model_fn) -> SafetyReport:
127
  recommendation = a.get("recommendation", ""),
128
  )
129
  for a in data.get("alerts", [])
 
130
  ]
131
  except Exception as e:
132
- report.error = str(e)
133
 
134
  return report
135
 
 
1
  """
2
  ClinIQ — Proactive Drug Safety Checker.
3
 
4
+ Design:
5
+ Step 1 — Regex extraction: pull MEDICATIONS and ALLERGIES sections directly
6
+ from the text (fast, 100% reliable, no LLM needed).
7
+ Step 2 — LLM safety reasoning: feed the clean extracted lists to the model
8
+ and ask only "are there any dangerous combinations?"
9
+
10
+ This avoids all JSON key confusion and context overflow issues.
11
  """
12
 
13
  from __future__ import annotations
 
16
  from typing import Any, List, Optional
17
 
18
 
19
+ # ── JSON helpers ───────────────────────────────────────────────────────────────
20
+
21
+ def _parse_json(text: str) -> Optional[Any]:
22
+ for candidate in [text.strip(), _brace_extract(text), _fix(_brace_extract(text))]:
23
+ if not candidate:
24
+ continue
25
+ try:
26
+ return json.loads(candidate)
27
+ except Exception:
28
+ pass
29
+ return None
30
+
31
 
32
+ def _brace_extract(text: str) -> str:
33
  start = text.find('{')
34
  if start == -1:
35
+ return ""
 
 
36
  depth = 0
37
  for i, ch in enumerate(text[start:], start):
38
  if ch == '{':
 
40
  elif ch == '}':
41
  depth -= 1
42
  if depth == 0:
43
+ return text[start:i + 1]
44
+ return text[start:]
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
+ def _fix(s: str) -> str:
48
+ if not s:
49
+ return s
50
  s = re.sub(r',\s*([}\]])', r'\1', s)
 
 
 
51
  s = re.sub(r'[\x00-\x1f\x7f]', ' ', s)
52
+ open_b = s.count('[') - s.count(']')
53
+ open_c = s.count('{') - s.count('}')
54
+ return s + ']' * max(0, open_b) + '}' * max(0, open_c)
55
 
56
 
57
+ # ── Data classes ───────────────────────────────────────────────────────────────
58
+
59
  @dataclass
60
  class SafetyAlert:
61
+ level: str
62
  title: str
63
  detail: str
64
  recommendation: str
 
66
 
67
  @dataclass
68
  class SafetyReport:
69
+ medications: List[str] = field(default_factory=list)
70
+ allergies: List[str] = field(default_factory=list)
71
+ alerts: List[SafetyAlert] = field(default_factory=list)
72
+ overall_safety: str = "SAFE"
73
+ error: Optional[str] = None
74
 
75
  @property
76
  def has_danger(self) -> bool:
77
  return any(a.level == "DANGER" for a in self.alerts)
78
 
79
 
80
+ # ── Regex extraction (Step 1 — no LLM) ────────────────────────────────────────
81
+
82
+ _SECTION_HEADER = re.compile(
83
+ r'^(PHYSICAL|HISTORY|HOSPITAL|PROCEDURE|ASSESSMENT|PLAN|IMPRESSION|FOLLOW|NOTE|REASON|'
84
+ r'DISCHARGE|PRIMARY|SECONDARY|VITAL|GENERAL|CARDIO|RESPIRATORY|EXAM|INVEST|PATIENT)',
85
+ re.IGNORECASE,
86
+ )
87
+
88
+
89
+ def _extract_lines(context: str) -> tuple[List[str], List[str]]:
90
+ """
91
+ Pull medication and allergy lines directly from document text using regex.
92
+ Finds ALL MEDICATIONS/ALLERGIES sections per document (not just the first).
93
+ """
94
+ meds: List[str] = []
95
+ allgs: List[str] = []
96
+ seen_m: set = set()
97
+ seen_a: set = set()
98
+
99
+ def _clean(line: str) -> str:
100
+ return re.sub(r'^[\d\.\-\*\s]+', '', line).strip()
101
+
102
+ def _is_noise(line: str) -> bool:
103
+ """Filter out section headers accidentally captured."""
104
+ return bool(_SECTION_HEADER.match(line)) or len(line) < 3
105
+
106
+ # Find all MEDICATIONS sections (there may be multiple per document)
107
+ for m in re.finditer(
108
+ r'MEDICATIONS?[^\n]*\n((?:[ \t]*[-\d*].*\n?){1,20})',
109
+ context, re.IGNORECASE
110
+ ):
111
+ for line in m.group(1).splitlines():
112
+ line = _clean(line)
113
+ key = line.lower()[:30]
114
+ if line and not _is_noise(line) and key not in seen_m:
115
+ seen_m.add(key)
116
+ meds.append(line)
117
+
118
+ # Find all ALLERGIES sections
119
+ for m in re.finditer(
120
+ r'ALLERGIES?[^\n]*\n((?:[ \t]*[-\d*].*\n?){1,10})',
121
+ context, re.IGNORECASE
122
+ ):
123
+ for line in m.group(1).splitlines():
124
+ line = _clean(line)
125
+ key = line.lower()[:30]
126
+ if line and not _is_noise(line) and key not in seen_a:
127
+ seen_a.add(key)
128
+ allgs.append(line)
129
+
130
+ return meds, allgs
131
+
132
+
133
+ # ── LLM safety prompt (Step 2) ─────────────────────────────────────────────────
134
+
135
+ _SAFETY_PROMPT = (
136
  "<|im_start|>system\n"
137
+ "You are a senior clinical pharmacist. Output ONLY valid JSON.<|im_end|>\n"
138
  "<|im_start|>user\n"
139
+ "A patient has these medications and allergies. Find ALL safety concerns.\n\n"
140
+ "MEDICATIONS:\n{meds}\n\n"
141
+ "ALLERGIES:\n{allgs}\n\n"
142
+ "Look for:\n"
143
+ "- Drug-allergy contraindications (same class as allergen)\n"
144
+ "- Dangerous drug-drug interactions\n"
145
+ "- Drug-disease contraindications (e.g. beta-blockers in asthma)\n\n"
146
+ "Output ONLY this JSON:\n"
147
+ '{{"alerts":[{{"level":"DANGER","title":"...","detail":"...","recommendation":"..."}}],'
148
+ '"overall_safety":"SAFE"}}\n'
149
+ "level must be DANGER, WARNING, or INFO. "
150
+ "overall_safety must be SAFE, REVIEW_NEEDED, or URGENT.\n"
151
  "<|im_end|>\n<|im_start|>assistant\n"
152
  )
153
 
154
 
155
+ # ── Main ───────────────────────────────────────────────────────────────────────
156
+
157
  def run_safety_check(context: str, call_model_fn) -> SafetyReport:
158
  report = SafetyReport()
159
  if not context.strip():
160
  return report
161
 
162
+ # Sanitize
 
163
  context = re.sub(r'[^\x00-\x7F]', '-', context)
 
 
164
 
165
+ # Step 1: regex extraction — fast, reliable, no LLM
166
+ meds, allgs = _extract_lines(context)
167
+
168
+ if not meds and not allgs:
169
+ report.overall_safety = "SAFE"
170
+ return report
171
+
172
+ report.medications = [{"name": m} for m in meds]
173
+ report.allergies = [{"substance": a} for a in allgs]
174
+
175
+ # Step 2: LLM safety reasoning on clean lists
176
+ meds_text = "\n".join(f"- {m}" for m in meds[:15])
177
+ allgs_text = "\n".join(f"- {a}" for a in allgs[:10])
178
+
179
+ prompt = _SAFETY_PROMPT.format(meds=meds_text, allgs=allgs_text)
180
 
181
  try:
182
+ raw = call_model_fn(prompt, 600, False)
183
+ data = _parse_json(raw) or {}
 
 
 
 
 
 
184
  report.overall_safety = data.get("overall_safety", "REVIEW_NEEDED")
185
  report.alerts = [
186
  SafetyAlert(
 
190
  recommendation = a.get("recommendation", ""),
191
  )
192
  for a in data.get("alerts", [])
193
+ if isinstance(a, dict) and a.get("title")
194
  ]
195
  except Exception as e:
196
+ report.error = f"Safety analysis error: {e}"
197
 
198
  return report
199