KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
a009989
·
unverified ·
1 Parent(s): 80162d5

perf: rank trials by phase, cap at 15, strip bloat, tighten defaults (#25)

Browse files

Trial ranking (Phase 4 > Phase 3 > ... > EAP > Observational) replaces
pure distance sorting so the highest-value trials surface first.

Token reduction (~60-80% of tool-result payload):
- _rank_and_slim: cap at 15 trials before LLM, strip summary/conditions/
keywords/age fields/std_ages/eligibility, reduce nearest_sites 5→3,
drop intervention descriptions
- max_tokens: research 8096→3000, bulk-parse eligibility 8192→4096

Default search radius: 100 miles → 20 miles

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

agents/eligibility.py CHANGED
@@ -37,6 +37,14 @@ For each criterion:
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}
@@ -93,22 +101,36 @@ _OPERATORS = {
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,
@@ -328,7 +350,7 @@ def bulk_parse_and_strip(
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"},
 
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
+ Canonical key names — always use these exact strings for the corresponding criteria:
41
+ Any "time since symptom/disease/weakness/condition onset", "disease duration", \
42
+ "duration of symptoms" → key: "symptom_onset_months" (value in months)
43
+ Any "time since diagnosis", "diagnosed within" → key: "diagnosis_months" (value in months)
44
+ Age → key: "age_years" (value in years)
45
+ When the trial states a threshold in years for a _months key, keep the value in months \
46
+ (e.g. "onset within 2 years" → value: 24, unit: "months").
47
+
48
  Express constraints as the condition the patient must meet to qualify:
49
  "Age 18-75" → {operator: "between", value: [18, 75]}
50
  "No prior systemic therapy" → {operator: "==", value: 0}
 
101
  "between": lambda pv, v: v[0] <= pv <= v[1],
102
  }
103
 
104
+ _MONTHS_KEYS = {"symptom_onset_months", "diagnosis_months"}
105
+
106
+
107
+ def _normalize_to_patient_units(
108
+ key: str, value: object, unit: str | None
109
+ ) -> tuple[object, str | None]:
110
+ """Convert constraint value to the same unit as the patient field (always months for time keys)."""
111
+ if key in _MONTHS_KEYS and unit == "years":
112
+ if isinstance(value, list):
113
+ return [int(v * 12) for v in value], "months"
114
+ return int(value * 12), "months"
115
+ return value, unit
116
+
117
 
118
  def _evaluate_deterministic(
119
  criterion: EligibilityCriterion,
120
  patient_value: object,
121
  ) -> CriterionAssessment:
122
  c = criterion.constraint
123
+ norm_value, norm_unit = _normalize_to_patient_units(c.key, c.value, c.unit)
124
  fn = _OPERATORS.get(c.operator)
125
  try:
126
+ passes = fn(patient_value, norm_value)
127
  except (TypeError, ValueError):
128
  passes = False
129
 
130
  verdict = CriterionVerdict.PASS if passes else CriterionVerdict.FAIL
131
+ unit_str = f" {norm_unit}" if norm_unit else ""
132
  reason = (
133
+ f"Requires {c.operator} {norm_value}{unit_str}; patient value: {patient_value}"
134
  )
135
  return CriterionAssessment(
136
  criterion=criterion,
 
350
  try:
351
  response = client.messages.create(
352
  model=ELIGIBILITY_MODEL,
353
+ max_tokens=4096,
354
  system=_PARSE_SYSTEM,
355
  tools=[PARSE_CRITERIA_BULK_TOOL],
356
  tool_choice={"type": "tool", "name": "parse_criteria_bulk"},
agents/intake.py CHANGED
@@ -109,7 +109,7 @@ def run_intake_agent(client: anthropic.Anthropic) -> PatientProfile:
109
  country_code=data.get("country_code", "US"),
110
  lat=lat,
111
  lon=lon,
112
- radius_miles=data.get("radius_miles", 100),
113
  phases=data.get("phases") or [],
114
  include_eap=data.get("include_eap", False),
115
  include_observational=data.get("include_observational", False),
@@ -224,7 +224,7 @@ def stream_intake_turn(
224
  country_code=data.get("country_code", "US"),
225
  lat=lat,
226
  lon=lon,
227
- radius_miles=data.get("radius_miles", 100),
228
  phases=data.get("phases") or [],
229
  include_eap=data.get("include_eap", False),
230
  include_observational=data.get("include_observational", False),
 
109
  country_code=data.get("country_code", "US"),
110
  lat=lat,
111
  lon=lon,
112
+ radius_miles=data.get("radius_miles", 20),
113
  phases=data.get("phases") or [],
114
  include_eap=data.get("include_eap", False),
115
  include_observational=data.get("include_observational", False),
 
224
  country_code=data.get("country_code", "US"),
225
  lat=lat,
226
  lon=lon,
227
+ radius_miles=data.get("radius_miles", 20),
228
  phases=data.get("phases") or [],
229
  include_eap=data.get("include_eap", False),
230
  include_observational=data.get("include_observational", False),
agents/research.py CHANGED
@@ -18,6 +18,61 @@ from _console import console
18
 
19
  _logger = get_logger("agents.research")
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) -> str:
23
  messages: list[anthropic.types.MessageParam] = [
@@ -33,7 +88,7 @@ def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) ->
33
  while True:
34
  response = client.messages.create(
35
  model=RESEARCH_MODEL,
36
- max_tokens=8096,
37
  system=cached_system(RESEARCH_SYSTEM),
38
  tools=cached_tools(RESEARCH_TOOLS),
39
  messages=messages,
@@ -74,7 +129,7 @@ def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) ->
74
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
75
  ranked = bulk_parse_and_strip(client, ranked, profile)
76
  console.print(f" [green]✓[/green] {len(ranked)} trial(s) found.")
77
- content = json.dumps(ranked)
78
  is_error = False
79
  except Exception as exc:
80
  console.print(f"[red bold]API error:[/red bold] {exc}")
@@ -113,7 +168,7 @@ def stream_research_agent(
113
  stream_text = ""
114
  with client.messages.stream(
115
  model=RESEARCH_MODEL,
116
- max_tokens=8096,
117
  system=cached_system(LANGUAGE_DIRECTIVE[profile.lang] + RESEARCH_SYSTEM),
118
  tools=cached_tools(RESEARCH_TOOLS),
119
  messages=messages,
@@ -152,7 +207,7 @@ def stream_research_agent(
152
  yield ("status", f"Found **{n}** {type_label} — checking eligibility for the {min(5, n)} closest…")
153
  ranked = bulk_parse_and_strip(client, ranked, profile)
154
  yield ("status", "Eligibility analysis complete — generating your report…")
155
- content = json.dumps(ranked)
156
  is_error = False
157
  except Exception as exc:
158
  content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
 
18
 
19
  _logger = get_logger("agents.research")
20
 
21
+ _MAX_TRIALS_FOR_LLM = 15
22
+
23
+ # Fields with no synthesis value once eligibility is parsed; stripping them
24
+ # shrinks the tool-result payload significantly (nearest_sites alone is ~250 tokens/trial).
25
+ _STRIP_BEFORE_LLM = {
26
+ "summary", # LLM writes its own plain-language summary
27
+ "conditions", # patient already knows their disease
28
+ "keywords",
29
+ "min_age", "max_age", "sex", "healthy_volunteers", # in parsed_criteria after bulk parse
30
+ "std_ages",
31
+ "eligibility", # raw text; replaced by parsed_criteria for top-5
32
+ }
33
+
34
+
35
+ _PHASE_PRIORITY: dict[str, int] = {
36
+ "PHASE4": 1,
37
+ "PHASE3": 2,
38
+ "PHASE2": 3,
39
+ "PHASE1": 4,
40
+ "EARLY_PHASE1": 5,
41
+ "NA": 6,
42
+ }
43
+
44
+
45
+ def _phase_rank(trial: dict) -> int:
46
+ """Lower = higher priority. Phase 4 > Phase 3 > ... > EAP > Observational."""
47
+ study_type = trial.get("study_type", "")
48
+ if study_type == "EXPANDED_ACCESS":
49
+ return 7
50
+ if study_type == "OBSERVATIONAL":
51
+ return 8
52
+ phase_str = trial.get("phase", "N/A")
53
+ phases = [p.strip() for p in phase_str.replace(" ", "").split(",") if p.strip()]
54
+ return min((_PHASE_PRIORITY.get(p, 6) for p in phases), default=6)
55
+
56
+
57
+ def _rank_and_slim(trials: list[dict]) -> list[dict]:
58
+ """Sort by phase priority then distance, cap at _MAX_TRIALS_FOR_LLM, strip bloat."""
59
+ ranked = sorted(
60
+ trials,
61
+ key=lambda t: (_phase_rank(t), t.get("closest_site_miles") or float("inf")),
62
+ )
63
+ slimmed = []
64
+ for t in ranked[:_MAX_TRIALS_FOR_LLM]:
65
+ t = {k: v for k, v in t.items() if k not in _STRIP_BEFORE_LLM}
66
+ if "nearest_sites" in t:
67
+ t["nearest_sites"] = t["nearest_sites"][:3]
68
+ if "interventions" in t:
69
+ t["interventions"] = [
70
+ {"type": iv.get("type", ""), "name": iv.get("name", "")}
71
+ for iv in t["interventions"]
72
+ ]
73
+ slimmed.append(t)
74
+ return slimmed
75
+
76
 
77
  def run_research_agent(client: anthropic.Anthropic, profile: PatientProfile) -> str:
78
  messages: list[anthropic.types.MessageParam] = [
 
88
  while True:
89
  response = client.messages.create(
90
  model=RESEARCH_MODEL,
91
+ max_tokens=3000,
92
  system=cached_system(RESEARCH_SYSTEM),
93
  tools=cached_tools(RESEARCH_TOOLS),
94
  messages=messages,
 
129
  ranked = _flatten_and_rank(studies, profile.lat, profile.lon)
130
  ranked = bulk_parse_and_strip(client, ranked, profile)
131
  console.print(f" [green]✓[/green] {len(ranked)} trial(s) found.")
132
+ content = json.dumps(_rank_and_slim(ranked))
133
  is_error = False
134
  except Exception as exc:
135
  console.print(f"[red bold]API error:[/red bold] {exc}")
 
168
  stream_text = ""
169
  with client.messages.stream(
170
  model=RESEARCH_MODEL,
171
+ max_tokens=3000,
172
  system=cached_system(LANGUAGE_DIRECTIVE[profile.lang] + RESEARCH_SYSTEM),
173
  tools=cached_tools(RESEARCH_TOOLS),
174
  messages=messages,
 
207
  yield ("status", f"Found **{n}** {type_label} — checking eligibility for the {min(5, n)} closest…")
208
  ranked = bulk_parse_and_strip(client, ranked, profile)
209
  yield ("status", "Eligibility analysis complete — generating your report…")
210
+ content = json.dumps(_rank_and_slim(ranked))
211
  is_error = False
212
  except Exception as exc:
213
  content = f"API request failed: {exc}. The ClinicalTrials.gov endpoint may be temporarily unavailable."
data/tools/submit_profile.json CHANGED
@@ -34,7 +34,7 @@
34
  },
35
  "radius_miles": {
36
  "type": "integer",
37
- "description": "Search radius in miles from patient location (default 100)"
38
  },
39
  "phases": {
40
  "type": "array",
 
34
  },
35
  "radius_miles": {
36
  "type": "integer",
37
+ "description": "Search radius in miles from patient location (default 20)"
38
  },
39
  "phases": {
40
  "type": "array",
models.py CHANGED
@@ -19,7 +19,7 @@ class PatientProfile:
19
  country_code: str = "US"
20
  lat: float = 0.0
21
  lon: float = 0.0
22
- radius_miles: int = 100
23
  phases: list[str] = field(default_factory=list)
24
  include_eap: bool = False
25
  include_observational: bool = False
 
19
  country_code: str = "US"
20
  lat: float = 0.0
21
  lon: float = 0.0
22
+ radius_miles: int = 20
23
  phases: list[str] = field(default_factory=list)
24
  include_eap: bool = False
25
  include_observational: bool = False
tests/agents/test_eligibility.py CHANGED
@@ -177,6 +177,37 @@ class TestEvaluateDeterministic:
177
  a = _evaluate_deterministic(c, 52)
178
  assert a.verdict == CriterionVerdict.FAIL
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  # ---------------------------------------------------------------------------
182
  # _resolve_patient_value
@@ -193,6 +224,12 @@ class TestResolvePatientValue:
193
  assert found is True
194
  assert val == 18
195
 
 
 
 
 
 
 
196
  def test_patient_diagnosis_months_resolves(self, als_patient):
197
  val, found = _resolve_patient_value("diagnosis_months", als_patient, None)
198
  assert found is True
 
177
  a = _evaluate_deterministic(c, 52)
178
  assert a.verdict == CriterionVerdict.FAIL
179
 
180
+ # --- unit normalization: years → months ---
181
+
182
+ def test_onset_years_converted_to_months_fails_correctly(self):
183
+ # Trial says "onset >= 18 years"; patient has 20 months — should FAIL (20 < 216)
184
+ c = make_criterion(key="symptom_onset_months", operator=">=", value=18, unit="years")
185
+ a = _evaluate_deterministic(c, 20)
186
+ assert a.verdict == CriterionVerdict.FAIL
187
+ assert "216" in a.reason
188
+ assert "months" in a.reason
189
+
190
+ def test_onset_months_unit_unchanged(self):
191
+ # Trial says "onset >= 18 months"; patient has 20 months — should PASS, value stays 18
192
+ c = make_criterion(key="symptom_onset_months", operator=">=", value=18, unit="months")
193
+ a = _evaluate_deterministic(c, 20)
194
+ assert a.verdict == CriterionVerdict.PASS
195
+ assert "18 months" in a.reason
196
+
197
+ def test_diagnosis_years_converted_to_months(self):
198
+ # Trial says "diagnosis >= 2 years"; patient has 18 months — should FAIL (18 < 24)
199
+ c = make_criterion(key="diagnosis_months", operator=">=", value=2, unit="years")
200
+ a = _evaluate_deterministic(c, 18)
201
+ assert a.verdict == CriterionVerdict.FAIL
202
+ assert "24" in a.reason
203
+
204
+ def test_between_years_normalized_to_months(self):
205
+ # Trial says "onset between 1 and 2 years" → [12, 24] months; patient 20 months → PASS
206
+ c = make_criterion(key="symptom_onset_months", operator="between", value=[1, 2], unit="years")
207
+ a = _evaluate_deterministic(c, 20)
208
+ assert a.verdict == CriterionVerdict.PASS
209
+ assert "12" in a.reason and "24" in a.reason
210
+
211
 
212
  # ---------------------------------------------------------------------------
213
  # _resolve_patient_value
 
224
  assert found is True
225
  assert val == 18
226
 
227
+ def test_non_canonical_onset_key_not_found(self, als_patient):
228
+ # "weakness_onset_months" is not in _KEY_MAP — confirms the parser must use
229
+ # the canonical "symptom_onset_months" key for deterministic evaluation to work
230
+ _, found = _resolve_patient_value("weakness_onset_months", als_patient, None)
231
+ assert found is False
232
+
233
  def test_patient_diagnosis_months_resolves(self, als_patient):
234
  val, found = _resolve_patient_value("diagnosis_months", als_patient, None)
235
  assert found is True
tests/agents/test_research.py CHANGED
@@ -12,11 +12,77 @@ from unittest.mock import MagicMock, patch
12
 
13
  import pytest
14
 
15
- from agents.research import run_research_agent, stream_research_agent
16
  from models import PatientProfile
17
  from tests.conftest import FakeStream, make_message, make_text_block, make_tool_use_block
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  PATCH_SEARCH = "agents.research.search_trials_api"
21
  PATCH_FLATTEN = "agents.research._flatten_and_rank"
22
  PATCH_BULK = "agents.research.bulk_parse_and_strip"
 
12
 
13
  import pytest
14
 
15
+ from agents.research import run_research_agent, stream_research_agent, _phase_rank, _rank_and_slim
16
  from models import PatientProfile
17
  from tests.conftest import FakeStream, make_message, make_text_block, make_tool_use_block
18
 
19
 
20
+ # ---------------------------------------------------------------------------
21
+ # _phase_rank and _rank_and_slim
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _trial(study_type="INTERVENTIONAL", phase="PHASE3", distance=10.0, **extra):
25
+ return {"study_type": study_type, "phase": phase, "closest_site_miles": distance, **extra}
26
+
27
+
28
+ class TestPhaseRank:
29
+ def test_phase4_beats_phase3(self):
30
+ assert _phase_rank(_trial(phase="PHASE4")) < _phase_rank(_trial(phase="PHASE3"))
31
+
32
+ def test_phase3_beats_phase2(self):
33
+ assert _phase_rank(_trial(phase="PHASE3")) < _phase_rank(_trial(phase="PHASE2"))
34
+
35
+ def test_phase2_beats_phase1(self):
36
+ assert _phase_rank(_trial(phase="PHASE2")) < _phase_rank(_trial(phase="PHASE1"))
37
+
38
+ def test_phase1_beats_eap(self):
39
+ assert _phase_rank(_trial(phase="PHASE1")) < _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase=""))
40
+
41
+ def test_eap_beats_observational(self):
42
+ assert _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase="")) < _phase_rank(_trial(study_type="OBSERVATIONAL", phase=""))
43
+
44
+ def test_na_phase_interventional_between_phase1_and_eap(self):
45
+ rank_na = _phase_rank(_trial(phase="NA"))
46
+ assert _phase_rank(_trial(phase="PHASE1")) < rank_na
47
+ assert rank_na < _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase=""))
48
+
49
+
50
+ class TestRankAndSlim:
51
+ def test_phase4_sorted_before_phase3(self):
52
+ trials = [_trial(phase="PHASE3", nct_id="B"), _trial(phase="PHASE4", nct_id="A")]
53
+ result = _rank_and_slim(trials)
54
+ assert result[0]["nct_id"] == "A"
55
+
56
+ def test_within_same_phase_closer_first(self):
57
+ trials = [_trial(phase="PHASE3", distance=50.0, nct_id="far"), _trial(phase="PHASE3", distance=5.0, nct_id="near")]
58
+ result = _rank_and_slim(trials)
59
+ assert result[0]["nct_id"] == "near"
60
+
61
+ def test_capped_at_max_trials(self):
62
+ trials = [_trial(phase="PHASE2", nct_id=str(i)) for i in range(20)]
63
+ result = _rank_and_slim(trials)
64
+ assert len(result) <= 15
65
+
66
+ def test_strips_summary_and_conditions(self):
67
+ trials = [_trial(phase="PHASE3", summary="long text", conditions=["ALS"])]
68
+ result = _rank_and_slim(trials)
69
+ assert "summary" not in result[0]
70
+ assert "conditions" not in result[0]
71
+
72
+ def test_nearest_sites_capped_at_3(self):
73
+ sites = [{"label": f"Site {i}"} for i in range(5)]
74
+ trials = [_trial(phase="PHASE3", nearest_sites=sites)]
75
+ result = _rank_and_slim(trials)
76
+ assert len(result[0]["nearest_sites"]) == 3
77
+
78
+ def test_intervention_description_stripped(self):
79
+ iv = [{"type": "DRUG", "name": "DrugX", "description": "long description text"}]
80
+ trials = [_trial(phase="PHASE3", interventions=iv)]
81
+ result = _rank_and_slim(trials)
82
+ assert "description" not in result[0]["interventions"][0]
83
+ assert result[0]["interventions"][0]["name"] == "DrugX"
84
+
85
+
86
  PATCH_SEARCH = "agents.research.search_trials_api"
87
  PATCH_FLATTEN = "agents.research._flatten_and_rank"
88
  PATCH_BULK = "agents.research.bulk_parse_and_strip"