Spaces:
Sleeping
Sleeping
KevinIsInCoding Claude Sonnet 4.6 commited on
perf: rank trials by phase, cap at 15, strip bloat, tighten defaults (#25)
Browse filesTrial 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 +26 -4
- agents/intake.py +2 -2
- agents/research.py +59 -4
- data/tools/submit_profile.json +1 -1
- models.py +1 -1
- tests/agents/test_eligibility.py +37 -0
- tests/agents/test_research.py +67 -1
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,
|
| 105 |
except (TypeError, ValueError):
|
| 106 |
passes = False
|
| 107 |
|
| 108 |
verdict = CriterionVerdict.PASS if passes else CriterionVerdict.FAIL
|
| 109 |
-
unit_str = f" {
|
| 110 |
reason = (
|
| 111 |
-
f"Requires {c.operator} {
|
| 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=
|
| 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",
|
| 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",
|
| 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=
|
| 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=
|
| 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
|
| 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 =
|
| 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"
|