Spaces:
Sleeping
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 +100 -0
- agents/eligibility.py +441 -0
- agents/intake.py +21 -4
- agents/research.py +12 -2
- app.py +27 -3
- config.py +1 -0
- data/criterion_keys.json +14 -0
- data/tools/assess_eligibility.json +25 -0
- data/tools/parse_criteria.json +34 -0
- data/tools/parse_criteria_bulk.json +44 -0
- data/tools/submit_profile.json +10 -2
- design/eligibility-assessment.md +364 -0
- models.py +42 -0
- prompts.py +15 -6
- tools.py +36 -0
- trials_api.py +20 -3
|
@@ -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` |
|
|
@@ -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"
|
|
@@ -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
|
| 88 |
-
diagnosis_months=
|
| 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
|
| 203 |
-
diagnosis_months=
|
| 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"),
|
|
@@ -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=
|
| 140 |
phases=args.get("phases") or None,
|
| 141 |
-
study_type=
|
| 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:
|
|
@@ -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"],
|
|
@@ -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"
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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",
|
|
@@ -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.
|
|
@@ -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",
|
|
@@ -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 |
-
•
|
| 39 |
-
•
|
| 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
|
| 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 |
-
**
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
---
|
|
@@ -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]
|
|
@@ -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":
|
| 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", "")
|
| 154 |
-
"eligibility": elig_mod.get("eligibilityCriteria", "")
|
| 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 |
})
|