ictrp-mcp-server 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,56 @@
|
|
|
3
3
|
Notable changes to the npm package `ictrp-mcp-server`. The Python package
|
|
4
4
|
`ictrp-mcp-service` is versioned separately in `pyproject.toml`.
|
|
5
5
|
|
|
6
|
+
## 0.1.2
|
|
7
|
+
|
|
8
|
+
**Default search view is now the clinician-triage view.** `ictrp_search` used to
|
|
9
|
+
return the 10 most common columns; it now returns the columns a clinician needs
|
|
10
|
+
to triage a target: title and scientific title, sponsor and secondary sponsor,
|
|
11
|
+
contact (PI) name/affiliation/email, recruitment status, phase, registration
|
|
12
|
+
date, condition, countries, age bounds and gender, target size, and a
|
|
13
|
+
`line_of_therapy_hint`. A new derived field, `line_of_therapy_hint`, is a
|
|
14
|
+
non-authoritative extraction of treatment line from the free-text
|
|
15
|
+
inclusion/exclusion criteria and the scientific title. It returns
|
|
16
|
+
`first-line`/`second-line`/`third-line`/`fourth-line`/`later-line`, or `null`
|
|
17
|
+
when nothing is stated -- `null` is documented as "not stated", never as
|
|
18
|
+
"first-line", because the export is incomplete and the column does not exist.
|
|
19
|
+
|
|
20
|
+
**Line-hint fix** — the hint missed trials whose line is stated only as a number
|
|
21
|
+
or only in the scientific title (e.g. "Received >=2 Prior Lines of Therapy"). It
|
|
22
|
+
now scans the scientific title and recognizes numeric line statements; a minimum
|
|
23
|
+
of N lines resolves to `first-line` only when N<=1, otherwise `later-line`.
|
|
24
|
+
|
|
25
|
+
Documented structural limits surfaced by the wider view: ICTRP has no "PI" and
|
|
26
|
+
no "participating-hospital list" column -- sponsor/PI come from
|
|
27
|
+
`primary_sponsor` + `contact_*`, and "participating hospitals" degrades to
|
|
28
|
+
`countries` + `contact_affiliation`. CT.gov `exclusion_criteria` is frequently
|
|
29
|
+
blank upstream, which is a source gap, not a parse failure.
|
|
30
|
+
|
|
31
|
+
## 0.1.1
|
|
32
|
+
|
|
33
|
+
**Retry on transient stalls** — the search POST intermittently stalls past a
|
|
34
|
+
minute. Measured: a `YL201` search returned `httpx.ReadTimeout` at a 60s budget
|
|
35
|
+
while a plain `GET /` on the same host answered HTTP 200 in 1.4s, so the stall
|
|
36
|
+
is in the POST chain rather than the host being down. Each of the three steps
|
|
37
|
+
(`load_form`, `search`, `export_csv`) is now retried with exponential backoff
|
|
38
|
+
and jitter, and the default per-request timeout went from 60s to 120s.
|
|
39
|
+
|
|
40
|
+
Only transport failures and `SESSION_FAILED` are retried. `UPSTREAM_BLOCKED` is
|
|
41
|
+
not: a refusal is information, and retrying into an active refusal both wastes
|
|
42
|
+
the budget and delays the diagnosis.
|
|
43
|
+
|
|
44
|
+
Two error-message fixes fell out of the same investigation:
|
|
45
|
+
|
|
46
|
+
- `httpx.ReadTimeout` stringifies to an empty message, which is how a real
|
|
47
|
+
failure reached a user as `Search request failed: ` with nothing after the
|
|
48
|
+
colon. Transport errors are now named when they carry no text.
|
|
49
|
+
- An exhausted retry used to repeat the per-attempt hint "then retry". It now
|
|
50
|
+
reports that the retries already happened and names `ICTRP_TIMEOUT` as the
|
|
51
|
+
next thing to raise.
|
|
52
|
+
|
|
53
|
+
New environment variables: `ICTRP_TIMEOUT` (default 120),
|
|
54
|
+
`ICTRP_ATTEMPTS` (default 3), `ICTRP_BACKOFF_SECONDS` (default 2, capped at 30).
|
|
55
|
+
|
|
6
56
|
## 0.1.0
|
|
7
57
|
|
|
8
58
|
First release.
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { getEnvReport } from "./runtime/env-probe.js";
|
|
|
21
21
|
import { callSidecar, pingSidecar, SEARCH_TIMEOUT_MS, toErrorText, } from "./runtime/sidecar-client.js";
|
|
22
22
|
import { ensureSidecar, installShutdownHooks, sidecarLogTail } from "./runtime/supervisor.js";
|
|
23
23
|
const SERVER_NAME = "ictrp-mcp-server";
|
|
24
|
-
const SERVER_VERSION = "0.1.
|
|
24
|
+
const SERVER_VERSION = "0.1.2";
|
|
25
25
|
/**
|
|
26
26
|
* Attached to every payload that returns rows.
|
|
27
27
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ictrp-mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "MCP server for the WHO International Clinical Trials Registry Platform (ICTRP). Plain HTTP, no browser. Every response is explicit about the export's known incompleteness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -323,6 +323,18 @@ def to_trial(row: list[str], header: tuple[str, ...]) -> dict[str, Any]:
|
|
|
323
323
|
trial["inclusion_age_min"] = _as_int(age_min)
|
|
324
324
|
trial["inclusion_age_max"] = _as_int(age_max)
|
|
325
325
|
|
|
326
|
+
# ICTRP has no structured "line of therapy" column. Treatment line is stated
|
|
327
|
+
# only inside the free-text inclusion/exclusion criteria (e.g. "first-line",
|
|
328
|
+
# "previously treated", "一线") and, frequently, the scientific title
|
|
329
|
+
# ("...Received >=2 Prior Lines of Therapy"). Derive a non-authoritative hint
|
|
330
|
+
# from that text so a caller can surface it without re-parsing. It is a hint,
|
|
331
|
+
# never a fact: absence here does NOT mean the trial is treatment-naive.
|
|
332
|
+
trial["line_of_therapy_hint"] = derive_line_of_therapy_hint(
|
|
333
|
+
raw.get("inclusion_criteria"),
|
|
334
|
+
raw.get("exclusion_criteria"),
|
|
335
|
+
raw.get("scientific_title"),
|
|
336
|
+
)
|
|
337
|
+
|
|
326
338
|
# `results yes no` is a flag, not content, so it is excluded from the test.
|
|
327
339
|
# Measured: the substantive `results *` columns are ~0.0% populated for
|
|
328
340
|
# ChiCTR records, so this is a property of the registry rather than of an
|
|
@@ -346,3 +358,66 @@ def _as_int(value: str | None) -> int | None:
|
|
|
346
358
|
return None
|
|
347
359
|
m = re.search(r"-?\d+", text)
|
|
348
360
|
return int(m.group(0)) if m else None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
#: Treatment-line vocabulary, as it actually appears in live inclusion/exclusion
|
|
364
|
+
#: text. Both English and Chinese markers are matched; "refractory"/"relapsed"/
|
|
365
|
+
#: "previously treated" indicate a later line without naming a number.
|
|
366
|
+
_LINE_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
367
|
+
(r"first[-\s]?line|1st[-\s]?line|一线|treatment[-\s]?naive|previously untreated", "first-line"),
|
|
368
|
+
(r"second[-\s]?line|2nd[-\s]?line|二线", "second-line"),
|
|
369
|
+
(r"third[-\s]?line|3rd[-\s]?line|三线", "third-line"),
|
|
370
|
+
(r"fourth[-\s]?line|4th[-\s]?line|四线", "fourth-line"),
|
|
371
|
+
(r"relapsed|refractory|previously treated|prior (therapy|treatment)|已接受.*治疗|经治", "later-line"),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
#: Numeric "line" statements, e.g. ">=2 Prior Lines of Therapy" or "2-line",
|
|
375
|
+
#: where a word may sit between the number and "line" ("2 prior lines"). The
|
|
376
|
+
#: captured number is the *minimum* line; ">=2" means second-line or later, never
|
|
377
|
+
#: first-line, so it resolves to `later-line` rather than a specific line.
|
|
378
|
+
_LINE_NUMBER = re.compile(r"(\d+)\s*(?:[-\w.,]+\s)*?(?:line|lines|线)")
|
|
379
|
+
|
|
380
|
+
_WORD_NUMBERS = {
|
|
381
|
+
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
|
382
|
+
"一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
|
|
383
|
+
}
|
|
384
|
+
_WORD_NUMBER = re.compile(r"(one|two|three|four|five|一|二|三|四|五)\s*(?:line|lines|线)")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def derive_line_of_therapy_hint(
|
|
388
|
+
inclusion: str | None, exclusion: str | None, scientific_title: str | None = None
|
|
389
|
+
) -> str | None:
|
|
390
|
+
"""Best-effort treatment-line label from free-text criteria.
|
|
391
|
+
|
|
392
|
+
Returns the earliest explicit line if one is named (first > second > third >
|
|
393
|
+
fourth), else "later-line" when a numeric "N lines" (N>=2) or relapse/
|
|
394
|
+
refractory language is present, else `None`. `None` is deliberate: it means
|
|
395
|
+
"not stated in the criteria we hold", which must not be read as "first-line"
|
|
396
|
+
-- the export is incomplete and the column does not exist.
|
|
397
|
+
|
|
398
|
+
The scientific title is included because the line is often stated only there
|
|
399
|
+
(e.g. "...Received >=2 Prior Lines of Therapy"), not in the criteria body.
|
|
400
|
+
"""
|
|
401
|
+
text = f"{inclusion or ''} {exclusion or ''} {scientific_title or ''}"
|
|
402
|
+
if not text.strip():
|
|
403
|
+
return None
|
|
404
|
+
from .query import _coerce # local import avoids a cycle at module load
|
|
405
|
+
|
|
406
|
+
lowered = _coerce(text)
|
|
407
|
+
|
|
408
|
+
# Explicit worded lines take precedence -- they name a specific line.
|
|
409
|
+
for pattern, label in _LINE_PATTERNS:
|
|
410
|
+
if re.search(pattern, lowered):
|
|
411
|
+
return label
|
|
412
|
+
|
|
413
|
+
# Numeric "N line(s)": a minimum of N, so only the first-line case is exact;
|
|
414
|
+
# anything >=2 is "later-line", never a specific later number.
|
|
415
|
+
for match in _LINE_NUMBER.finditer(lowered):
|
|
416
|
+
if int(match.group(1)) <= 1:
|
|
417
|
+
return "first-line"
|
|
418
|
+
return "later-line"
|
|
419
|
+
for match in _WORD_NUMBER.finditer(lowered):
|
|
420
|
+
if _WORD_NUMBERS[match.group(1)] <= 1:
|
|
421
|
+
return "first-line"
|
|
422
|
+
return "later-line"
|
|
423
|
+
return None
|
|
@@ -19,6 +19,10 @@ Measured facts this module encodes (docs/MEASUREMENTS.md):
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
22
26
|
from dataclasses import dataclass, field
|
|
23
27
|
|
|
24
28
|
import httpx
|
|
@@ -53,6 +57,145 @@ SEARCH_BUTTON = "Button1"
|
|
|
53
57
|
SEARCH_TEXTBOX = "TextBox1"
|
|
54
58
|
|
|
55
59
|
|
|
60
|
+
#: The portal's search POST intermittently stalls past a minute. Measured: a
|
|
61
|
+
#: `YL201` search returned `httpx.ReadTimeout` at a 60s budget while a plain
|
|
62
|
+
#: `GET /` on the same host answered HTTP 200 in 1.4s, so the stall is in the
|
|
63
|
+
#: POST chain rather than in the host being down. Widening the budget is the
|
|
64
|
+
#: cheap half of the fix; see `retry` for the half that actually recovers.
|
|
65
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
66
|
+
ENV_TIMEOUT = "ICTRP_TIMEOUT"
|
|
67
|
+
|
|
68
|
+
#: How many times a single step is attempted before its failure is surfaced.
|
|
69
|
+
#: Three is enough to ride out a transient stall without turning a genuine
|
|
70
|
+
#: outage into a multi-minute hang.
|
|
71
|
+
DEFAULT_ATTEMPTS = 3
|
|
72
|
+
ENV_ATTEMPTS = "ICTRP_ATTEMPTS"
|
|
73
|
+
|
|
74
|
+
#: Base delay between attempts, doubled each time and jittered. Jitter matters
|
|
75
|
+
#: because a client that retries on a fixed cadence can stay in lockstep with
|
|
76
|
+
#: whatever is stalling.
|
|
77
|
+
DEFAULT_BACKOFF_SECONDS = 2.0
|
|
78
|
+
ENV_BACKOFF = "ICTRP_BACKOFF_SECONDS"
|
|
79
|
+
|
|
80
|
+
#: The hard ceiling on a single backoff sleep, so a long search cannot turn
|
|
81
|
+
#: into an unbounded wait.
|
|
82
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
83
|
+
|
|
84
|
+
#: Transport failures and transient portal hiccups. A 429/503 raised as
|
|
85
|
+
#: UPSTREAM_BLOCKED is deliberately excluded: retrying into an active refusal
|
|
86
|
+
#: makes the block worse and hides the diagnosis.
|
|
87
|
+
RETRYABLE_CODES: frozenset = frozenset(
|
|
88
|
+
{ErrorCode.UPSTREAM_ERROR, ErrorCode.SESSION_FAILED}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _env_float(name: str, default: float) -> float:
|
|
93
|
+
raw = os.environ.get(name)
|
|
94
|
+
if raw:
|
|
95
|
+
try:
|
|
96
|
+
value = float(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return default
|
|
99
|
+
return value if value > 0 else default
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _env_int(name: str, default: int) -> int:
|
|
104
|
+
raw = os.environ.get(name)
|
|
105
|
+
if raw:
|
|
106
|
+
try:
|
|
107
|
+
value = int(raw)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return default
|
|
110
|
+
return value if value > 0 else default
|
|
111
|
+
return default
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _describe(exc: httpx.HTTPError) -> str:
|
|
115
|
+
"""Text for a transport error that may legitimately be empty.
|
|
116
|
+
|
|
117
|
+
`httpx.ReadTimeout` stringifies to an empty message, which is how a real
|
|
118
|
+
failure reached a user as `Search request failed: ` with nothing after the
|
|
119
|
+
colon. Name the class when there is no text, so the message is never blank.
|
|
120
|
+
"""
|
|
121
|
+
text = str(exc).strip()
|
|
122
|
+
if text:
|
|
123
|
+
return text
|
|
124
|
+
return f"{type(exc).__name__} (no message)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def timeout_seconds() -> float:
|
|
128
|
+
"""Per-request timeout, overridable for slow or poor links."""
|
|
129
|
+
return _env_float(ENV_TIMEOUT, DEFAULT_TIMEOUT_SECONDS)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def max_attempts() -> int:
|
|
133
|
+
"""Attempts per step, so a transient stall is ridden out rather than raised."""
|
|
134
|
+
return _env_int(ENV_ATTEMPTS, DEFAULT_ATTEMPTS)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def backoff_seconds() -> float:
|
|
138
|
+
return _env_float(ENV_BACKOFF, DEFAULT_BACKOFF_SECONDS)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sleep_for(attempt: int) -> float:
|
|
142
|
+
"""Exponential backoff with jitter, capped.
|
|
143
|
+
|
|
144
|
+
`attempt` is 1-based: the first retry waits ~base, the second ~2x base.
|
|
145
|
+
"""
|
|
146
|
+
delay = backoff_seconds() * (2 ** (attempt - 1))
|
|
147
|
+
delay = min(delay, MAX_BACKOFF_SECONDS)
|
|
148
|
+
return random.uniform(delay * 0.5, delay)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def retry(
|
|
152
|
+
step: str,
|
|
153
|
+
op: Callable[[], Awaitable[object]],
|
|
154
|
+
*,
|
|
155
|
+
attempts: int | None = None,
|
|
156
|
+
sleeper: Callable[[float], Awaitable[None]] | None = None,
|
|
157
|
+
) -> object:
|
|
158
|
+
"""Run `op`, retrying only failures that a retry could plausibly fix.
|
|
159
|
+
|
|
160
|
+
Non-retryable codes propagate on the first attempt. A refusal is
|
|
161
|
+
information -- the caller needs to see "blocked", not "slow" -- and
|
|
162
|
+
retrying it would both waste the budget and delay the diagnosis.
|
|
163
|
+
"""
|
|
164
|
+
total = attempts if attempts is not None else max_attempts()
|
|
165
|
+
sleep = sleeper or asyncio.sleep
|
|
166
|
+
last: IctrpError | None = None
|
|
167
|
+
|
|
168
|
+
for index in range(1, total + 1):
|
|
169
|
+
try:
|
|
170
|
+
return await op()
|
|
171
|
+
except IctrpError as exc:
|
|
172
|
+
if exc.code not in RETRYABLE_CODES:
|
|
173
|
+
raise
|
|
174
|
+
last = exc
|
|
175
|
+
if index == total:
|
|
176
|
+
break
|
|
177
|
+
await sleep(_sleep_for(index))
|
|
178
|
+
|
|
179
|
+
assert last is not None
|
|
180
|
+
# The per-attempt hint ("then retry") is wrong by the time we get here: we
|
|
181
|
+
# have already retried. Speak to what the caller can still do.
|
|
182
|
+
hint = (
|
|
183
|
+
f"Already retried {total} time(s) with backoff; every attempt failed. "
|
|
184
|
+
f"Raise {ENV_TIMEOUT} (currently {timeout_seconds():g}s) to give each "
|
|
185
|
+
f"attempt longer, or retry later -- the portal was answering plain GETs "
|
|
186
|
+
f"while this step stalled."
|
|
187
|
+
)
|
|
188
|
+
raise IctrpError(
|
|
189
|
+
last.code,
|
|
190
|
+
f"{step}: failed after {total} attempt(s) -- {last.message}",
|
|
191
|
+
upstream_status=last.upstream_status,
|
|
192
|
+
upstream_content_type=last.upstream_content_type,
|
|
193
|
+
upstream_body_excerpt=last.upstream_body_excerpt,
|
|
194
|
+
hint=hint,
|
|
195
|
+
detail=f"Retryable failure, exhausted {total} attempts: {last.detail or ''}".strip(),
|
|
196
|
+
) from last
|
|
197
|
+
|
|
198
|
+
|
|
56
199
|
def _check_present(state: htmlstate.FormState, *, step: str) -> None:
|
|
57
200
|
missing = state.missing_state()
|
|
58
201
|
if missing:
|
|
@@ -86,10 +229,13 @@ class IctrpSession:
|
|
|
86
229
|
steps: list[str] = field(default_factory=list)
|
|
87
230
|
|
|
88
231
|
@classmethod
|
|
89
|
-
def create(cls, *, timeout: float =
|
|
232
|
+
def create(cls, *, timeout: float | None = None) -> "IctrpSession":
|
|
233
|
+
# 60s was measured to be too tight: the search POST stalls past it while
|
|
234
|
+
# the host is answering GETs in ~1.4s. Callers that pass nothing get the
|
|
235
|
+
# current default; callers that pass a value still get that value.
|
|
90
236
|
client = httpx.AsyncClient(
|
|
91
237
|
headers=DEFAULT_HEADERS,
|
|
92
|
-
timeout=timeout,
|
|
238
|
+
timeout=timeout if timeout is not None else timeout_seconds(),
|
|
93
239
|
follow_redirects=False,
|
|
94
240
|
)
|
|
95
241
|
return cls(client=client)
|
|
@@ -106,14 +252,18 @@ class IctrpSession:
|
|
|
106
252
|
# ---- step 1 -----------------------------------------------------------
|
|
107
253
|
|
|
108
254
|
async def load_form(self) -> htmlstate.FormState:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
255
|
+
async def attempt_get():
|
|
256
|
+
try:
|
|
257
|
+
return await self.client.get(self.base_url)
|
|
258
|
+
except httpx.HTTPError as exc:
|
|
259
|
+
raise IctrpError(
|
|
260
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
261
|
+
f"Could not reach the ICTRP search portal: {_describe(exc)}",
|
|
262
|
+
hint="Check network connectivity, then retry.",
|
|
263
|
+
) from exc
|
|
264
|
+
|
|
265
|
+
response = await retry("load_form", attempt_get)
|
|
266
|
+
assert isinstance(response, httpx.Response)
|
|
117
267
|
|
|
118
268
|
html = classify_form_page(status=response.status_code, body=response.content)
|
|
119
269
|
self.steps.append(f"GET {self.base_url} -> {response.status_code}")
|
|
@@ -138,20 +288,25 @@ class IctrpSession:
|
|
|
138
288
|
body = self.form_state.merged_with(
|
|
139
289
|
{SEARCH_TEXTBOX: keyword, SEARCH_BUTTON: "Search"}
|
|
140
290
|
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
291
|
+
|
|
292
|
+
async def attempt_post():
|
|
293
|
+
try:
|
|
294
|
+
return await self.client.post(
|
|
295
|
+
self.base_url,
|
|
296
|
+
data=body,
|
|
297
|
+
headers={
|
|
298
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
299
|
+
"Referer": self.base_url,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
except httpx.HTTPError as exc:
|
|
303
|
+
raise IctrpError(
|
|
304
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
305
|
+
f"Search request failed: {_describe(exc)}",
|
|
306
|
+
) from exc
|
|
307
|
+
|
|
308
|
+
response = await retry(f"search {keyword!r}", attempt_post)
|
|
309
|
+
assert isinstance(response, httpx.Response)
|
|
155
310
|
|
|
156
311
|
location = response.headers.get("location")
|
|
157
312
|
if location and "/noaccess.aspx" in location.lower():
|
|
@@ -205,20 +360,24 @@ class IctrpSession:
|
|
|
205
360
|
# can never be reintroduced silently.
|
|
206
361
|
htmlstate.assert_export_body_excludes_search_controls(body)
|
|
207
362
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
self.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
363
|
+
async def attempt_export():
|
|
364
|
+
try:
|
|
365
|
+
return await self.client.post(
|
|
366
|
+
self.base_url,
|
|
367
|
+
data=body,
|
|
368
|
+
headers={
|
|
369
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
370
|
+
"Referer": self.base_url,
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
except httpx.HTTPError as exc:
|
|
374
|
+
raise IctrpError(
|
|
375
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
376
|
+
f"Export request failed: {_describe(exc)}",
|
|
377
|
+
) from exc
|
|
378
|
+
|
|
379
|
+
response = await retry("export_csv", attempt_export)
|
|
380
|
+
assert isinstance(response, httpx.Response)
|
|
222
381
|
|
|
223
382
|
self.steps.append(f"POST export -> {response.status_code}")
|
|
224
383
|
self.response_date = response.headers.get("date")
|
|
@@ -236,7 +395,9 @@ class IctrpSession:
|
|
|
236
395
|
return list(self.steps)
|
|
237
396
|
|
|
238
397
|
|
|
239
|
-
async def run_chain(
|
|
398
|
+
async def run_chain(
|
|
399
|
+
keyword: str, *, timeout: float | None = None
|
|
400
|
+
) -> tuple[CsvPayload, IctrpSession]:
|
|
240
401
|
"""Run the full chain and return the payload plus the session for provenance."""
|
|
241
402
|
session = IctrpSession.create(timeout=timeout)
|
|
242
403
|
await session.load_form()
|
|
@@ -35,16 +35,33 @@ from .provenance import INCOMPLETENESS_NOTICE, Provenance, derive_set_provenance
|
|
|
35
35
|
|
|
36
36
|
#: Fields shown by default when a caller does not ask for specific ones. Chosen to
|
|
37
37
|
#: be the high-coverage, decision-relevant columns rather than all 58.
|
|
38
|
+
#: The default view. Deliberately NOT just the 10 most common columns: it is the
|
|
39
|
+
#: view a clinician actually needs to triage a target -- name, sponsor/PI, where
|
|
40
|
+
#: it runs, eligibility shape, and a treatment-line hint. Sponsor/PI come from the
|
|
41
|
+
#: `primary_sponsor` + `contact_*` columns (ICTRP has no dedicated "PI" or
|
|
42
|
+
#: "participating-hospital" field; hospitals exist only as `countries` and the
|
|
43
|
+
#: contact's affiliation), so those are surfaced here. `line_of_therapy_hint` is a
|
|
44
|
+
#: non-authoritative extraction from the free-text criteria -- see normalize.py.
|
|
38
45
|
DEFAULT_FIELDS: tuple[str, ...] = (
|
|
39
46
|
"trial_id",
|
|
40
47
|
"source_register",
|
|
41
48
|
"public_title",
|
|
49
|
+
"scientific_title",
|
|
50
|
+
"primary_sponsor",
|
|
51
|
+
"secondary_sponsor",
|
|
52
|
+
"contact_firstname",
|
|
53
|
+
"contact_lastname",
|
|
54
|
+
"contact_affiliation",
|
|
42
55
|
"recruitment_status",
|
|
43
56
|
"phase_code",
|
|
44
57
|
"registration_date",
|
|
45
58
|
"condition",
|
|
46
59
|
"countries",
|
|
60
|
+
"inclusion_age_min",
|
|
61
|
+
"inclusion_age_max",
|
|
62
|
+
"inclusion_gender",
|
|
47
63
|
"target_size_total",
|
|
64
|
+
"line_of_therapy_hint",
|
|
48
65
|
"last_refreshed_display",
|
|
49
66
|
)
|
|
50
67
|
|