KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
e15864e
·
unverified ·
1 Parent(s): 22195c1

feat: structured eligibility assessment with per-criterion verdicts (#14)

Browse files

- Add agents/eligibility.py: two-step pipeline — bulk LLM parse of
eligibility text into structured constraints, then deterministic
code evaluation (Path A) for known patient fields; research LLM
handles unclear criteria inline (Path B, no extra call)
- Add data/criterion_keys.json: canonical key → patient/platform
field mapping; extend without touching agent code
- Add data/tools/{parse_criteria,parse_criteria_bulk,assess_eligibility}.json
- Extend models.py: CriterionVerdict, ParsedConstraint, EligibilityCriterion,
CriterionAssessment, TrialEligibilityReport
- trials_api.py: extract full fields (sex, healthy_volunteers, std_ages,
study_type, enrollment, conditions, keywords, interventions); remove
1000/500-char truncations; pageSize 200 → 1000 (fewer round trips)
- agents/intake.py: accept onset_date/diagnosis_date (YYYY-MM) and
compute months in Python — fixes LLM arithmetic drift on current date
- agents/research.py: bulk parse + strip after each search; yield
("status", msg) events for live progress (per study type)
- app.py: handle status events; colorize ✓/✗/! verdicts with inline
HTML spans (green/red/yellow); sanitize_html=False on chatbot
- prompts.py: updated research format to render eligibility checklist
from parsed_criteria + deterministic_verdicts
- design/eligibility-assessment.md: full design doc

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

CLAUDE.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Beacon — Architecture Guide for Claude Code
2
+
3
+ ## Layer Responsibilities
4
+
5
+ ### `app.py` — UI wiring only
6
+
7
+ `app.py` owns **Gradio state and event wiring**. Nothing else.
8
+
9
+ Allowed:
10
+ - `gr.State`, `gr.Chatbot`, `gr.Textbox`, and other Gradio components
11
+ - Event handlers that call into `agents.*` and yield streaming updates
12
+ - Language switching, layout, and display formatting
13
+
14
+ Not allowed:
15
+ - Direct calls to `trials_api`, `prompts`, `tools`, `models`, or `config`
16
+ - Business rules, data transformations, or domain logic
17
+ - Any knowledge of what a `PatientProfile` field means
18
+
19
+ If you find yourself importing a non-agent module into `app.py`, that logic belongs in an agent or a business-logic module instead.
20
+
21
+ ---
22
+
23
+ ### `clinical_trials_guru.py` — business wiring only
24
+
25
+ `clinical_trials_guru.py` owns **orchestration and re-exports**. It is the single public facade for CLI callers and external scripts.
26
+
27
+ Allowed:
28
+ - `BeaconState` TypedDict and LangGraph graph construction (`_build_graph`)
29
+ - CLI entry point (`guru_main`)
30
+ - Re-exporting symbols from internal modules so callers import one place
31
+
32
+ Not allowed:
33
+ - UI logic, Gradio imports, or display formatting
34
+ - New business logic that belongs in `agents/`, `models.py`, `tools.py`, etc.
35
+ - Duplicating functions that already live in `agents/`
36
+
37
+ When you add a new public symbol (function, class, constant), expose it here so the rest of the codebase has one stable import surface.
38
+
39
+ ---
40
+
41
+ ### `agents/` — orchestration with streaming support
42
+
43
+ Each agent module (`intake.py`, `research.py`) provides two variants:
44
+ - A **blocking** function (`run_*`) for CLI / LangGraph nodes
45
+ - A **streaming generator** (`stream_*`) for the Gradio web UI
46
+
47
+ Keep agents free of Gradio types. They yield plain strings and return domain objects (`PatientProfile`, `str`).
48
+
49
+ ---
50
+
51
+ ## Design Principle: Load Only Necessary Context
52
+
53
+ **Never inject all disease benchmarks into context when only one disease is relevant.**
54
+
55
+ ### Rule
56
+
57
+ Context for a disease-specific benchmark set must not be loaded until the disease is identified. Once the patient's disease is known (via the `identify_disease` tool), load only that disease's profile and pass it to the LLM. Do not pre-load or concatenate profiles for other diseases.
58
+
59
+ ### Rationale
60
+
61
+ - Each disease JSON carries benchmark definitions, ranges, and guidance text. Loading all 8+ profiles wastes tokens and dilutes the system prompt.
62
+ - The `identify_disease` tool call is the earliest reliable signal of disease identity. Everything after that point can be scoped to one profile.
63
+ - This keeps context proportional to the task and reduces the risk of cross-disease confusion in the LLM.
64
+
65
+ ### How it works today
66
+
67
+ `prompts.py` eagerly reads all disease files into `_ALL_DISEASES` at module import, but `lookup_disease_profile(standardized_name)` returns **only one profile** — the matched disease. The intake agent calls this function after the `identify_disease` tool fires and injects only the returned benchmarks into the conversation.
68
+
69
+ ### How to stay compliant
70
+
71
+ - After `identify_disease` resolves, call `lookup_disease_profile(standardized_name)` and use the returned dict exclusively.
72
+ - Do not iterate over `_ALL_DISEASES` to build a multi-disease context block.
73
+ - If you need to add a new disease, add its JSON under `data/diseases/` — do not hardcode benchmark lists in prompts or agents.
74
+ - For features that genuinely need cross-disease comparison (e.g., a disease selector UI), load lazily: fetch each profile only when selected, not all at startup.
75
+
76
+ ---
77
+
78
+ ## Module Map
79
+
80
+ | File | Layer | Imports allowed from |
81
+ |---|---|---|
82
+ | `app.py` | UI | `agents.*`, `translations`, `models` (type hints only) |
83
+ | `clinical_trials_guru.py` | Business facade | Everything |
84
+ | `agents/intake.py` | Agent | `models`, `prompts`, `tools`, `config`, `llm`, `translations` |
85
+ | `agents/research.py` | Agent | `models`, `prompts`, `tools`, `config`, `llm`, `trials_api`, `translations` |
86
+ | `models.py` | Domain model | stdlib, `requests` |
87
+ | `prompts.py` | Prompt builder | stdlib, `data/diseases/*.json` |
88
+ | `tools.py` | Tool schema loader | stdlib, `data/tools/*.json` |
89
+ | `trials_api.py` | External API | stdlib, `models`, `config` |
90
+ | `config.py` | Constants | stdlib |
91
+ | `translations.py` | i18n | stdlib |
92
+
93
+ ---
94
+
95
+ ## Entry Points
96
+
97
+ | Entry | File | UI | Flow |
98
+ |---|---|---|---|
99
+ | Web | `app.py` | Gradio | `intake_greeting` → `stream_intake_turn` → `stream_research_agent` |
100
+ | CLI | `main.py` → `clinical_trials_guru.py` | Rich console | LangGraph: `run_intake_agent` → `run_research_agent` |
agents/eligibility.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import operator as _op
5
+ from pathlib import Path
6
+ from typing import Generator
7
+
8
+ import anthropic
9
+
10
+ from beacon_logging import get_logger
11
+ from config import ELIGIBILITY_MODEL
12
+ from models import (
13
+ CriterionAssessment,
14
+ CriterionVerdict,
15
+ EligibilityCriterion,
16
+ ParsedConstraint,
17
+ PatientProfile,
18
+ TrialEligibilityReport,
19
+ )
20
+ from tools import ASSESS_ELIGIBILITY_TOOL, PARSE_CRITERIA_TOOL, PARSE_CRITERIA_BULK_TOOL
21
+
22
+ _logger = get_logger("agents.eligibility")
23
+
24
+ _KEY_MAP: dict[str, str] = json.loads(
25
+ (Path(__file__).parent.parent / "data" / "criterion_keys.json").read_text()
26
+ )
27
+
28
+ _PARSE_SYSTEM = """\
29
+ You are a clinical trial eligibility parser. Given raw eligibility criteria text, \
30
+ extract every inclusion and exclusion criterion as a structured object.
31
+
32
+ For each criterion:
33
+ - key: a snake_case canonical name that identifies the patient attribute being tested \
34
+ (e.g. age_years, ecog_status, prior_systemic_therapy_lines, egfr_ml_min)
35
+ - type: "inclusion" or "exclusion"
36
+ - description: a concise plain-English restatement of the requirement the patient must satisfy
37
+ - raw_criteria: the verbatim criterion text from the source
38
+ - constraint: a structured comparison if the criterion can be expressed as one; null otherwise
39
+
40
+ Express constraints as the condition the patient must meet to qualify:
41
+ "Age 18-75" → {operator: "between", value: [18, 75]}
42
+ "No prior systemic therapy" → {operator: "==", value: 0}
43
+ "ECOG 0 or 1" → {operator: "in", value: [0, 1]}
44
+ "eGFR >= 60 mL/min" → {operator: ">=", value: 60, unit: "mL/min"}
45
+ "Adequate hepatic function per investigator" → null
46
+
47
+ Set constraint to null for any criterion that is vague, subjective, compound, or \
48
+ cannot be expressed as a single comparison against a known patient field.
49
+ """
50
+
51
+ _ASSESS_SYSTEM = """\
52
+ You are a clinical trial eligibility assessor. Given a list of eligibility criteria \
53
+ that could not be evaluated deterministically, assess each one against the provided \
54
+ patient profile.
55
+
56
+ Rules:
57
+ 1. If patient data is missing for a criterion → verdict must be "unknown", never "pass"
58
+ 2. Confidence is "medium" if the criterion is clear but data is incomplete; \
59
+ "low" if the criterion itself is ambiguous
60
+ 3. For exclusion criteria: if patient data matches the exclusion condition → verdict is "fail"
61
+ 4. List every criterion key you could not assess in missing_data_keys
62
+ 5. Be conservative — when in doubt, use "unknown"
63
+ """
64
+
65
+
66
+ def _resolve_patient_value(
67
+ key: str,
68
+ patient: PatientProfile,
69
+ platform_data: dict | None,
70
+ ) -> tuple[object, bool]:
71
+ mapped = _KEY_MAP.get(key)
72
+ if mapped is None:
73
+ return None, False
74
+ prefix, field = mapped.split(".", 1)
75
+ if prefix == "patient":
76
+ val = getattr(patient, field, None)
77
+ return val, val is not None
78
+ if prefix == "platform":
79
+ if platform_data is None:
80
+ return None, False
81
+ val = platform_data.get(field)
82
+ return val, val is not None
83
+ return None, False
84
+
85
+
86
+ _OPERATORS = {
87
+ "<=": _op.le,
88
+ ">=": _op.ge,
89
+ "==": _op.eq,
90
+ "!=": _op.ne,
91
+ "in": lambda pv, v: pv in v,
92
+ "not_in": lambda pv, v: pv not in v,
93
+ "between": lambda pv, v: v[0] <= pv <= v[1],
94
+ }
95
+
96
+
97
+ def _evaluate_deterministic(
98
+ criterion: EligibilityCriterion,
99
+ patient_value: object,
100
+ ) -> CriterionAssessment:
101
+ c = criterion.constraint
102
+ fn = _OPERATORS.get(c.operator)
103
+ try:
104
+ passes = fn(patient_value, c.value)
105
+ except (TypeError, ValueError):
106
+ passes = False
107
+
108
+ verdict = CriterionVerdict.PASS if passes else CriterionVerdict.FAIL
109
+ unit_str = f" {c.unit}" if c.unit else ""
110
+ reason = (
111
+ f"Requires {c.operator} {c.value}{unit_str}; patient value: {patient_value}"
112
+ )
113
+ return CriterionAssessment(
114
+ criterion=criterion,
115
+ verdict=verdict,
116
+ reason=reason,
117
+ patient_value=str(patient_value),
118
+ confidence="high",
119
+ )
120
+
121
+
122
+ def _parse_criteria(client: anthropic.Anthropic, eligibility_text: str) -> list[EligibilityCriterion]:
123
+ if not eligibility_text.strip():
124
+ return []
125
+
126
+ response = client.messages.create(
127
+ model=ELIGIBILITY_MODEL,
128
+ max_tokens=4096,
129
+ system=_PARSE_SYSTEM,
130
+ tools=[PARSE_CRITERIA_TOOL],
131
+ tool_choice={"type": "tool", "name": "parse_criteria"},
132
+ messages=[{"role": "user", "content": eligibility_text}],
133
+ )
134
+
135
+ tool_use = next((b for b in response.content if b.type == "tool_use"), None)
136
+ if tool_use is None:
137
+ _logger.warning("parse_criteria tool not called", extra={"data": {}})
138
+ return []
139
+
140
+ raw_criteria: list[dict] = tool_use.input.get("criteria", [])
141
+ result = []
142
+ for rc in raw_criteria:
143
+ raw_constraint = rc.get("constraint")
144
+ constraint = None
145
+ if raw_constraint:
146
+ constraint = ParsedConstraint(
147
+ key=raw_constraint["key"],
148
+ operator=raw_constraint["operator"],
149
+ value=raw_constraint["value"],
150
+ unit=raw_constraint.get("unit"),
151
+ )
152
+ result.append(EligibilityCriterion(
153
+ key=rc["key"],
154
+ type=rc["type"],
155
+ description=rc["description"],
156
+ raw_criteria=rc["raw_criteria"],
157
+ constraint=constraint,
158
+ ))
159
+
160
+ _logger.info(
161
+ "Parsed eligibility criteria",
162
+ extra={"data": {"count": len(result)}},
163
+ )
164
+ return result
165
+
166
+
167
+ def _assess_llm(
168
+ client: anthropic.Anthropic,
169
+ criteria: list[EligibilityCriterion],
170
+ patient: PatientProfile,
171
+ platform_data: dict | None,
172
+ ) -> list[CriterionAssessment]:
173
+ if not criteria:
174
+ return []
175
+
176
+ patient_context = {
177
+ "age_years": patient.age,
178
+ "symptom_onset_months": patient.onset_months,
179
+ "diagnosis_months": patient.diagnosis_months,
180
+ "disease": patient.disease,
181
+ }
182
+ if platform_data:
183
+ patient_context["platform_data"] = platform_data
184
+
185
+ criteria_payload = [
186
+ {"key": c.key, "type": c.type, "description": c.description, "raw_criteria": c.raw_criteria}
187
+ for c in criteria
188
+ ]
189
+
190
+ user_content = (
191
+ f"Patient profile:\n{json.dumps(patient_context, indent=2)}\n\n"
192
+ f"Criteria to assess:\n{json.dumps(criteria_payload, indent=2)}"
193
+ )
194
+
195
+ response = client.messages.create(
196
+ model=ELIGIBILITY_MODEL,
197
+ max_tokens=4096,
198
+ system=_ASSESS_SYSTEM,
199
+ tools=[ASSESS_ELIGIBILITY_TOOL],
200
+ tool_choice={"type": "tool", "name": "assess_eligibility"},
201
+ messages=[{"role": "user", "content": user_content}],
202
+ )
203
+
204
+ tool_use = next((b for b in response.content if b.type == "tool_use"), None)
205
+ if tool_use is None:
206
+ _logger.warning("assess_eligibility tool not called", extra={"data": {}})
207
+ return [
208
+ CriterionAssessment(
209
+ criterion=c,
210
+ verdict=CriterionVerdict.UNKNOWN,
211
+ reason="Assessment unavailable",
212
+ patient_value=None,
213
+ confidence="low",
214
+ )
215
+ for c in criteria
216
+ ]
217
+
218
+ criterion_by_key = {c.key: c for c in criteria}
219
+ assessments = []
220
+ for a in tool_use.input.get("assessments", []):
221
+ criterion = criterion_by_key.get(a["criterion_key"])
222
+ if criterion is None:
223
+ continue
224
+ assessments.append(CriterionAssessment(
225
+ criterion=criterion,
226
+ verdict=CriterionVerdict(a["verdict"]),
227
+ reason=a["reason"],
228
+ patient_value=a.get("patient_value"),
229
+ confidence=a["confidence"],
230
+ ))
231
+
232
+ return assessments
233
+
234
+
235
+ def _compute_overall(assessments: list[CriterionAssessment]) -> CriterionVerdict:
236
+ if any(a.verdict == CriterionVerdict.FAIL for a in assessments):
237
+ return CriterionVerdict.FAIL
238
+ if any(a.verdict == CriterionVerdict.UNKNOWN for a in assessments):
239
+ return CriterionVerdict.UNKNOWN
240
+ return CriterionVerdict.PASS
241
+
242
+
243
+ def run_eligibility_check(
244
+ client: anthropic.Anthropic,
245
+ trial: dict,
246
+ patient: PatientProfile,
247
+ platform_data: dict | None = None,
248
+ ) -> TrialEligibilityReport:
249
+ nct_id = trial.get("nct_id", "")
250
+ eligibility_text = trial.get("eligibility", "")
251
+
252
+ criteria = _parse_criteria(client, eligibility_text)
253
+
254
+ deterministic: list[CriterionAssessment] = []
255
+ needs_llm: list[EligibilityCriterion] = []
256
+
257
+ for c in criteria:
258
+ if c.constraint is not None:
259
+ patient_value, found = _resolve_patient_value(c.key, patient, platform_data)
260
+ if found:
261
+ deterministic.append(_evaluate_deterministic(c, patient_value))
262
+ continue
263
+ needs_llm.append(c)
264
+
265
+ llm_assessments = _assess_llm(client, needs_llm, patient, platform_data)
266
+
267
+ all_assessments = deterministic + llm_assessments
268
+ missing_data_keys = [
269
+ a.criterion.key
270
+ for a in llm_assessments
271
+ if a.verdict == CriterionVerdict.UNKNOWN
272
+ ]
273
+
274
+ report = TrialEligibilityReport(
275
+ nct_id=nct_id,
276
+ overall_verdict=_compute_overall(all_assessments),
277
+ assessments=all_assessments,
278
+ missing_data_keys=missing_data_keys,
279
+ )
280
+
281
+ _logger.info(
282
+ "Eligibility check complete",
283
+ extra={
284
+ "data": {
285
+ "nct_id": nct_id,
286
+ "overall": report.overall_verdict,
287
+ "total": len(all_assessments),
288
+ "deterministic": len(deterministic),
289
+ "llm": len(llm_assessments),
290
+ "missing": missing_data_keys,
291
+ }
292
+ },
293
+ )
294
+ return report
295
+
296
+
297
+ _FIELDS_TO_STRIP_AFTER_PARSE = {"eligibility", "std_ages", "healthy_volunteers"}
298
+
299
+
300
+ def bulk_parse_and_strip(
301
+ client: anthropic.Anthropic,
302
+ trials: list[dict],
303
+ patient: PatientProfile,
304
+ platform_data: dict | None = None,
305
+ top_n: int = 5,
306
+ ) -> list[dict]:
307
+ """
308
+ One LLM call to parse criteria for top_n trials.
309
+ Applies deterministic assessment (Path A) in code.
310
+ Strips raw eligibility text and redundant fields.
311
+ Research LLM handles Path B (unclear criteria) inline during synthesis.
312
+ """
313
+ to_parse = [t for t in trials[:top_n] if t.get("eligibility", "").strip()]
314
+ rest = trials[top_n:]
315
+
316
+ if not to_parse:
317
+ return trials
318
+
319
+ payload = [
320
+ {"nct_id": t["nct_id"], "eligibility_text": t["eligibility"]}
321
+ for t in to_parse
322
+ ]
323
+ user_content = (
324
+ f"Parse eligibility criteria for these {len(payload)} trials:\n"
325
+ + json.dumps(payload, indent=2)
326
+ )
327
+
328
+ try:
329
+ response = client.messages.create(
330
+ model=ELIGIBILITY_MODEL,
331
+ max_tokens=8192,
332
+ system=_PARSE_SYSTEM,
333
+ tools=[PARSE_CRITERIA_BULK_TOOL],
334
+ tool_choice={"type": "tool", "name": "parse_criteria_bulk"},
335
+ messages=[{"role": "user", "content": user_content}],
336
+ )
337
+ tool_use = next((b for b in response.content if b.type == "tool_use"), None)
338
+ parsed_by_nct: dict[str, list[dict]] = {}
339
+ if tool_use:
340
+ for entry in tool_use.input.get("trials", []):
341
+ parsed_by_nct[entry["nct_id"]] = entry.get("criteria", [])
342
+ except Exception as exc:
343
+ _logger.warning("Bulk parse failed", extra={"data": {"error": str(exc)}})
344
+ parsed_by_nct = {}
345
+
346
+ _logger.info(
347
+ "Bulk criteria parse complete",
348
+ extra={"data": {"trials_parsed": len(parsed_by_nct)}},
349
+ )
350
+
351
+ for trial in to_parse:
352
+ nct_id = trial["nct_id"]
353
+ raw_criteria = parsed_by_nct.get(nct_id, [])
354
+ criteria: list[EligibilityCriterion] = []
355
+ deterministic_verdicts: list[dict] = []
356
+
357
+ for rc in raw_criteria:
358
+ raw_constraint = rc.get("constraint")
359
+ constraint = None
360
+ if raw_constraint:
361
+ constraint = ParsedConstraint(
362
+ key=raw_constraint["key"],
363
+ operator=raw_constraint["operator"],
364
+ value=raw_constraint["value"],
365
+ unit=raw_constraint.get("unit"),
366
+ )
367
+ c = EligibilityCriterion(
368
+ key=rc["key"],
369
+ type=rc["type"],
370
+ description=rc["description"],
371
+ raw_criteria=rc["raw_criteria"],
372
+ constraint=constraint,
373
+ )
374
+ criteria.append(c)
375
+
376
+ if constraint is not None:
377
+ patient_value, found = _resolve_patient_value(c.key, patient, platform_data)
378
+ if found:
379
+ assessment = _evaluate_deterministic(c, patient_value)
380
+ deterministic_verdicts.append({
381
+ "verdict": assessment.verdict.value,
382
+ "description": c.description,
383
+ "reason": assessment.reason,
384
+ "confidence": assessment.confidence,
385
+ "raw_criteria": c.raw_criteria,
386
+ })
387
+
388
+ trial["parsed_criteria"] = [
389
+ {
390
+ "key": c.key,
391
+ "type": c.type,
392
+ "description": c.description,
393
+ "raw_criteria": c.raw_criteria,
394
+ "constraint": (
395
+ {
396
+ "operator": c.constraint.operator,
397
+ "value": c.constraint.value,
398
+ "unit": c.constraint.unit,
399
+ }
400
+ if c.constraint else None
401
+ ),
402
+ }
403
+ for c in criteria
404
+ ]
405
+ trial["deterministic_verdicts"] = deterministic_verdicts
406
+
407
+ for field in _FIELDS_TO_STRIP_AFTER_PARSE:
408
+ trial.pop(field, None)
409
+
410
+ return to_parse + rest
411
+
412
+
413
+ def stream_eligibility_check(
414
+ client: anthropic.Anthropic,
415
+ trial: dict,
416
+ patient: PatientProfile,
417
+ platform_data: dict | None = None,
418
+ ) -> Generator[str, None, None]:
419
+ nct_id = trial.get("nct_id", "")
420
+ yield f"Parsing eligibility criteria for {nct_id}…\n"
421
+
422
+ report = run_eligibility_check(client, trial, patient, platform_data)
423
+
424
+ verdict_icon = {"pass": "✓", "fail": "✗", "unknown": "!"}
425
+ lines = []
426
+ for a in report.assessments:
427
+ icon = verdict_icon[a.verdict.value]
428
+ lines.append(f" {icon} {a.criterion.description} — {a.reason}")
429
+
430
+ lines.append("")
431
+ overall_label = {
432
+ CriterionVerdict.PASS: "Eligible",
433
+ CriterionVerdict.FAIL: "Not eligible",
434
+ CriterionVerdict.UNKNOWN: "Likely eligible — confirm missing info",
435
+ }[report.overall_verdict]
436
+ lines.append(f" Overall: {overall_label}")
437
+
438
+ if report.missing_data_keys:
439
+ lines.append(f" Missing information: {', '.join(report.missing_data_keys)}")
440
+
441
+ yield "\n".join(lines) + "\n"
agents/intake.py CHANGED
@@ -20,6 +20,23 @@ from _console import console
20
  _logger = get_logger("agents.intake")
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
24
  today = datetime.date.today().strftime("%B %d, %Y")
25
 
@@ -84,8 +101,8 @@ def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
84
  profile = PatientProfile(
85
  disease=data["disease"],
86
  age=data["age"],
87
- onset_months=data["onset_months"],
88
- diagnosis_months=data.get("diagnosis_months", 0),
89
  benchmarks=data.get("benchmarks") or {},
90
  zip_code=data["zip_code"],
91
  country_code=data.get("country_code", "US"),
@@ -199,8 +216,8 @@ def stream_intake_turn(
199
  profile = PatientProfile(
200
  disease=data["disease"],
201
  age=data["age"],
202
- onset_months=data["onset_months"],
203
- diagnosis_months=data.get("diagnosis_months", 0),
204
  benchmarks=data.get("benchmarks") or {},
205
  zip_code=data["zip_code"],
206
  country_code=data.get("country_code", "US"),
 
20
  _logger = get_logger("agents.intake")
21
 
22
 
23
+ def _months_from_date(date_str: str) -> int:
24
+ """Convert YYYY-MM string to elapsed months from today. Returns 0 on parse failure."""
25
+ try:
26
+ year, month = int(date_str[:4]), int(date_str[5:7])
27
+ today = datetime.date.today()
28
+ return (today.year - year) * 12 + (today.month - month)
29
+ except (ValueError, IndexError):
30
+ return 0
31
+
32
+
33
+ def _resolve_months(data: dict, date_key: str, months_key: str) -> int:
34
+ """Prefer date string for exact computation; fall back to LLM-supplied integer."""
35
+ if data.get(date_key):
36
+ return _months_from_date(data[date_key])
37
+ return data.get(months_key) or 0
38
+
39
+
40
  def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
41
  today = datetime.date.today().strftime("%B %d, %Y")
42
 
 
101
  profile = PatientProfile(
102
  disease=data["disease"],
103
  age=data["age"],
104
+ onset_months=_resolve_months(data, "onset_date", "onset_months"),
105
+ diagnosis_months=_resolve_months(data, "diagnosis_date", "diagnosis_months"),
106
  benchmarks=data.get("benchmarks") or {},
107
  zip_code=data["zip_code"],
108
  country_code=data.get("country_code", "US"),
 
216
  profile = PatientProfile(
217
  disease=data["disease"],
218
  age=data["age"],
219
+ onset_months=_resolve_months(data, "onset_date", "onset_months"),
220
+ diagnosis_months=_resolve_months(data, "diagnosis_date", "diagnosis_months"),
221
  benchmarks=data.get("benchmarks") or {},
222
  zip_code=data["zip_code"],
223
  country_code=data.get("country_code", "US"),
agents/research.py CHANGED
@@ -5,6 +5,7 @@ from typing import Generator
5
 
6
  import anthropic
7
 
 
8
  from beacon_logging import get_logger
9
  from config import RESEARCH_MODEL
10
  from models import PatientProfile
@@ -70,6 +71,7 @@ def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) ->
70
  study_type=study_type,
71
  )
72
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
 
73
  console.print(f" [green]✓[/green] {len(ranked)} trial(s) found.")
74
  content = json.dumps(ranked)
75
  is_error = False
@@ -131,16 +133,24 @@ def stream_research_agent(
131
  if block.type != "tool_use" or block.name != "search_clinical_trials":
132
  continue
133
  args = block.input
 
 
 
 
134
  try:
135
  studies = search_trials_api(
136
  condition=args["condition"],
137
  lat=args["lat"],
138
  lon=args["lon"],
139
- radius_miles=args.get("radius_miles", profile.radius_miles),
140
  phases=args.get("phases") or None,
141
- study_type=args.get("study_type", "INTERVENTIONAL"),
142
  )
143
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
 
 
 
 
144
  content = json.dumps(ranked)
145
  is_error = False
146
  except Exception as exc:
 
5
 
6
  import anthropic
7
 
8
+ from agents.eligibility import bulk_parse_and_strip
9
  from beacon_logging import get_logger
10
  from config import RESEARCH_MODEL
11
  from models import PatientProfile
 
71
  study_type=study_type,
72
  )
73
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
74
+ ranked = bulk_parse_and_strip(client, ranked, profile)
75
  console.print(f" [green]✓[/green] {len(ranked)} trial(s) found.")
76
  content = json.dumps(ranked)
77
  is_error = False
 
133
  if block.type != "tool_use" or block.name != "search_clinical_trials":
134
  continue
135
  args = block.input
136
+ radius = args.get("radius_miles", profile.radius_miles)
137
+ study_type = args.get("study_type", "INTERVENTIONAL")
138
+ type_label = {"INTERVENTIONAL": "clinical trials", "EXPANDED_ACCESS": "expanded access programs", "OBSERVATIONAL": "observational studies"}.get(study_type, study_type.lower())
139
+ yield ("status", f"Searching ClinicalTrials.gov for **{args['condition']}** ({type_label}, {radius} mi radius)…")
140
  try:
141
  studies = search_trials_api(
142
  condition=args["condition"],
143
  lat=args["lat"],
144
  lon=args["lon"],
145
+ radius_miles=radius,
146
  phases=args.get("phases") or None,
147
+ study_type=study_type,
148
  )
149
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
150
+ n = len(ranked)
151
+ yield ("status", f"Found **{n}** {type_label} — checking eligibility for the {min(5, n)} closest…")
152
+ ranked = bulk_parse_and_strip(client, ranked, profile)
153
+ yield ("status", "Eligibility analysis complete — generating your report…")
154
  content = json.dumps(ranked)
155
  is_error = False
156
  except Exception as exc:
app.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import dataclasses
 
4
  from typing import Generator
5
 
6
  import anthropic
@@ -17,6 +18,22 @@ load_dotenv()
17
 
18
  _logger = get_logger("app")
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def initialize(lang: str = "en"):
22
  client = anthropic.Anthropic()
@@ -82,13 +99,20 @@ def respond(
82
  if rev[0] == "token":
83
  stream_text += rev[1]
84
  yield (
85
- chat_history + [{"role": "assistant", "content": stream_text}],
 
 
 
 
 
 
 
86
  updated_msgs, new_profile, "researching",
87
  gr.update(interactive=False, placeholder=t["searching"]),
88
  gr.update(visible=False),
89
  )
90
  elif rev[0] == "done":
91
- analysis = rev[1] or t["no_analysis"]
92
  chat_history = chat_history + [{"role": "assistant", "content": analysis}]
93
  yield (
94
  chat_history, updated_msgs, new_profile, "done",
@@ -125,7 +149,7 @@ with gr.Blocks(title=UI["en"]["page_title"]) as demo:
125
  container=False,
126
  )
127
 
128
- chatbot = gr.Chatbot(height=550, show_label=False)
129
  with gr.Row():
130
  msg_box = gr.Textbox(
131
  placeholder=UI["en"]["placeholder"],
 
1
  from __future__ import annotations
2
 
3
  import dataclasses
4
+ import re
5
  from typing import Generator
6
 
7
  import anthropic
 
18
 
19
  _logger = get_logger("app")
20
 
21
+ _VERDICT_STYLE = {
22
+ "✓": "background:#22c55e;color:#fff;padding:2px 10px;border-radius:12px;font-weight:700;",
23
+ "✗": "background:#ef4444;color:#fff;padding:2px 10px;border-radius:12px;font-weight:700;",
24
+ "!": "background:#eab308;color:#fff;padding:2px 10px;border-radius:12px;font-weight:700;",
25
+ }
26
+
27
+ _VERDICT_RE = re.compile(r"(?m)^(\s*)(✓|✗|!)\s")
28
+
29
+
30
+ def _colorize(text: str) -> str:
31
+ def _replace(m: re.Match) -> str:
32
+ symbol = m.group(2)
33
+ style = _VERDICT_STYLE[symbol]
34
+ return f'{m.group(1)}<span style="{style}">{symbol}</span> '
35
+ return _VERDICT_RE.sub(_replace, text)
36
+
37
 
38
  def initialize(lang: str = "en"):
39
  client = anthropic.Anthropic()
 
99
  if rev[0] == "token":
100
  stream_text += rev[1]
101
  yield (
102
+ chat_history + [{"role": "assistant", "content": _colorize(stream_text)}],
103
+ updated_msgs, new_profile, "researching",
104
+ gr.update(interactive=False, placeholder=t["searching"]),
105
+ gr.update(visible=False),
106
+ )
107
+ elif rev[0] == "status":
108
+ yield (
109
+ chat_history + [{"role": "assistant", "content": rev[1]}],
110
  updated_msgs, new_profile, "researching",
111
  gr.update(interactive=False, placeholder=t["searching"]),
112
  gr.update(visible=False),
113
  )
114
  elif rev[0] == "done":
115
+ analysis = _colorize(rev[1] or t["no_analysis"])
116
  chat_history = chat_history + [{"role": "assistant", "content": analysis}]
117
  yield (
118
  chat_history, updated_msgs, new_profile, "done",
 
149
  container=False,
150
  )
151
 
152
+ chatbot = gr.Chatbot(height=550, show_label=False, sanitize_html=False)
153
  with gr.Row():
154
  msg_box = gr.Textbox(
155
  placeholder=UI["en"]["placeholder"],
config.py CHANGED
@@ -1,3 +1,4 @@
1
  CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
2
  INTAKE_MODEL = "claude-sonnet-4-6"
3
  RESEARCH_MODEL = "claude-opus-4-7"
 
 
1
  CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
2
  INTAKE_MODEL = "claude-sonnet-4-6"
3
  RESEARCH_MODEL = "claude-opus-4-7"
4
+ ELIGIBILITY_MODEL = "claude-sonnet-4-6"
data/criterion_keys.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "age_years": "patient.age",
3
+ "symptom_onset_months": "patient.onset_months",
4
+ "diagnosis_months": "patient.diagnosis_months",
5
+ "sex": "patient.sex",
6
+ "ecog_status": "platform.ecog_status",
7
+ "prior_systemic_therapy_lines": "platform.prior_treatment_lines",
8
+ "egfr_ml_min": "platform.egfr_ml_min",
9
+ "anc_per_ul": "platform.anc_per_ul",
10
+ "hemoglobin_g_dl": "platform.hemoglobin_g_dl",
11
+ "platelets_per_ul": "platform.platelets_per_ul",
12
+ "bilirubin_mg_dl": "platform.bilirubin_mg_dl",
13
+ "creatinine_mg_dl": "platform.creatinine_mg_dl"
14
+ }
data/tools/assess_eligibility.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "object",
3
+ "required": ["assessments", "missing_data_keys"],
4
+ "properties": {
5
+ "assessments": {
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "required": ["criterion_key", "verdict", "reason", "confidence", "raw_criteria"],
10
+ "properties": {
11
+ "criterion_key": { "type": "string" },
12
+ "verdict": { "enum": ["pass", "fail", "unknown"] },
13
+ "reason": { "type": "string" },
14
+ "patient_value": { "type": ["string", "null"] },
15
+ "confidence": { "enum": ["medium", "low"] },
16
+ "raw_criteria": { "type": "string" }
17
+ }
18
+ }
19
+ },
20
+ "missing_data_keys": {
21
+ "type": "array",
22
+ "items": { "type": "string" }
23
+ }
24
+ }
25
+ }
data/tools/parse_criteria.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "object",
3
+ "required": ["criteria"],
4
+ "properties": {
5
+ "criteria": {
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "required": ["key", "type", "description", "raw_criteria"],
10
+ "properties": {
11
+ "key": { "type": "string" },
12
+ "type": { "enum": ["inclusion", "exclusion"] },
13
+ "description": { "type": "string" },
14
+ "raw_criteria": { "type": "string" },
15
+ "constraint": {
16
+ "oneOf": [
17
+ { "type": "null" },
18
+ {
19
+ "type": "object",
20
+ "required": ["key", "operator", "value"],
21
+ "properties": {
22
+ "key": { "type": "string" },
23
+ "operator": { "enum": ["<=", ">=", "==", "!=", "in", "not_in", "between"] },
24
+ "value": {},
25
+ "unit": { "type": ["string", "null"] }
26
+ }
27
+ }
28
+ ]
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
data/tools/parse_criteria_bulk.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "object",
3
+ "required": ["trials"],
4
+ "properties": {
5
+ "trials": {
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "required": ["nct_id", "criteria"],
10
+ "properties": {
11
+ "nct_id": { "type": "string" },
12
+ "criteria": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "required": ["key", "type", "description", "raw_criteria"],
17
+ "properties": {
18
+ "key": { "type": "string" },
19
+ "type": { "enum": ["inclusion", "exclusion"] },
20
+ "description": { "type": "string" },
21
+ "raw_criteria": { "type": "string" },
22
+ "constraint": {
23
+ "oneOf": [
24
+ { "type": "null" },
25
+ {
26
+ "type": "object",
27
+ "required": ["key", "operator", "value"],
28
+ "properties": {
29
+ "key": { "type": "string" },
30
+ "operator": { "enum": ["<=", ">=", "==", "!=", "in", "not_in", "between"] },
31
+ "value": {},
32
+ "unit": { "type": ["string", "null"] }
33
+ }
34
+ }
35
+ ]
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
data/tools/submit_profile.json CHANGED
@@ -6,13 +6,21 @@
6
  "description": "Full medical name (e.g. 'Amyotrophic Lateral Sclerosis')"
7
  },
8
  "age": { "type": "integer" },
 
 
 
 
9
  "onset_months": {
10
  "type": "integer",
11
- "description": "Months since first symptom onset"
 
 
 
 
12
  },
13
  "diagnosis_months": {
14
  "type": "integer",
15
- "description": "Months since formal/official diagnosis"
16
  },
17
  "benchmarks": {
18
  "type": "object",
 
6
  "description": "Full medical name (e.g. 'Amyotrophic Lateral Sclerosis')"
7
  },
8
  "age": { "type": "integer" },
9
+ "onset_date": {
10
+ "type": "string",
11
+ "description": "Date of first symptom onset in YYYY-MM format (preferred over onset_months)"
12
+ },
13
  "onset_months": {
14
  "type": "integer",
15
+ "description": "Months since first symptom onset — only use if exact date is unknown"
16
+ },
17
+ "diagnosis_date": {
18
+ "type": "string",
19
+ "description": "Date of formal diagnosis in YYYY-MM format (preferred over diagnosis_months)"
20
  },
21
  "diagnosis_months": {
22
  "type": "integer",
23
+ "description": "Months since formal/official diagnosis — only use if exact date is unknown"
24
  },
25
  "benchmarks": {
26
  "type": "object",
design/eligibility-assessment.md ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Eligibility Assessment — Design Document
2
+
3
+ ## Background
4
+
5
+ Currently, Beacon fetches raw eligibility text from ClinicalTrials.gov but drops most of it — criteria are truncated at 1000 characters, and structured fields (sex, age groups, healthy volunteers) are never extracted. The research agent passes the truncated blob to the LLM and lets it synthesize a free-form summary. There is no per-criterion verdict, no structured representation of why a patient does or does not qualify, and no seam for external data to plug into.
6
+
7
+ This document covers two related changes:
8
+ 1. **Stop discarding data** — extract all useful fields from the API response we already receive as JSON format
9
+ 2. **Eligibility assessment** — structured per-criterion judgment with a defined UI representation
10
+
11
+ ---
12
+
13
+ ## Part 1: Complete the API Extraction (`trials_api.py`)
14
+
15
+ ### Problem
16
+
17
+ `_flatten_and_rank` currently:
18
+ - Truncates `eligibilityCriteria` at 1000 chars (criteria routinely run 3–5x that, cutting off key exclusions)
19
+ - Truncates `briefSummary` at 500 chars
20
+ - Reads 6 protocol modules but uses only a fraction of each
21
+ - Never reads `conditionsModule` or `armsInterventionsModule`
22
+
23
+ No extra network calls are needed — all of this is already in the paginated response.
24
+
25
+ ### Fields to add
26
+
27
+ | Field | Source module | Key | Notes |
28
+ |---|---|---|---|
29
+ | `sex` | `eligibilityModule` | `sex` | `"ALL"`, `"FEMALE"`, `"MALE"` |
30
+ | `healthy_volunteers` | `eligibilityModule` | `healthyVolunteers` | `"Yes"` / `"No"` |
31
+ | `std_ages` | `eligibilityModule` | `stdAges` | e.g. `["ADULT", "OLDER_ADULT"]` |
32
+ | `study_type` | `designModule` | `studyType` | `"INTERVENTIONAL"` etc. |
33
+ | `enrollment` | `designModule` | `enrollmentInfo.count` | expected participant count |
34
+ | `conditions` | `conditionsModule` | `conditions` | list of condition strings |
35
+ | `keywords` | `conditionsModule` | `keywords` | list of keyword strings |
36
+ | `interventions` | `armsInterventionsModule` | `interventions[*]` | `{type, name, description}` per entry |
37
+
38
+ ### Truncation removal
39
+
40
+ - `eligibilityCriteria`: remove `[:1000]` — pass full text
41
+ - `briefSummary`: remove `[:500]` — pass full text
42
+
43
+ ### File changed
44
+
45
+ `trials_api.py` — `_flatten_and_rank` only (lines 99–162)
46
+
47
+ ---
48
+
49
+ ## Part 2: Eligibility Assessment
50
+
51
+ ### Goal
52
+
53
+ For each trial returned to a patient, produce a per-criterion verdict:
54
+ - `pass` — patient meets this criterion (show ✓)
55
+ - `fail` — patient does not meet this criterion (show ✗ + reason)
56
+ - `unknown` — insufficient data to assess (show ! + what data would resolve it)
57
+
58
+ **Core principle: robustness over optimism.** When data is missing, the verdict is `unknown`, never an assumed `pass`.
59
+
60
+ ### Data the patient provides today
61
+
62
+ | Field | Source |
63
+ |---|---|
64
+ | Age | `PatientProfile.age` |
65
+ | Disease | `PatientProfile.disease` |
66
+ | Time since diagnosis | `PatientProfile.diagnosis_months` |
67
+ | Time since symptom onset | `PatientProfile.onset_months` |
68
+ | Phases acceptable | `PatientProfile.phases` |
69
+ | Location | `PatientProfile.location` |
70
+
71
+ ### Data the integration partner could provide
72
+
73
+ | Field | Maps to common trial criteria |
74
+ |---|---|
75
+ | ECOG / performance status | Performance status requirements |
76
+ | Prior lines of therapy | Prior treatment exclusions |
77
+ | Lab values (eGFR, ANC, Hgb, platelets, etc.) | Lab-based inclusion/exclusion |
78
+ | Current medications | Drug interaction exclusions |
79
+ | Comorbidities | Disease-based exclusion criteria |
80
+
81
+ These map to the most common failure points in trial eligibility. Providing them moves criteria from `unknown` to `pass` or `fail`.
82
+
83
+ ### Criteria parsing (Step 1 of 2)
84
+
85
+ Before assessment, raw eligibility text is parsed into structured criterion objects. This is a separate LLM call using a `parse_criteria` tool.
86
+
87
+ **Example:**
88
+
89
+ | Raw text | Parsed constraint |
90
+ |---|---|
91
+ | `"Patients must have symptom onset within 24 months"` | `{"key": "symptom_onset_months", "operator": "<=", "value": 24, "unit": "months"}` |
92
+ | `"Age 18 to 75 years"` | `{"key": "age_years", "operator": "between", "value": [18, 75], "unit": "years"}` |
93
+ | `"ECOG performance status 0 or 1"` | `{"key": "ecog_status", "operator": "in", "value": [0, 1], "unit": null}` |
94
+ | `"No prior systemic therapy"` | `{"key": "prior_systemic_therapy_lines", "operator": "==", "value": 0, "unit": null}` |
95
+ | `"Adequate hepatic function as per investigator"` | `null` — not parseable, falls back to LLM judgment |
96
+
97
+ When a criterion cannot be expressed as a structured constraint (vague language, compound logic, subjective assessments), `constraint` is `null`. Assessment for those falls back to LLM judgment, which will produce `unknown` if patient data is insufficient.
98
+
99
+ **Tool schema** (`data/tools/parse_criteria.json`):
100
+
101
+ ```json
102
+ {
103
+ "type": "object",
104
+ "required": ["criteria"],
105
+ "properties": {
106
+ "criteria": {
107
+ "type": "array",
108
+ "items": {
109
+ "type": "object",
110
+ "required": ["key", "type", "description", "raw_criteria"],
111
+ "properties": {
112
+ "key": { "type": "string" },
113
+ "type": { "enum": ["inclusion", "exclusion"] },
114
+ "description": { "type": "string" },
115
+ "raw_criteria": { "type": "string" },
116
+ "constraint": {
117
+ "oneOf": [
118
+ { "type": "null" },
119
+ {
120
+ "type": "object",
121
+ "required": ["key", "operator", "value"],
122
+ "properties": {
123
+ "key": { "type": "string" },
124
+ "operator": { "enum": ["<=", ">=", "==", "!=", "in", "not_in", "between"] },
125
+ "value": {},
126
+ "unit": { "type": ["string", "null"] }
127
+ }
128
+ }
129
+ ]
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ **Key mapping** — canonical `key` values that map to patient profile fields (`data/criterion_keys.json`):
139
+
140
+ | Criterion key | Resolves to |
141
+ |---|---|
142
+ | `age_years` | `PatientProfile.age` |
143
+ | `symptom_onset_months` | `PatientProfile.onset_months` |
144
+ | `diagnosis_months` | `PatientProfile.diagnosis_months` |
145
+ | `sex` | `PatientProfile.sex` *(field to be added to PatientProfile)* |
146
+ | `ecog_status` | `platform_data["ecog_status"]` |
147
+ | `prior_systemic_therapy_lines` | `platform_data["prior_treatment_lines"]` |
148
+ | `egfr_ml_min` | `platform_data["egfr_ml_min"]` |
149
+ | `anc_per_ul` | `platform_data["anc_per_ul"]` |
150
+ | `hemoglobin_g_dl` | `platform_data["hemoglobin_g_dl"]` |
151
+ | `platelets_per_ul` | `platform_data["platelets_per_ul"]` |
152
+ | `bilirubin_mg_dl` | `platform_data["bilirubin_mg_dl"]` |
153
+ | `creatinine_mg_dl` | `platform_data["creatinine_mg_dl"]` |
154
+
155
+ Any `key` not in this table and not in `platform_data` produces verdict `unknown`. Add new keys to `data/criterion_keys.json` without touching agent code.
156
+
157
+ ---
158
+
159
+ ### New data model (`models.py`)
160
+
161
+ ```python
162
+ class CriterionVerdict(str, Enum):
163
+ PASS = "pass"
164
+ FAIL = "fail"
165
+ UNKNOWN = "unknown"
166
+
167
+ @dataclass
168
+ class ParsedConstraint:
169
+ key: str # canonical patient field name
170
+ operator: str # "<=", ">=", "==", "!=", "in", "not_in", "between"
171
+ value: int | float | str | list
172
+ unit: str | None # "months", "years", "mL/min" — for display and mismatch detection
173
+
174
+ @dataclass
175
+ class EligibilityCriterion:
176
+ key: str # e.g. "age_years", "prior_systemic_therapy_lines"
177
+ type: str # "inclusion" | "exclusion"
178
+ description: str # original human-readable criterion text
179
+ raw_criteria: str # verbatim criterion bullet preserved for patient transparency
180
+ constraint: ParsedConstraint | None # None → LLM judgment required
181
+
182
+ @dataclass
183
+ class CriterionAssessment:
184
+ criterion: EligibilityCriterion
185
+ verdict: CriterionVerdict
186
+ reason: str # what the trial requires vs. what we know
187
+ patient_value: str | None # patient's value for this criterion key
188
+ confidence: str # "high" | "medium" | "low"
189
+
190
+ @dataclass
191
+ class TrialEligibilityReport:
192
+ nct_id: str
193
+ overall_verdict: CriterionVerdict
194
+ assessments: list[CriterionAssessment]
195
+ missing_data_keys: list[str] # fields needed but unavailable
196
+ ```
197
+
198
+ Overall verdict logic:
199
+ - `FAIL` if any criterion is `fail`
200
+ - `UNKNOWN` if no `fail` but any `unknown`
201
+ - `PASS` only if every criterion is `pass`
202
+
203
+ ### New agent (`agents/eligibility.py`)
204
+
205
+ Three public functions:
206
+
207
+ ```python
208
+ def run_eligibility_check(
209
+ client: anthropic.Anthropic,
210
+ trial: dict, # flattened trial record from trials_api
211
+ patient: PatientProfile,
212
+ platform_data: dict | None = None,
213
+ ) -> TrialEligibilityReport: ...
214
+
215
+ def check_trials_inline(
216
+ client: anthropic.Anthropic,
217
+ trials: list[dict],
218
+ patient: PatientProfile,
219
+ platform_data: dict | None = None,
220
+ top_n: int = 10, # cap to avoid excessive LLM calls
221
+ ) -> list[dict]: ... # returns trials with "eligibility_report" key added
222
+
223
+ def stream_eligibility_check(
224
+ client: anthropic.Anthropic,
225
+ trial: dict,
226
+ patient: PatientProfile,
227
+ platform_data: dict | None = None,
228
+ ) -> Generator[str, None, None]: ...
229
+ ```
230
+
231
+ `check_trials_inline` is the function called by `agents/research.py` — it runs eligibility on the top N trials (by distance) and embeds a serialized report under `trial["eligibility_report"]` so the research LLM can incorporate verdicts into its synthesis.
232
+
233
+ `platform_data` is the integration seam — partner-supplied values resolve criteria that would otherwise be `unknown`.
234
+
235
+ Internal flow of `run_eligibility_check`:
236
+ 1. **Parse step** — LLM call with `parse_criteria` tool on `trial["eligibility"]` → `list[EligibilityCriterion]`
237
+ 2. Load `data/criterion_keys.json` (loaded once at module import as `_KEY_MAP`)
238
+ 3. **Assess step** per criterion:
239
+ - **Path A (deterministic)**: constraint not None + patient value known → evaluate in code → `confidence: "high"`
240
+ - **Path B (LLM)**: constraint None or patient value missing → batched `assess_eligibility` tool call → `confidence: "medium"` or `"low"`
241
+ 4. Collect `missing_data_keys`, compute overall verdict, return `TrialEligibilityReport`
242
+
243
+ `eligibility_report` embedded in each trial dict:
244
+ ```json
245
+ {
246
+ "overall": "fail",
247
+ "assessments": [
248
+ {
249
+ "verdict": "pass",
250
+ "description": "Age meets requirement",
251
+ "reason": "Requires between [18, 75]; patient value: 42",
252
+ "confidence": "high",
253
+ "raw_criteria": "Age 18 to 75 years"
254
+ },
255
+ {
256
+ "verdict": "unknown",
257
+ "description": "eGFR >= 60 mL/min",
258
+ "reason": "Lab value not on file",
259
+ "confidence": "medium",
260
+ "raw_criteria": "Adequate renal function: eGFR >= 60 mL/min"
261
+ }
262
+ ],
263
+ "missing_data_keys": ["egfr_ml_min"]
264
+ }
265
+ ```
266
+
267
+ ### New tool schema (`data/tools/assess_eligibility.json`)
268
+
269
+ The LLM uses a structured tool call to emit verdicts — no free-form text output for verdicts.
270
+
271
+ ```json
272
+ {
273
+ "type": "object",
274
+ "required": ["assessments", "missing_data_keys"],
275
+ "properties": {
276
+ "assessments": {
277
+ "type": "array",
278
+ "items": {
279
+ "type": "object",
280
+ "required": ["criterion_key", "verdict", "reason", "confidence"],
281
+ "properties": {
282
+ "criterion_key": { "type": "string" },
283
+ "verdict": { "enum": ["pass", "fail", "unknown"] },
284
+ "reason": { "type": "string" },
285
+ "patient_value": { "type": ["string", "null"] },
286
+ "confidence": { "enum": ["medium", "low"] },
287
+ "raw_criteria": { "type": "string" }
288
+ }
289
+ }
290
+ },
291
+ "missing_data_keys": {
292
+ "type": "array",
293
+ "items": { "type": "string" }
294
+ }
295
+ }
296
+ }
297
+ ```
298
+
299
+ ### Assessment pipeline (Step 2 of 2)
300
+
301
+ Assessment is a two-path process per criterion:
302
+
303
+ **Path A — deterministic** (when `constraint` is not null and patient value is known):
304
+ - Evaluate `patient_value <operator> constraint.value` directly in code — no LLM needed
305
+ - Verdict is `pass` or `fail` with `confidence: "high"`
306
+ - Example: `onset_months=18`, constraint `<= 24` → `pass`
307
+
308
+ **Path B — LLM judgment** (when `constraint` is null, or patient value is missing):
309
+ - Pass criterion description + available patient context to the LLM via `assess_eligibility` tool
310
+ - If patient value is missing → verdict must be `unknown`, never assumed `pass`
311
+ - LLM sets `confidence: "medium"` or `"low"` based on criterion clarity
312
+
313
+ ### Judgment rules (system prompt constraints for Path B)
314
+
315
+ 1. If data to assess a criterion is missing → `unknown`, never assume `pass`
316
+ 2. Confidence is `high` only for deterministic Path A evaluations — LLM assessments are at most `medium`
317
+ 3. For exclusion criteria: if patient value matches the exclusion → `fail` with explicit reason
318
+ 4. `missing_data_keys` must list every criterion key the agent could not assess due to missing patient data
319
+
320
+ ### UI rendering (`app.py`)
321
+
322
+ ```
323
+ Trial NCT123456 — Phase 2 — Sponsor X
324
+
325
+ ✓ Age 42 — meets requirement (≥ 16 and ≤ 64)
326
+ ✓ Disease confirmed — meets inclusion criteria
327
+ ✗ Prior chemotherapy — trial excludes ≥ 2 prior lines (patient has 3)
328
+ ! eGFR — trial requires ≥ 60 mL/min — lab value not on file
329
+
330
+ Overall: Not eligible
331
+ Missing information: eGFR — ask your care team
332
+ ```
333
+
334
+ Rendering rules:
335
+ - ✓ green, ✗ red, ! amber
336
+ - `fail` reasons always visible — never collapsed
337
+ - `unknown` items show what data would resolve them
338
+ - Overall badge: red "Not eligible" / amber "Likely eligible — missing info" / green "Eligible"
339
+
340
+ ---
341
+
342
+ ## Integration seam for the external platform
343
+
344
+ `PatientProfile` gets one new optional field:
345
+
346
+ ```python
347
+ platform_data: dict | None = None
348
+ # e.g. {"prior_treatment_lines": 2, "ecog_status": 1, "egfr_ml_min": 55}
349
+ ```
350
+
351
+ The eligibility agent maps known platform keys → criterion keys at assessment time. When a platform value is present, the criterion moves from `unknown` to a real verdict. This is the value-add for the integration partner — their data directly reduces the number of `!` items shown to the patient.
352
+
353
+
354
+ ---
355
+
356
+ ## Decisions
357
+
358
+ **Eligibility runs inline** — no extra API call. Triggered automatically for the top 10 closest trials after each `search_clinical_trials` tool call. All results shown to patient; clinician and patient decide whether to pursue.
359
+
360
+ **`raw_criteria` is always preserved** — verbatim criterion text is stored in every `EligibilityCriterion` and echoed in the report. Trust requires traceability: the patient can see exactly what the trial says, not just our interpretation.
361
+
362
+ **After parsing, raw eligibility blob is not stored** — the structured `eligibility_report` replaces it. Unstructured fields like experiment description are not carried forward.
363
+
364
+ **Key mapping lives in `data/criterion_keys.json`** — add new platform fields there without touching agent code.
models.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  import math
4
  from dataclasses import dataclass, field
 
 
5
 
6
  import httpx
7
 
@@ -55,6 +57,46 @@ class PatientProfile:
55
  return "\n".join(lines)
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def geocode_zip(zip_code: str, country_code: str = "US") -> tuple[float, float]:
59
  resp = httpx.get(
60
  "https://nominatim.openstreetmap.org/search",
 
2
 
3
  import math
4
  from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Union
7
 
8
  import httpx
9
 
 
57
  return "\n".join(lines)
58
 
59
 
60
+ class CriterionVerdict(str, Enum):
61
+ PASS = "pass"
62
+ FAIL = "fail"
63
+ UNKNOWN = "unknown"
64
+
65
+
66
+ @dataclass
67
+ class ParsedConstraint:
68
+ key: str
69
+ operator: str # "<=", ">=", "==", "!=", "in", "not_in", "between"
70
+ value: Union[int, float, str, list]
71
+ unit: str | None
72
+
73
+
74
+ @dataclass
75
+ class EligibilityCriterion:
76
+ key: str
77
+ type: str # "inclusion" | "exclusion"
78
+ description: str
79
+ raw_criteria: str # verbatim criterion text preserved for patient transparency
80
+ constraint: ParsedConstraint | None
81
+
82
+
83
+ @dataclass
84
+ class CriterionAssessment:
85
+ criterion: EligibilityCriterion
86
+ verdict: CriterionVerdict
87
+ reason: str
88
+ patient_value: str | None
89
+ confidence: str # "high" (deterministic) | "medium" | "low" (LLM)
90
+
91
+
92
+ @dataclass
93
+ class TrialEligibilityReport:
94
+ nct_id: str
95
+ overall_verdict: CriterionVerdict
96
+ assessments: list[CriterionAssessment]
97
+ missing_data_keys: list[str]
98
+
99
+
100
  def geocode_zip(zip_code: str, country_code: str = "US") -> tuple[float, float]:
101
  resp = httpx.get(
102
  "https://nominatim.openstreetmap.org/search",
prompts.py CHANGED
@@ -35,8 +35,8 @@ Collect the following through a warm, conversational interview — do NOT presen
35
  REQUIRED:
36
  • Disease/condition (standardize: "Lou Gehrig's" → "Amyotrophic Lateral Sclerosis")
37
  • Patient age
38
- Months since first symptom onset (convert dates/years as needed)
39
- Months since formal/official diagnosis (convert dates/years as needed; may differ from onset)
40
  • ZIP/postal code and country for geographic search
41
 
42
  OPTIONAL (disease-specific benchmarks):
@@ -73,7 +73,7 @@ OPTIONAL (disease-specific benchmarks):
73
  but may be an option when no approved treatments remain.
74
  - Or any combination; or all types (default if no preference)
75
 
76
- Ask naturally. You may infer disease synonyms and convert dates to months, but never infer or skip the ZIP/postal code — always ask the patient for it directly. Once you have every required field confirmed by the patient, call submit_profile.\
77
  """
78
 
79
 
@@ -110,9 +110,18 @@ Workflow:
110
  **Contact:** [Phone number] | [Email address] (use "Not listed" for any missing field)
111
  **Summary:** [2–3 sentence plain-language description of what the trial/program is testing
112
  and why it may matter for this patient]
113
- **Qualification criteria:** [Key inclusion AND exclusion criteria relevant to this patient,
114
- including age range, functional score thresholds, FVC cutoffs,
115
- and any red flags. Be specific use exact numbers from the data.]
 
 
 
 
 
 
 
 
 
116
  **Link:** https://clinicaltrials.gov/study/[NCT_ID]
117
 
118
  ---
 
35
  REQUIRED:
36
  • Disease/condition (standardize: "Lou Gehrig's" → "Amyotrophic Lateral Sclerosis")
37
  • Patient age
38
+ Date of first symptom onset (ask for month and year; submit as onset_date in YYYY-MM format)
39
+ Date of formal/official diagnosis (ask for month and year; submit as diagnosis_date in YYYY-MM format; may differ from onset)
40
  • ZIP/postal code and country for geographic search
41
 
42
  OPTIONAL (disease-specific benchmarks):
 
73
  but may be an option when no approved treatments remain.
74
  - Or any combination; or all types (default if no preference)
75
 
76
+ Ask naturally. You may infer disease synonyms. For onset and diagnosis dates, always submit the raw YYYY-MM date strings (onset_date, diagnosis_date) never compute months yourself. Never infer or skip the ZIP/postal code — always ask the patient for it directly. Once you have every required field confirmed by the patient, call submit_profile.\
77
  """
78
 
79
 
 
110
  **Contact:** [Phone number] | [Email address] (use "Not listed" for any missing field)
111
  **Summary:** [2–3 sentence plain-language description of what the trial/program is testing
112
  and why it may matter for this patient]
113
+ **Eligibility:** Build a checklist from the trial's parsed_criteria and deterministic_verdicts.
114
+ For each criterion in parsed_criteria:
115
+ - If it appears in deterministic_verdicts, use that verdict directly (confidence: high).
116
+ - Otherwise assess it yourself using the patient profile. If patient data is missing → use !.
117
+ Use one line per criterion:
118
+ ✓ [description] — [reason] ← patient meets this criterion
119
+ ✗ [description] — [reason] ← patient does not meet this criterion
120
+ ! [description] — [reason] ← insufficient data to confirm
121
+ After the checklist add one bold summary line:
122
+ **Overall: Eligible** / **Overall: Not eligible** / **Overall: Likely eligible — confirm missing info**
123
+ If any ! criteria exist, add: *Missing info: [list what data would resolve each] — ask your care team.*
124
+ If parsed_criteria is absent for a trial, omit this section entirely.
125
  **Link:** https://clinicaltrials.gov/study/[NCT_ID]
126
 
127
  ---
tools.py CHANGED
@@ -45,5 +45,41 @@ SEARCH_TRIALS_TOOL: anthropic.types.ToolParam = {
45
  "input_schema": _load("search_trials"),
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  INTAKE_TOOLS: list[anthropic.types.ToolParam] = [SUBMIT_PROFILE_TOOL, IDENTIFY_DISEASE_TOOL]
49
  RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_TRIALS_TOOL]
 
 
45
  "input_schema": _load("search_trials"),
46
  }
47
 
48
+ PARSE_CRITERIA_BULK_TOOL: anthropic.types.ToolParam = {
49
+ "name": "parse_criteria_bulk",
50
+ "description": (
51
+ "Parse raw eligibility criteria text for multiple trials in one call. "
52
+ "For each trial, extract every inclusion and exclusion criterion. "
53
+ "Produce a structured constraint where possible (numeric ranges, enums, comparisons); "
54
+ "set constraint to null for vague, subjective, or compound criteria."
55
+ ),
56
+ "input_schema": _load("parse_criteria_bulk"),
57
+ }
58
+
59
+ PARSE_CRITERIA_TOOL: anthropic.types.ToolParam = {
60
+ "name": "parse_criteria",
61
+ "description": (
62
+ "Parse raw eligibility criteria text into structured criterion objects. "
63
+ "For each criterion, extract a canonical key, inclusion/exclusion type, "
64
+ "and a structured constraint where possible (numeric ranges, enums, comparisons). "
65
+ "Set constraint to null for vague, subjective, or compound criteria that cannot "
66
+ "be expressed as a single structured comparison."
67
+ ),
68
+ "input_schema": _load("parse_criteria"),
69
+ }
70
+
71
+ ASSESS_ELIGIBILITY_TOOL: anthropic.types.ToolParam = {
72
+ "name": "assess_eligibility",
73
+ "description": (
74
+ "Assess whether a patient meets eligibility criteria that cannot be evaluated "
75
+ "deterministically. Only called for criteria where a structured constraint is "
76
+ "unavailable or patient data is missing. "
77
+ "Use verdict 'unknown' when patient data is insufficient — never assume 'pass'. "
78
+ "Confidence is 'medium' or 'low' only; 'high' is reserved for deterministic evaluation."
79
+ ),
80
+ "input_schema": _load("assess_eligibility"),
81
+ }
82
+
83
  INTAKE_TOOLS: list[anthropic.types.ToolParam] = [SUBMIT_PROFILE_TOOL, IDENTIFY_DISEASE_TOOL]
84
  RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_TRIALS_TOOL]
85
+ ELIGIBILITY_TOOLS: list[anthropic.types.ToolParam] = [PARSE_CRITERIA_TOOL, ASSESS_ELIGIBILITY_TOOL]
trials_api.py CHANGED
@@ -26,7 +26,7 @@ def search_trials_api(
26
  "query.cond": condition,
27
  "filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
28
  "filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
29
- "pageSize": 200,
30
  "format": "json",
31
  }
32
  # aggFilters supports comma-separated keys (e.g. "studyType:exp,phase:3 4").
@@ -106,6 +106,8 @@ def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: floa
106
  contacts_mod = proto.get("contactsLocationsModule", {})
107
  sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
108
  design_mod = proto.get("designModule", {})
 
 
109
 
110
  central_contacts = contacts_mod.get("centralContacts", [])
111
  central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
@@ -150,10 +152,25 @@ def _flatten_and_rank(studies: list[dict], patient_lat: float, patient_lon: floa
150
  "principal_investigator": pi,
151
  "contact_phone": central_phone,
152
  "contact_email": central_email,
153
- "summary": desc_mod.get("briefSummary", "")[:500],
154
- "eligibility": elig_mod.get("eligibilityCriteria", "")[:1000],
155
  "min_age": elig_mod.get("minimumAge", ""),
156
  "max_age": elig_mod.get("maximumAge", ""),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  "closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
158
  "nearest_sites": [info for _, info in sites_with_dist[:5]],
159
  })
 
26
  "query.cond": condition,
27
  "filter.overallStatus": "AVAILABLE" if is_eap else "RECRUITING",
28
  "filter.geo": f"distance({lat},{lon},{radius_miles}mi)",
29
+ "pageSize": 1000,
30
  "format": "json",
31
  }
32
  # aggFilters supports comma-separated keys (e.g. "studyType:exp,phase:3 4").
 
106
  contacts_mod = proto.get("contactsLocationsModule", {})
107
  sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
108
  design_mod = proto.get("designModule", {})
109
+ conditions_mod = proto.get("conditionsModule", {})
110
+ arms_mod = proto.get("armsInterventionsModule", {})
111
 
112
  central_contacts = contacts_mod.get("centralContacts", [])
113
  central_phone = next((c.get("phone", "") for c in central_contacts if c.get("phone")), "")
 
152
  "principal_investigator": pi,
153
  "contact_phone": central_phone,
154
  "contact_email": central_email,
155
+ "summary": desc_mod.get("briefSummary", ""),
156
+ "eligibility": elig_mod.get("eligibilityCriteria", ""),
157
  "min_age": elig_mod.get("minimumAge", ""),
158
  "max_age": elig_mod.get("maximumAge", ""),
159
+ "sex": elig_mod.get("sex", "ALL"),
160
+ "healthy_volunteers": elig_mod.get("healthyVolunteers", ""),
161
+ "std_ages": elig_mod.get("stdAges", []),
162
+ "study_type": design_mod.get("studyType", ""),
163
+ "enrollment": design_mod.get("enrollmentInfo", {}).get("count"),
164
+ "conditions": conditions_mod.get("conditions", []),
165
+ "keywords": conditions_mod.get("keywords", []),
166
+ "interventions": [
167
+ {
168
+ "type": iv.get("type", ""),
169
+ "name": iv.get("name", ""),
170
+ "description": iv.get("description", ""),
171
+ }
172
+ for iv in arms_mod.get("interventions", [])
173
+ ],
174
  "closest_site_miles": round(closest_dist, 1) if closest_dist is not None else None,
175
  "nearest_sites": [info for _, info in sites_with_dist[:5]],
176
  })