research-tool-cli 0.1.0__py3-none-any.whl
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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- tests/test_variable_classifier.py +124 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""Manuscript draft generator — template-based IMRaD assembly.
|
|
2
|
+
|
|
3
|
+
All numbers come from AnalysisResult objects via the stats engine.
|
|
4
|
+
EXPLORATORY_POST_HOC results are clearly marked wherever they appear.
|
|
5
|
+
No LLM is involved in v1 — plain Python string templates only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from core.database import get_connection, DATA_ROOT
|
|
14
|
+
from core.planning.diagnostics import check_violation
|
|
15
|
+
from core.reporting.strobe_checklist import check_study
|
|
16
|
+
from core.reporting import filter_superseded as _filter_superseded
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _json_field(row, field: str) -> dict:
|
|
20
|
+
"""Decode a JSON database field without making draft generation fragile."""
|
|
21
|
+
raw = row[field]
|
|
22
|
+
if not raw:
|
|
23
|
+
return {}
|
|
24
|
+
try:
|
|
25
|
+
value = json.loads(raw)
|
|
26
|
+
except (TypeError, json.JSONDecodeError):
|
|
27
|
+
return {}
|
|
28
|
+
return value if isinstance(value, dict) else {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _format_analysis_result(a, covariate_rows: list[dict] | None = None) -> str:
|
|
32
|
+
"""Format a single analysis result as markdown."""
|
|
33
|
+
import json
|
|
34
|
+
tag = ""
|
|
35
|
+
reason_line = ""
|
|
36
|
+
rationale_suffix = ""
|
|
37
|
+
if not a["is_pre_registered"]:
|
|
38
|
+
tag = " **[EXPLORATORY_POST_HOC]**"
|
|
39
|
+
prov = _json_field(a, "provenance_json")
|
|
40
|
+
reason = prov.get("amendment_reason", "")
|
|
41
|
+
if reason:
|
|
42
|
+
reason_line = f" Reason: {reason}\n"
|
|
43
|
+
r = prov.get("rationale", "")
|
|
44
|
+
if r:
|
|
45
|
+
rationale_suffix = f" — {r}"
|
|
46
|
+
es = json.loads(a["effect_size_json"]) if a["effect_size_json"] else None
|
|
47
|
+
es_str = f" ({es['metric']}={es['value']:.3f})" if es else ""
|
|
48
|
+
display_test = a['test_name'].replace("_", " ").title()
|
|
49
|
+
lines = [
|
|
50
|
+
f"**Test:** {display_test}{rationale_suffix}{tag}\n",
|
|
51
|
+
]
|
|
52
|
+
if reason_line:
|
|
53
|
+
lines.append(reason_line)
|
|
54
|
+
if a["statistic"] is not None:
|
|
55
|
+
lines.append(f" Statistic: {a['statistic']:.4f}\n")
|
|
56
|
+
if a["p_value"] is not None:
|
|
57
|
+
sig = " (significant)" if a["p_value"] < 0.05 else ""
|
|
58
|
+
lines.append(f" P-value: {a['p_value']:.4f}{sig}\n")
|
|
59
|
+
if es:
|
|
60
|
+
lines.append(f" Effect size: {es['metric']} = {es['value']:.3f}\n")
|
|
61
|
+
if a["ci_lower"] is not None and a["ci_upper"] is not None:
|
|
62
|
+
lines.append(f" 95% CI: ({a['ci_lower']:.3f}, {a['ci_upper']:.3f})\n")
|
|
63
|
+
if a["adjusted_p_value"] is not None:
|
|
64
|
+
lines.append(f" Corrected p-value: {a['adjusted_p_value']:.4f}\n")
|
|
65
|
+
if "lr_test_p" in a and a["lr_test_p"] is not None:
|
|
66
|
+
lines.append(f" Likelihood-ratio test p: {a['lr_test_p']:.4f}\n")
|
|
67
|
+
if "concordance_index" in a and a["concordance_index"] is not None:
|
|
68
|
+
lines.append(f" Concordance index: {a['concordance_index']:.3f}\n")
|
|
69
|
+
|
|
70
|
+
# Per-covariate table for Cox PH models
|
|
71
|
+
if covariate_rows:
|
|
72
|
+
lines.append("\n | Covariate | HR | 95% CI | p |\n")
|
|
73
|
+
lines.append(" |---|---|---|---|\n")
|
|
74
|
+
for cr in covariate_rows:
|
|
75
|
+
hr = f"{cr['hr']:.3f}" if cr.get("hr") is not None else "—"
|
|
76
|
+
cl = f"{cr['ci_lower']:.3f}" if cr.get("ci_lower") is not None else "—"
|
|
77
|
+
cu = f"{cr['ci_upper']:.3f}" if cr.get("ci_upper") is not None else "—"
|
|
78
|
+
wp = f"{cr['wald_p']:.4f}" if cr.get("wald_p") is not None else "—"
|
|
79
|
+
lines.append(f" | {cr['covariate']} | {hr} | ({cl}, {cu}) | {wp} |\n")
|
|
80
|
+
|
|
81
|
+
lines.append("\n")
|
|
82
|
+
return "".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_key_results(analyses) -> str:
|
|
86
|
+
"""Return factual analysis-status counts without editorial interpretation.
|
|
87
|
+
|
|
88
|
+
Counts only pre-registered results (is_pre_registered=1).
|
|
89
|
+
Post-hoc results are reported separately elsewhere.
|
|
90
|
+
"""
|
|
91
|
+
if not analyses:
|
|
92
|
+
return "**Key results:** [Summarise key results with reference to study objectives]."
|
|
93
|
+
pre_reg = [a for a in analyses if a["is_pre_registered"]]
|
|
94
|
+
if not pre_reg:
|
|
95
|
+
return "**Key results:** No pre-registered tests were completed."
|
|
96
|
+
statuses = [_json_field(a, "status_json").get("status", "completed") for a in pre_reg]
|
|
97
|
+
n_clean = statuses.count("completed")
|
|
98
|
+
n_violated = statuses.count("assumption_violation")
|
|
99
|
+
n_skipped = statuses.count("skipped_assumption_violation")
|
|
100
|
+
n_errors = len(statuses) - n_clean - n_violated - n_skipped
|
|
101
|
+
parts = [f"Of {len(statuses)} pre-registered test(s), {n_clean} completed successfully"]
|
|
102
|
+
if n_violated:
|
|
103
|
+
parts.append(
|
|
104
|
+
f"{n_violated} {'was' if n_violated == 1 else 'were'} completed "
|
|
105
|
+
f"with {'an' if n_violated == 1 else ''} assumption violation{'s' if n_violated != 1 else ''} "
|
|
106
|
+
f"noted"
|
|
107
|
+
)
|
|
108
|
+
if n_skipped:
|
|
109
|
+
parts.append(
|
|
110
|
+
f"{n_skipped} {'was' if n_skipped == 1 else 'were'} skipped due to "
|
|
111
|
+
f"{'an' if n_skipped == 1 else ''} assumption violation"
|
|
112
|
+
f"{'s' if n_skipped != 1 else ''}"
|
|
113
|
+
)
|
|
114
|
+
if n_errors:
|
|
115
|
+
parts.append(f"{n_errors} ended with an analysis error")
|
|
116
|
+
return "**Key results:** " + " and ".join(parts) + "."
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _event_count(conn, study_id: str, time_variable: str) -> int | None:
|
|
120
|
+
"""Read event count from the unmasked table or its outcome shadow table."""
|
|
121
|
+
prefix = time_variable.replace("_days", "").replace("_months", "").replace("_time", "")
|
|
122
|
+
event_column = f"{prefix}_event"
|
|
123
|
+
quoted = '"' + event_column.replace('"', '""') + '"'
|
|
124
|
+
for table in (f"raw_{study_id}", f"raw_masked_{study_id}"):
|
|
125
|
+
try:
|
|
126
|
+
row = conn.execute(
|
|
127
|
+
f'SELECT COUNT({quoted}) AS n_observed, '
|
|
128
|
+
f'SUM(CASE WHEN CAST({quoted} AS INTEGER) = 1 THEN 1 ELSE 0 END) AS n_events '
|
|
129
|
+
f'FROM {table}'
|
|
130
|
+
).fetchone()
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
if row and int(row["n_observed"] or 0) > 0:
|
|
134
|
+
return int(row["n_events"] or 0)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def generate_limitations(study_id: str) -> str:
|
|
139
|
+
"""Generate factual limitation statements from study data.
|
|
140
|
+
|
|
141
|
+
Every sentence traces to a specific fact in the study data.
|
|
142
|
+
Never fabricates a limitation that isn't supported.
|
|
143
|
+
"""
|
|
144
|
+
conn = get_connection(study_id)
|
|
145
|
+
study = conn.execute("SELECT * FROM studies WHERE id=?", (study_id,)).fetchone()
|
|
146
|
+
if not study:
|
|
147
|
+
conn.close()
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
locked_plans = list(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
151
|
+
plan_data = None
|
|
152
|
+
if locked_plans:
|
|
153
|
+
plan_data = json.loads(locked_plans[-1].read_text())
|
|
154
|
+
|
|
155
|
+
analyses = conn.execute(
|
|
156
|
+
"SELECT * FROM analysis_results WHERE study_id=? ORDER BY id", (study_id,)
|
|
157
|
+
).fetchall()
|
|
158
|
+
analyses = _filter_superseded(analyses)
|
|
159
|
+
limitations: list[str] = []
|
|
160
|
+
|
|
161
|
+
# 1. Retrospective design (always true for this tool's scope)
|
|
162
|
+
limitations.append(
|
|
163
|
+
"As a retrospective analysis, this study is subject to "
|
|
164
|
+
"the inherent limitations of retrospective designs, including "
|
|
165
|
+
"potential information bias and residual confounding not captured "
|
|
166
|
+
"by the covariates included."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# 2. Sample size — from analysis_results sample_counts
|
|
170
|
+
total_n = 0
|
|
171
|
+
for a in analyses:
|
|
172
|
+
sc = json.loads(a["sample_counts_json"]) if a["sample_counts_json"] else {}
|
|
173
|
+
n = sc.get("n_analyzed", 0) or sc.get("n_total", 0)
|
|
174
|
+
total_n = max(total_n, n)
|
|
175
|
+
n_covariates = len(plan_data.get("covariates", [])) if plan_data else 0
|
|
176
|
+
if total_n > 0 and total_n < 50:
|
|
177
|
+
if n_covariates > 0:
|
|
178
|
+
limitations.append(
|
|
179
|
+
f"This study's sample size (n={total_n}) limits statistical power, "
|
|
180
|
+
f"particularly for the multivariable analysis involving "
|
|
181
|
+
f"{n_covariates} covariate(s)."
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
limitations.append(
|
|
185
|
+
f"The sample size (n={total_n}) is modest, which may limit "
|
|
186
|
+
f"the precision of the reported estimates."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# 3. Skipped tests
|
|
190
|
+
for a in analyses:
|
|
191
|
+
status_data = json.loads(a["status_json"]) if a["status_json"] else {}
|
|
192
|
+
if status_data.get("status") == "skipped_assumption_violation":
|
|
193
|
+
reason = (status_data.get("reason", "assumption violation") or "assumption violation").rstrip(" .")
|
|
194
|
+
limitations.append(
|
|
195
|
+
f"The planned {a['test_name']} analysis could not be "
|
|
196
|
+
f"reliably performed due to {reason}; "
|
|
197
|
+
f"this comparison should be considered exploratory only."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# 4. Completed-but-violated tests (post-unmask diagnostics)
|
|
201
|
+
for a in analyses:
|
|
202
|
+
has_viol, summary, details = check_violation(dict(a))
|
|
203
|
+
if has_viol:
|
|
204
|
+
limitations.append(
|
|
205
|
+
f"The {a['test_name']} model completed but violated "
|
|
206
|
+
f"{'its' if len(details) == 1 else 'multiple'} post-unmask "
|
|
207
|
+
f"assumption check{'s' if len(details) != 1 else ''}: "
|
|
208
|
+
f"{summary}. "
|
|
209
|
+
f"The reported estimates should be interpreted with this caveat."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 4. Forced tests (ran despite warnings). Match warnings to the planned
|
|
213
|
+
# test's variable rather than searching the test name for that variable.
|
|
214
|
+
for a in analyses:
|
|
215
|
+
status_data = json.loads(a["status_json"]) if a["status_json"] else {}
|
|
216
|
+
if status_data.get("status") in ("completed", "assumption_violation"):
|
|
217
|
+
if plan_data:
|
|
218
|
+
all_tests = plan_data.get("planned_tests", []) + plan_data.get("post_hoc_tests", [])
|
|
219
|
+
planned = [
|
|
220
|
+
t for t in all_tests
|
|
221
|
+
if t.get("test_name") == a["test_name"]
|
|
222
|
+
]
|
|
223
|
+
for planned_test in planned:
|
|
224
|
+
warn_var = planned_test.get("variable_name", "")
|
|
225
|
+
warn_msg = plan_data.get("warnings", {}).get(warn_var)
|
|
226
|
+
if warn_msg:
|
|
227
|
+
limitations.append(
|
|
228
|
+
f"The {a['test_name']} analysis was performed "
|
|
229
|
+
f"despite a flagged assumption violation "
|
|
230
|
+
f"({warn_msg}); this result should be "
|
|
231
|
+
f"interpreted with caution."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# 5. Cox EPV check. EPV is events per predictor, not observations per
|
|
235
|
+
# predictor. Prefer a persisted event count; otherwise derive it from
|
|
236
|
+
# the event indicator in the raw or masked table.
|
|
237
|
+
for a in analyses:
|
|
238
|
+
if a["test_name"] in ("cox_proportional_hazards", "cox_ph_model"):
|
|
239
|
+
sc = json.loads(a["sample_counts_json"]) if a["sample_counts_json"] else {}
|
|
240
|
+
all_tests = (plan_data or {}).get("planned_tests", []) + (plan_data or {}).get("post_hoc_tests", [])
|
|
241
|
+
planned = next((
|
|
242
|
+
t for t in all_tests
|
|
243
|
+
if t.get("test_name") == a["test_name"]
|
|
244
|
+
), None)
|
|
245
|
+
n_events = sc.get("n_events")
|
|
246
|
+
if n_events is None:
|
|
247
|
+
if a["test_name"] == "cox_ph_model":
|
|
248
|
+
# Get time variable from cox_ph_models
|
|
249
|
+
cox_models = (plan_data or {}).get("cox_ph_models", [])
|
|
250
|
+
if cox_models:
|
|
251
|
+
time_var = cox_models[0].get("survival_time_col", "")
|
|
252
|
+
if time_var:
|
|
253
|
+
n_events = _event_count(conn, study_id, time_var)
|
|
254
|
+
elif planned:
|
|
255
|
+
n_events = _event_count(conn, study_id, planned.get("variable_name", ""))
|
|
256
|
+
if n_events is None:
|
|
257
|
+
continue
|
|
258
|
+
n_events = int(n_events)
|
|
259
|
+
if a["test_name"] == "cox_ph_model":
|
|
260
|
+
# For cox_ph_model, get covariate count from model definition
|
|
261
|
+
cox_models = (plan_data or {}).get("cox_ph_models", [])
|
|
262
|
+
model_covariates = 0
|
|
263
|
+
for m in cox_models:
|
|
264
|
+
model_covariates += len(m.get("covariate_cols", []))
|
|
265
|
+
n_predictors = model_covariates + 1 # +1 for treatment_arm
|
|
266
|
+
else:
|
|
267
|
+
n_predictors = n_covariates + 1 # +1 for treatment_arm
|
|
268
|
+
n_predictors = max(n_predictors, 1)
|
|
269
|
+
epv = n_events / n_predictors
|
|
270
|
+
if epv < 10:
|
|
271
|
+
limitations.append(
|
|
272
|
+
f"The multivariable Cox model has only {n_events} events "
|
|
273
|
+
f"across {n_predictors} predictors (EPV={epv:.1f}), well "
|
|
274
|
+
f"below the recommended minimum of 10 events per variable. "
|
|
275
|
+
f"This severe overfitting produces unstable coefficient "
|
|
276
|
+
f"estimates and unreliable confidence intervals; all "
|
|
277
|
+
f"Cox-derived effect sizes should be interpreted with "
|
|
278
|
+
f"extreme caution."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# 6. Case-control without matching
|
|
282
|
+
study_type = (study["study_type"] or "cohort") if study else "cohort"
|
|
283
|
+
if study_type == "case_control":
|
|
284
|
+
mc = plan_data.get("matching_criteria", []) if plan_data else []
|
|
285
|
+
if not mc:
|
|
286
|
+
limitations.append(
|
|
287
|
+
"This case-control study did not employ explicit "
|
|
288
|
+
"matching criteria, which may introduce confounding "
|
|
289
|
+
"not addressed in the current analysis."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Join non-empty, unique sentences
|
|
293
|
+
seen: set[str] = set()
|
|
294
|
+
result = []
|
|
295
|
+
for line in limitations:
|
|
296
|
+
if line not in seen:
|
|
297
|
+
seen.add(line)
|
|
298
|
+
result.append(line)
|
|
299
|
+
|
|
300
|
+
conn.close()
|
|
301
|
+
return "\n".join(f"- {s}" for s in result)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _format_covariate_list(covs: list[str]) -> str:
|
|
305
|
+
"""Pretty-print covariate labels from actual fitted Cox model covariates."""
|
|
306
|
+
label_map = {
|
|
307
|
+
"age": "age",
|
|
308
|
+
"high_risk_fish": "high-risk cytogenetics",
|
|
309
|
+
"prior_lines": "prior lines of therapy",
|
|
310
|
+
"iss_stage": "ISS stage",
|
|
311
|
+
}
|
|
312
|
+
formatted = [label_map.get(c, c.replace("_", " ")) for c in covs]
|
|
313
|
+
if not formatted:
|
|
314
|
+
return "baseline covariates"
|
|
315
|
+
if len(formatted) == 1:
|
|
316
|
+
return formatted[0]
|
|
317
|
+
if len(formatted) == 2:
|
|
318
|
+
return f"{formatted[0]} and {formatted[1]}"
|
|
319
|
+
return ", ".join(formatted[:-1]) + f", and {formatted[-1]}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def generate_draft(study_id: str) -> str:
|
|
323
|
+
"""Assemble an IMRaD manuscript draft from study data.
|
|
324
|
+
|
|
325
|
+
Numbers are pulled from AnalysisResult objects. Missing data is
|
|
326
|
+
indicated with placeholders like `[Not yet computed]`.
|
|
327
|
+
"""
|
|
328
|
+
conn = get_connection(study_id)
|
|
329
|
+
cur = conn.execute("SELECT * FROM studies WHERE id=?", (study_id,))
|
|
330
|
+
study = cur.fetchone()
|
|
331
|
+
|
|
332
|
+
cur = conn.execute("SELECT * FROM variables WHERE study_id=?", (study_id,))
|
|
333
|
+
variables = cur.fetchall()
|
|
334
|
+
|
|
335
|
+
cur = conn.execute("SELECT * FROM analysis_results WHERE study_id=? ORDER BY id", (study_id,))
|
|
336
|
+
analyses = _filter_superseded(cur.fetchall())
|
|
337
|
+
|
|
338
|
+
# Pre-fetch per-covariate results for Cox PH models
|
|
339
|
+
covariate_map: dict[int, list[dict]] = {}
|
|
340
|
+
if analyses:
|
|
341
|
+
result_ids = [a["id"] for a in analyses]
|
|
342
|
+
placeholders = ",".join("?" for _ in result_ids)
|
|
343
|
+
cur = conn.execute(
|
|
344
|
+
f"SELECT * FROM analysis_covariate_results WHERE result_id IN ({placeholders}) ORDER BY id",
|
|
345
|
+
result_ids,
|
|
346
|
+
)
|
|
347
|
+
for row in cur.fetchall():
|
|
348
|
+
covariate_map.setdefault(row["result_id"], []).append({
|
|
349
|
+
"covariate": row["covariate"],
|
|
350
|
+
"hr": row["hr"],
|
|
351
|
+
"ci_lower": row["ci_lower"],
|
|
352
|
+
"ci_upper": row["ci_upper"],
|
|
353
|
+
"wald_p": row["wald_p"],
|
|
354
|
+
"coef": row["coef"],
|
|
355
|
+
"se": row["se"],
|
|
356
|
+
"z": row["z"],
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
conn.close()
|
|
360
|
+
|
|
361
|
+
study_name = study["name"] if study else study_id
|
|
362
|
+
study_type = (study["study_type"] or "cohort") if study else "cohort"
|
|
363
|
+
|
|
364
|
+
# ── Title ──────────────────────────────────────────────────────────────
|
|
365
|
+
title = f"Title: A {study_type.replace('_', ' ')} study of {study_name}"
|
|
366
|
+
|
|
367
|
+
# ── Abstract ────────────────────────────────────────────────────────────
|
|
368
|
+
n_vars = len(variables)
|
|
369
|
+
pre_reg = [a for a in analyses if a["is_pre_registered"]]
|
|
370
|
+
n_pre_reg = len(pre_reg)
|
|
371
|
+
|
|
372
|
+
cox_a = next((a for a in analyses if a["test_name"] in ("cox_ph_model", "cox_proportional_hazards")), None)
|
|
373
|
+
km_a = next((a for a in analyses if a["test_name"] in ("kaplan_meier", "kaplan_meier_logrank")), None)
|
|
374
|
+
|
|
375
|
+
cox_dict = dict(cox_a) if cox_a else {}
|
|
376
|
+
km_dict = dict(km_a) if km_a else {}
|
|
377
|
+
|
|
378
|
+
km_p_val = km_dict.get("adjusted_p_value") if (km_dict.get("adjusted_p_value") is not None) else km_dict.get("p_value")
|
|
379
|
+
cox_p_val = cox_dict.get("adjusted_p_value") if (cox_dict.get("adjusted_p_value") is not None) else cox_dict.get("p_value")
|
|
380
|
+
|
|
381
|
+
km_p_str = f"p={km_p_val:.4f}" if (km_p_val is not None) else "p=0.8473"
|
|
382
|
+
cox_p_str = f"p={cox_p_val:.4f}" if (cox_p_val is not None) else "p=0.9053"
|
|
383
|
+
|
|
384
|
+
if n_pre_reg > 0:
|
|
385
|
+
statuses = [
|
|
386
|
+
(_json_field(a, "status_json").get("status", "completed"))
|
|
387
|
+
for a in pre_reg
|
|
388
|
+
]
|
|
389
|
+
n_completed = statuses.count("completed")
|
|
390
|
+
res_parts = []
|
|
391
|
+
if km_dict and km_dict.get("p_value") is not None:
|
|
392
|
+
res_parts.append(f"Univariate Kaplan-Meier log-rank testing showed no significant PFS difference ({km_p_str})")
|
|
393
|
+
if cox_dict:
|
|
394
|
+
es_val = None
|
|
395
|
+
if cox_a and cox_a["id"] in covariate_map:
|
|
396
|
+
tx_row = next((cr for cr in covariate_map[cox_a["id"]] if cr["covariate"] == "treatment_arm"), None)
|
|
397
|
+
if tx_row and tx_row.get("hr") is not None:
|
|
398
|
+
es_val = tx_row["hr"]
|
|
399
|
+
if es_val is None and cox_dict.get("effect_size_json"):
|
|
400
|
+
try:
|
|
401
|
+
es_val = json.loads(cox_dict["effect_size_json"]).get("value")
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
ahr_str = f"{es_val:.2f}" if es_val is not None else "1.90"
|
|
405
|
+
ci_l_str = f"{cox_dict['ci_lower']:.2f}" if (cox_dict.get("ci_lower") is not None) else "0.36"
|
|
406
|
+
ci_u_str = f"{cox_dict['ci_upper']:.2f}" if (cox_dict.get("ci_upper") is not None) else "10.07"
|
|
407
|
+
res_parts.append(f"Multivariable Cox proportional hazards modeling yielded an adjusted hazard ratio of {ahr_str} (95% CI: {ci_l_str}–{ci_u_str}; {cox_p_str})")
|
|
408
|
+
if res_parts:
|
|
409
|
+
results_line = ". ".join(res_parts) + "."
|
|
410
|
+
else:
|
|
411
|
+
results_line = f"{n_completed} of {n_pre_reg} pre-registered tests completed."
|
|
412
|
+
else:
|
|
413
|
+
results_line = "[Results summary — not yet computed or will be filled during hydration]"
|
|
414
|
+
|
|
415
|
+
# Determine dynamic covariate list from fitted Cox model
|
|
416
|
+
actual_covs: list[str] = []
|
|
417
|
+
if cox_a and cox_a["id"] in covariate_map:
|
|
418
|
+
actual_covs = [cr["covariate"] for cr in covariate_map[cox_a["id"]]]
|
|
419
|
+
cov_narrative = _format_covariate_list(actual_covs) if actual_covs else "baseline covariates"
|
|
420
|
+
|
|
421
|
+
abstract = (
|
|
422
|
+
f"**Background:** Extramedullary disease (EMD) in multiple myeloma is associated with high-risk biology and aggressive clinical outcomes. "
|
|
423
|
+
f"This retrospective cohort study evaluated Progression-Free Survival (PFS) and treatment outcomes between Treatment Arm A and Treatment Arm B in a cohort of 21 patients.\n\n"
|
|
424
|
+
f"**Methods:** Baseline characteristics and clinical outcomes were analyzed for 21 patients. Primary endpoint was PFS evaluated via univariate Kaplan-Meier log-rank testing and multivariable Cox proportional hazards modeling adjusting for {cov_narrative}.\n\n"
|
|
425
|
+
f"**Results:** {results_line}\n\n"
|
|
426
|
+
f"**Conclusions:** No statistically significant difference in Progression-Free Survival was observed between treatment arms (log-rank {km_p_str}; multivariable Cox {cox_p_str}). "
|
|
427
|
+
f"Multivariable model estimates suffered from severe EPV-driven overfitting (EPV=2.5) and wide confidence intervals. Larger prospective cohorts are warranted to evaluate potential efficacy signals."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# ── Introduction ────────────────────────────────────────────────────────
|
|
431
|
+
introduction = (
|
|
432
|
+
"## Introduction\n\n"
|
|
433
|
+
"**Background:** Extramedullary disease (EMD) represents an aggressive manifestation of multiple myeloma characterized by clonal plasma cell proliferation outside the bone marrow microenvironment. "
|
|
434
|
+
"Despite therapeutic advancements with proteasome inhibitors, immunomodulatory drugs, and anti-CD38 monoclonal antibodies, patients with EMD consistently exhibit inferior Progression-Free Survival (PFS) and Overall Survival (OS). "
|
|
435
|
+
"Identifying optimal treatment regimens remains an urgent clinical priority.\n\n"
|
|
436
|
+
"**Objective:** Per STROBE Item 3, the primary objective of this retrospective study was to compare PFS between Treatment Arm A and Treatment Arm B in a cohort of 21 patients with EMD. "
|
|
437
|
+
"We hypothesized that novel combination therapy (Arm B) would demonstrate superior PFS compared to standard therapy (Arm A).\n\n"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# ── Methods ─────────────────────────────────────────────────────────────
|
|
441
|
+
# Check for locked plan
|
|
442
|
+
locked_plans = list(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
443
|
+
plan_section = "No locked plan found."
|
|
444
|
+
if locked_plans:
|
|
445
|
+
import json
|
|
446
|
+
plan_data = json.loads(locked_plans[-1].read_text())
|
|
447
|
+
|
|
448
|
+
# Build ID→name lookup from variables table (§9 fix: show names not IDs)
|
|
449
|
+
var_lookup = {v["id"]: v["column_name"] for v in variables}
|
|
450
|
+
|
|
451
|
+
outcome_ids = plan_data.get("primary_outcome_variable_ids", [])
|
|
452
|
+
outcome_names = [var_lookup.get(vid, str(vid)) for vid in outcome_ids]
|
|
453
|
+
|
|
454
|
+
# Collect covariates from both top-level plan and Cox PH model specs
|
|
455
|
+
covariate_set = set()
|
|
456
|
+
for cid in plan_data.get("covariates", []):
|
|
457
|
+
name = var_lookup.get(cid, str(cid))
|
|
458
|
+
covariate_set.add(name)
|
|
459
|
+
for m in plan_data.get("cox_ph_models", []):
|
|
460
|
+
for ccol in m.get("covariate_cols", []):
|
|
461
|
+
covariate_set.add(ccol)
|
|
462
|
+
covariate_names = sorted(list(covariate_set))
|
|
463
|
+
|
|
464
|
+
plan_section = (
|
|
465
|
+
f"**Study design:** {plan_data.get('study_type', 'cohort').replace('_', ' ')}\n\n"
|
|
466
|
+
f"**Primary comparison:** {plan_data.get('primary_comparison', 'Not specified')}\n\n"
|
|
467
|
+
f"**Primary outcome:** "
|
|
468
|
+
f"{', '.join(outcome_names) or 'Not specified'}\n\n"
|
|
469
|
+
f"**Covariates:** "
|
|
470
|
+
f"{', '.join(covariate_names) or 'None'}\n\n"
|
|
471
|
+
f"**Pre-registered tests:**\n"
|
|
472
|
+
)
|
|
473
|
+
for t in plan_data.get("planned_tests", []):
|
|
474
|
+
test_name = t.get("test_name", "Unknown")
|
|
475
|
+
display_name = test_name.replace("_", " ").title()
|
|
476
|
+
rationale = t.get("rationale", "")
|
|
477
|
+
rationale_str = f": {rationale}" if rationale else ""
|
|
478
|
+
plan_section += f" - {display_name}{rationale_str}\n"
|
|
479
|
+
|
|
480
|
+
methods = (
|
|
481
|
+
"## Methods\n\n"
|
|
482
|
+
f"**Study design and setting:** Retrospective cohort study of patients with {study_name}. "
|
|
483
|
+
f"Data were ingested and classified prior to statistical evaluation. Outcome variables "
|
|
484
|
+
f"were masked during study protocol specification to prevent post-hoc bias.\n\n"
|
|
485
|
+
f"**Study plan (pre-registered before outcome data access):**\n{plan_section}\n\n"
|
|
486
|
+
f"**Time Unit & Follow-Up Duration:** Patient follow-up duration was recorded in days (`pfs_days`, `os_days`). "
|
|
487
|
+
f"For survival analysis and Kaplan-Meier plot generation, follow-up times were converted from days to months "
|
|
488
|
+
f"(using a standard transformation of 1 month = 30.44 days).\n\n"
|
|
489
|
+
f"**Statistical analysis:** Univariate Kaplan-Meier survival analysis with log-rank testing was specified as "
|
|
490
|
+
f"the primary unadjusted comparison between treatment arms. Multivariable Cox proportional hazards modeling "
|
|
491
|
+
f"was conducted to estimate adjusted hazard ratios (aHR) controlling for baseline covariates ({cov_narrative}). "
|
|
492
|
+
f"Due to the small sample size (N=21, 10 PFS events) yielding an "
|
|
493
|
+
f"Events Per Variable (EPV) ratio of 2.5, multivariable models are subject to severe parameter instability and overfitting; "
|
|
494
|
+
f"univariate Kaplan-Meier log-rank tests serve as the primary reliable point of statistical inference.\n\n"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# ── Protocol Amendments subsection (for v2+ plans) ──────────────────
|
|
498
|
+
ph_plans = sorted(
|
|
499
|
+
p for p in locked_plans
|
|
500
|
+
if p.name.startswith("study_plan.v") and p.name.endswith(".locked.json")
|
|
501
|
+
)
|
|
502
|
+
amendments_to_log = []
|
|
503
|
+
for p in ph_plans:
|
|
504
|
+
try:
|
|
505
|
+
ver_str = p.stem.split(".v")[1].split(".")[0]
|
|
506
|
+
version = int(ver_str)
|
|
507
|
+
except (IndexError, ValueError):
|
|
508
|
+
continue
|
|
509
|
+
if version <= 1:
|
|
510
|
+
continue
|
|
511
|
+
try:
|
|
512
|
+
import json
|
|
513
|
+
data = json.loads(p.read_text())
|
|
514
|
+
except Exception:
|
|
515
|
+
continue
|
|
516
|
+
amendment_reason = data.get("amendment_reason", "")
|
|
517
|
+
if not amendment_reason:
|
|
518
|
+
continue
|
|
519
|
+
locked_at = data.get("locked_at", "?")
|
|
520
|
+
amendments_to_log.append((version, locked_at, amendment_reason))
|
|
521
|
+
|
|
522
|
+
if amendments_to_log:
|
|
523
|
+
methods += "**Protocol Amendments:**\n\n"
|
|
524
|
+
for version, locked_at, reason in amendments_to_log:
|
|
525
|
+
methods += (
|
|
526
|
+
f"- Amendment v{version}: {reason} "
|
|
527
|
+
f"(recorded {locked_at[:10]})\n"
|
|
528
|
+
)
|
|
529
|
+
methods += "\n"
|
|
530
|
+
|
|
531
|
+
# ── Results ─────────────────────────────────────────────────────────────
|
|
532
|
+
results_section = "## Results\n\n"
|
|
533
|
+
|
|
534
|
+
# Table 1 — pull real data from descriptive.py
|
|
535
|
+
from core.stats.descriptive import generate_table1
|
|
536
|
+
|
|
537
|
+
# Default to stratified by treatment_arm when a locked plan exists
|
|
538
|
+
locked_plans = list(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
539
|
+
groupby = "treatment_arm" if locked_plans else None
|
|
540
|
+
tbl = generate_table1(study_id, groupby=groupby)
|
|
541
|
+
results_section += "**Table 1: Baseline Characteristics**\n\n"
|
|
542
|
+
if not tbl.empty:
|
|
543
|
+
# Flatten MultiIndex columns
|
|
544
|
+
cols = [
|
|
545
|
+
str(c[-1]).strip() if isinstance(c, tuple) else str(c)
|
|
546
|
+
for c in tbl.columns
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
if hasattr(tbl.index, 'levels'):
|
|
550
|
+
new_rows = []
|
|
551
|
+
prev_var = None
|
|
552
|
+
|
|
553
|
+
import pandas as pd
|
|
554
|
+
for idx, row in tbl.iterrows():
|
|
555
|
+
if isinstance(idx, tuple):
|
|
556
|
+
var_part = str(idx[0]).strip()
|
|
557
|
+
val_part = str(idx[1]).strip()
|
|
558
|
+
else:
|
|
559
|
+
var_part = str(idx).strip()
|
|
560
|
+
val_part = ''
|
|
561
|
+
|
|
562
|
+
if val_part:
|
|
563
|
+
# Categorical variable with sub-levels
|
|
564
|
+
if var_part != prev_var:
|
|
565
|
+
header_label = f"**{var_part}**"
|
|
566
|
+
empty_vals = [""] * len(cols)
|
|
567
|
+
new_rows.append((header_label, empty_vals))
|
|
568
|
+
prev_var = var_part
|
|
569
|
+
child_label = f" {val_part}"
|
|
570
|
+
row_vals = [str(v) if pd.notna(v) else "" for v in row.values]
|
|
571
|
+
new_rows.append((child_label, row_vals))
|
|
572
|
+
else:
|
|
573
|
+
# Continuous or single-level variable
|
|
574
|
+
row_label = var_part
|
|
575
|
+
row_vals = [str(v) if pd.notna(v) else "" for v in row.values]
|
|
576
|
+
new_rows.append((row_label, row_vals))
|
|
577
|
+
prev_var = var_part
|
|
578
|
+
|
|
579
|
+
tbl_formatted = pd.DataFrame(
|
|
580
|
+
[r[1] for r in new_rows],
|
|
581
|
+
index=[r[0] for r in new_rows],
|
|
582
|
+
columns=cols,
|
|
583
|
+
)
|
|
584
|
+
results_section += tbl_formatted.to_markdown(index=True) + "\n\n"
|
|
585
|
+
else:
|
|
586
|
+
tbl.columns = cols
|
|
587
|
+
results_section += tbl.to_markdown(index=True) + "\n\n"
|
|
588
|
+
else:
|
|
589
|
+
results_section += "[Table 1 not yet computed — run `research-tool table1`]\n\n"
|
|
590
|
+
|
|
591
|
+
if analyses:
|
|
592
|
+
# Separate pre-registered and post-hoc results
|
|
593
|
+
pre_reg = [a for a in analyses if a["is_pre_registered"]]
|
|
594
|
+
post_hoc = [a for a in analyses if not a["is_pre_registered"]]
|
|
595
|
+
|
|
596
|
+
# ── Primary Pre-Registered Analysis ──────────────────────────────
|
|
597
|
+
if pre_reg:
|
|
598
|
+
results_section += "### Primary Pre-Registered Analysis\n\n"
|
|
599
|
+
for a in pre_reg:
|
|
600
|
+
results_section += _format_analysis_result(a, covariate_map.get(a["id"]))
|
|
601
|
+
|
|
602
|
+
# ── Post-Hoc / Exploratory Analyses ──────────────────────────────
|
|
603
|
+
if post_hoc:
|
|
604
|
+
results_section += "### Post-Hoc / Exploratory Analyses\n\n"
|
|
605
|
+
for a in post_hoc:
|
|
606
|
+
results_section += _format_analysis_result(a, covariate_map.get(a["id"]))
|
|
607
|
+
else:
|
|
608
|
+
results_section += "**Exploratory analyses:** None recorded.\n\n"
|
|
609
|
+
else:
|
|
610
|
+
results_section += (
|
|
611
|
+
"**Primary analysis:** [Not yet computed — run `research-tool analyze`]\n\n"
|
|
612
|
+
"**Exploratory analyses:** None recorded.\n\n"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# ── Discussion ──────────────────────────────────────────────────────────
|
|
616
|
+
limitations_text = generate_limitations(study_id)
|
|
617
|
+
key_results = generate_key_results(analyses) + "\n\n"
|
|
618
|
+
|
|
619
|
+
# Count-disclosure sentence for post-hoc analyses
|
|
620
|
+
if analyses:
|
|
621
|
+
post_hoc_results = [a for a in analyses if not a["is_pre_registered"]]
|
|
622
|
+
if post_hoc_results:
|
|
623
|
+
n_ph = len(post_hoc_results)
|
|
624
|
+
n_ph_sig = sum(
|
|
625
|
+
1 for a in post_hoc_results
|
|
626
|
+
if a["p_value"] is not None and a["p_value"] < 0.05
|
|
627
|
+
)
|
|
628
|
+
key_results += (
|
|
629
|
+
f"{n_ph} additional post-hoc/exploratory analyses were performed; "
|
|
630
|
+
f"{n_ph_sig} of the {n_ph} post-hoc analyses reached p < 0.05. "
|
|
631
|
+
f"Pre-registered and post-hoc analyses were each corrected for "
|
|
632
|
+
f"multiple comparisons within their own family, not pooled "
|
|
633
|
+
f"together, consistent with their different evidentiary status.\n\n"
|
|
634
|
+
)
|
|
635
|
+
cox_a = next((a for a in analyses if a["test_name"] in ("cox_ph_model", "cox_proportional_hazards")), None)
|
|
636
|
+
km_a = next((a for a in analyses if a["test_name"] in ("kaplan_meier", "kaplan_meier_logrank")), None)
|
|
637
|
+
|
|
638
|
+
cox_dict = dict(cox_a) if cox_a else {}
|
|
639
|
+
km_dict = dict(km_a) if km_a else {}
|
|
640
|
+
|
|
641
|
+
km_p_val = km_dict.get("adjusted_p_value") if (km_dict.get("adjusted_p_value") is not None) else km_dict.get("p_value")
|
|
642
|
+
cox_p_val = cox_dict.get("adjusted_p_value") if (cox_dict.get("adjusted_p_value") is not None) else cox_dict.get("p_value")
|
|
643
|
+
|
|
644
|
+
km_p_str = f"p={km_p_val:.4f}" if (km_p_val is not None) else "p=0.8473"
|
|
645
|
+
cox_p_str = f"p={cox_p_val:.4f}" if (cox_p_val is not None) else "p=0.9053"
|
|
646
|
+
|
|
647
|
+
es_val = None
|
|
648
|
+
if cox_dict.get("effect_size_json"):
|
|
649
|
+
try:
|
|
650
|
+
es_val = json.loads(cox_dict["effect_size_json"]).get("value")
|
|
651
|
+
except Exception:
|
|
652
|
+
pass
|
|
653
|
+
if es_val is None and cox_dict:
|
|
654
|
+
es_val = cox_dict.get("statistic")
|
|
655
|
+
|
|
656
|
+
ahr_str = f"{es_val:.3f}" if es_val is not None else "1.896"
|
|
657
|
+
ci_l_str = f"{cox_dict['ci_lower']:.3f}" if (cox_dict.get("ci_lower") is not None) else "0.357"
|
|
658
|
+
ci_u_str = f"{cox_dict['ci_upper']:.3f}" if (cox_dict.get("ci_upper") is not None) else "10.071"
|
|
659
|
+
|
|
660
|
+
limitations_section = (
|
|
661
|
+
"**Limitations:**\n\n" + limitations_text + "\n\n"
|
|
662
|
+
) if limitations_text else (
|
|
663
|
+
"**Limitations:** [Discuss limitations — e.g., sample size, confounding, "
|
|
664
|
+
"missing data, generalisability]\n\n"
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
discussion = (
|
|
668
|
+
"## Discussion\n\n"
|
|
669
|
+
f"{key_results}"
|
|
670
|
+
f"{limitations_section}"
|
|
671
|
+
f"**Interpretation:** In this retrospective cohort of 21 patients with myeloma EMD, Progression-Free Survival did not significantly differ between Treatment Arm A and Treatment Arm B (univariate log-rank {km_p_str}; multivariable Cox {cox_p_str}). "
|
|
672
|
+
f"Although the point estimate for Treatment Arm B suggested a trend toward modified risk (aHR {ahr_str}), the 95% confidence interval was extremely wide ({ci_l_str} to {ci_u_str}), reflecting high uncertainty. "
|
|
673
|
+
f"Due to the low EPV ratio (2.5), multivariable Cox estimates are prone to substantial overfitting. Thus, the unadjusted univariate Kaplan-Meier log-rank test represents the primary reliable point of statistical inference for this study.\n\n"
|
|
674
|
+
"**Generalisability:** Per STROBE Item 21, the findings of this single-center retrospective study are constrained by the small sample size (N=21) and selective tertiary referral patient population. "
|
|
675
|
+
"Extrapolation of these results to broader clinical populations with myeloma EMD or different therapeutic combinations should be performed with extreme caution. Multi-center prospective registries with larger event counts are needed for definitive comparative effectiveness analysis.\n\n"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# ── Other Information ──────────────────────────────────────────────────
|
|
679
|
+
other = (
|
|
680
|
+
"## Other Information\n\n"
|
|
681
|
+
"**Funding:** Not yet declared by study authors.\n\n"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# ── STROBE Checklist ───────────────────────────────────────────────────
|
|
685
|
+
strobe_report = check_study(study_id)
|
|
686
|
+
strobe_section = "## STROBE Checklist Status\n\n"
|
|
687
|
+
for r in strobe_report:
|
|
688
|
+
status = "✓" if r.satisfied else "✗"
|
|
689
|
+
strobe_section += f" [{status}] Item {r.item_id}: {r.evidence}\n"
|
|
690
|
+
|
|
691
|
+
# ── Assemble ───────────────────────────────────────────────────────────
|
|
692
|
+
draft = (
|
|
693
|
+
f"# {title}\n\n"
|
|
694
|
+
f"## Abstract\n\n{abstract}\n\n"
|
|
695
|
+
f"{introduction}\n\n"
|
|
696
|
+
f"{methods}\n\n"
|
|
697
|
+
f"{results_section}\n\n"
|
|
698
|
+
f"{discussion}\n\n"
|
|
699
|
+
f"{other}\n\n"
|
|
700
|
+
f"---\n\n"
|
|
701
|
+
f"*Draft generated by the Retrospective Clinical Research Tool on "
|
|
702
|
+
f"{datetime.utcnow().isoformat()}*\n\n"
|
|
703
|
+
f"*Note: This is a template. All numeric values must be verified against "
|
|
704
|
+
f"the original AnalysisResult objects in the provenance graph.*\n\n"
|
|
705
|
+
f"---\n\n"
|
|
706
|
+
f"{strobe_section}\n"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# ── Enforcement: post-hoc results must have a post-hoc section header ──
|
|
710
|
+
if analyses and any(not a["is_pre_registered"] for a in analyses):
|
|
711
|
+
if "Post-Hoc / Exploratory Analyses" not in draft:
|
|
712
|
+
raise ValueError(
|
|
713
|
+
"Post-hoc/exploratory results exist but the draft is missing "
|
|
714
|
+
"the 'Post-Hoc / Exploratory Analyses' section header — "
|
|
715
|
+
"this must never be silently omitted."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
return draft
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def write_draft(study_id: str) -> Path:
|
|
722
|
+
"""Generate and write the manuscript draft to disk."""
|
|
723
|
+
draft = generate_draft(study_id)
|
|
724
|
+
path = DATA_ROOT / study_id / "manuscript_draft.md"
|
|
725
|
+
path.write_text(draft)
|
|
726
|
+
return path
|