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,460 @@
|
|
|
1
|
+
"""STROBE checklist compliance checker.
|
|
2
|
+
|
|
3
|
+
Encodes the 22-item STROBE checklist (v4 combined) as structured data, then
|
|
4
|
+
checks which items are satisfied by the current study's recorded plan, data,
|
|
5
|
+
and analyses.
|
|
6
|
+
|
|
7
|
+
Items 6, 12, 14, 15 are design-specific (cohort / case-control / cross-sectional).
|
|
8
|
+
|
|
9
|
+
Section-based items (Introduction, Methods, Results, Discussion, Other) also
|
|
10
|
+
check whether the manuscript draft section has hydrated content beyond template
|
|
11
|
+
placeholders. Items whose section is still bracketed placeholder text are shown
|
|
12
|
+
as [ ] (PENDING) rather than [✓].
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from core.database import get_connection, DATA_ROOT
|
|
22
|
+
from core.reporting import filter_superseded as _filter_superseded
|
|
23
|
+
|
|
24
|
+
# ── Draft section heading mapping ──────────────────────────────────────
|
|
25
|
+
SECTION_HEADINGS = {
|
|
26
|
+
"title_abstract": "Abstract",
|
|
27
|
+
"introduction": "Introduction",
|
|
28
|
+
"methods": "Methods",
|
|
29
|
+
"results": "Results",
|
|
30
|
+
"discussion": "Discussion",
|
|
31
|
+
"other": "Other Information",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StrobeItem:
|
|
37
|
+
item_id: str # e.g. "1a", "6a", "12d"
|
|
38
|
+
section: str # "title_abstract" | "introduction" | "methods" | "results" | "discussion" | "other"
|
|
39
|
+
description: str
|
|
40
|
+
applies_to: list[str] # "cohort", "case_control", "cross_sectional"
|
|
41
|
+
satisfied: bool = False
|
|
42
|
+
evidence: str = ""
|
|
43
|
+
status: str = "satisfied" # "satisfied" | "pending" | "unsatisfied"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
STROBE_ITEMS: list[StrobeItem] = [
|
|
47
|
+
StrobeItem("1a", "title_abstract",
|
|
48
|
+
"Indicate study's design with a commonly used term in the title or abstract.",
|
|
49
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
50
|
+
StrobeItem("1b", "title_abstract",
|
|
51
|
+
"Provide in the abstract an informative and balanced summary.",
|
|
52
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
53
|
+
StrobeItem("2", "introduction",
|
|
54
|
+
"Explain scientific background and rationale.",
|
|
55
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
56
|
+
StrobeItem("3", "introduction",
|
|
57
|
+
"State specific objectives, including prespecified hypotheses.",
|
|
58
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
59
|
+
StrobeItem("4", "methods",
|
|
60
|
+
"Present key elements of study design early in the paper.",
|
|
61
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
62
|
+
StrobeItem("5", "methods",
|
|
63
|
+
"Describe setting, locations, and relevant dates.",
|
|
64
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
65
|
+
StrobeItem("6a", "methods",
|
|
66
|
+
"Give eligibility criteria, sources and methods of selection.",
|
|
67
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
68
|
+
StrobeItem("6b", "methods",
|
|
69
|
+
"For matched studies, give matching criteria and numbers.",
|
|
70
|
+
["case_control"]),
|
|
71
|
+
StrobeItem("7", "methods",
|
|
72
|
+
"Clearly define all outcomes, exposures, predictors, confounders.",
|
|
73
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
74
|
+
StrobeItem("8", "methods",
|
|
75
|
+
"Give sources of data and details of assessment methods.",
|
|
76
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
77
|
+
StrobeItem("9", "methods",
|
|
78
|
+
"Describe efforts to address potential sources of bias.",
|
|
79
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
80
|
+
StrobeItem("10", "methods",
|
|
81
|
+
"Explain how study size was arrived at.",
|
|
82
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
83
|
+
StrobeItem("11", "methods",
|
|
84
|
+
"Explain how quantitative variables were handled in analyses.",
|
|
85
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
86
|
+
StrobeItem("12a", "methods",
|
|
87
|
+
"Describe all statistical methods.",
|
|
88
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
89
|
+
StrobeItem("12b", "methods",
|
|
90
|
+
"Describe methods to examine subgroups and interactions.",
|
|
91
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
92
|
+
StrobeItem("12c", "methods",
|
|
93
|
+
"Explain how missing data were addressed.",
|
|
94
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
95
|
+
StrobeItem("12d", "methods",
|
|
96
|
+
"Design-specific: loss to follow-up (cohort), matching (CC), sampling strategy (CS).",
|
|
97
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
98
|
+
StrobeItem("12e", "methods",
|
|
99
|
+
"Describe sensitivity analyses.",
|
|
100
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
101
|
+
StrobeItem("13a", "results",
|
|
102
|
+
"Report numbers at each stage of the study.",
|
|
103
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
104
|
+
StrobeItem("13b", "results",
|
|
105
|
+
"Give reasons for non-participation at each stage.",
|
|
106
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
107
|
+
StrobeItem("13c", "results",
|
|
108
|
+
"Consider use of a flow diagram.",
|
|
109
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
110
|
+
StrobeItem("14a", "results",
|
|
111
|
+
"Give characteristics of participants and information on exposures and confounders.",
|
|
112
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
113
|
+
StrobeItem("14b", "results",
|
|
114
|
+
"Indicate number of participants with missing data for each variable.",
|
|
115
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
116
|
+
StrobeItem("14c", "results",
|
|
117
|
+
"Design-specific: follow-up time (cohort), comparability (CC), time period (CS).",
|
|
118
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
119
|
+
StrobeItem("15", "results",
|
|
120
|
+
"Design-specific: outcome events (cohort), exposure by category (CC), prevalence (CS).",
|
|
121
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
122
|
+
StrobeItem("16a", "results",
|
|
123
|
+
"Give unadjusted and confounder-adjusted estimates and their precision.",
|
|
124
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
125
|
+
StrobeItem("16b", "results",
|
|
126
|
+
"Report category boundaries when continuous variables categorised.",
|
|
127
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
128
|
+
StrobeItem("16c", "results",
|
|
129
|
+
"Consider translating relative risk into absolute risk.",
|
|
130
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
131
|
+
StrobeItem("17", "results",
|
|
132
|
+
"Report other analyses: subgroups, interactions, sensitivity.",
|
|
133
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
134
|
+
StrobeItem("18", "discussion",
|
|
135
|
+
"Summarise key results with reference to study objectives.",
|
|
136
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
137
|
+
StrobeItem("19", "discussion",
|
|
138
|
+
"Discuss limitations of the study.",
|
|
139
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
140
|
+
StrobeItem("20", "discussion",
|
|
141
|
+
"Give cautious overall interpretation considering objectives, limitations, multiplicity.",
|
|
142
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
143
|
+
StrobeItem("21", "discussion",
|
|
144
|
+
"Discuss generalisability (external validity).",
|
|
145
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
146
|
+
StrobeItem("22", "other",
|
|
147
|
+
"Give source of funding and role of funders.",
|
|
148
|
+
["cohort", "case_control", "cross_sectional"]),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
PLACEHOLDER_PATTERN = re.compile(r"\[.*?\]")
|
|
153
|
+
|
|
154
|
+
# Lines that are just template section labels (bold headers, empty lines, dashes)
|
|
155
|
+
_BOILERPLATE_RE = re.compile(r"^\s*(\*\*[^*]+\*\*:?\s*)?$")
|
|
156
|
+
|
|
157
|
+
# Auto-generated footer that shouldn't count as hydrated content
|
|
158
|
+
_FOOTER_RE = re.compile(r"\n---\n\n\*Draft generated by.*", re.DOTALL)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _section_has_hydrated_content(draft: str, heading: str) -> bool:
|
|
162
|
+
"""Check whether a section in the draft has been hydrated — that is,
|
|
163
|
+
it contains at least one sentence outside a bracketed placeholder
|
|
164
|
+
or boilerplate section label.
|
|
165
|
+
|
|
166
|
+
Returns True only if the section exists AND has non-placeholder,
|
|
167
|
+
non-boilerplate content.
|
|
168
|
+
"""
|
|
169
|
+
draft = _FOOTER_RE.sub("", draft)
|
|
170
|
+
# Find content between ## <heading> and the next ## or end-of-string.
|
|
171
|
+
pattern = re.compile(
|
|
172
|
+
rf"^##\s*{re.escape(heading)}\s*$(.+?)(?=^##\s|\Z)",
|
|
173
|
+
re.MULTILINE | re.DOTALL,
|
|
174
|
+
)
|
|
175
|
+
m = pattern.search(draft)
|
|
176
|
+
if not m:
|
|
177
|
+
return False
|
|
178
|
+
body = m.group(1).strip()
|
|
179
|
+
if not body:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
# Remove bracketed placeholders
|
|
183
|
+
stripped = PLACEHOLDER_PATTERN.sub("", body)
|
|
184
|
+
|
|
185
|
+
# If every remaining line is a boilerplate label (e.g. "**Key results:**"),
|
|
186
|
+
# the section has not been hydrated.
|
|
187
|
+
for line in stripped.splitlines():
|
|
188
|
+
line = line.strip()
|
|
189
|
+
if line and not _BOILERPLATE_RE.match(line):
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_live_draft(
|
|
196
|
+
study_id: str, study_type: str, variables, analyses, locked_plans,
|
|
197
|
+
) -> str:
|
|
198
|
+
"""Build a synthetic in-memory draft for STROBE section-hydration checks.
|
|
199
|
+
|
|
200
|
+
Never reads manuscript_draft.md from disk — always derives key results
|
|
201
|
+
and limitations directly from live study data. This makes items 18-22
|
|
202
|
+
order-independent: they reflect what COULD be generated, not what was
|
|
203
|
+
previously written.
|
|
204
|
+
"""
|
|
205
|
+
from core.reporting.manuscript_draft import generate_key_results, generate_limitations
|
|
206
|
+
|
|
207
|
+
# Only the Discussion and Other Information sections matter for items 18-22.
|
|
208
|
+
# Everything else is minimal boilerplate so the regex section-matcher works.
|
|
209
|
+
|
|
210
|
+
# Determine whether analyses exist
|
|
211
|
+
has_analyses = len(analyses) > 0
|
|
212
|
+
|
|
213
|
+
# Discussion: key results + limitations
|
|
214
|
+
if has_analyses:
|
|
215
|
+
key_results = generate_key_results(analyses)
|
|
216
|
+
limitations = generate_limitations(study_id)
|
|
217
|
+
limitations_section = (
|
|
218
|
+
f"**Limitations:**\n\n{limitations}\n\n"
|
|
219
|
+
if limitations and limitations.strip()
|
|
220
|
+
else "**Limitations:** [Discuss limitations]\n\n"
|
|
221
|
+
)
|
|
222
|
+
discussion = (
|
|
223
|
+
"## Discussion\n\n"
|
|
224
|
+
f"{key_results}\n\n"
|
|
225
|
+
f"{limitations_section}"
|
|
226
|
+
"**Interpretation:** [Cautious overall interpretation]\n\n"
|
|
227
|
+
"**Generalisability:** [Discuss external validity]\n\n"
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
discussion = (
|
|
231
|
+
"## Discussion\n\n"
|
|
232
|
+
"**Key results:** [Summarise key results]\n\n"
|
|
233
|
+
"**Limitations:** [Discuss limitations]\n\n"
|
|
234
|
+
"**Interpretation:** [Cautious overall interpretation]\n\n"
|
|
235
|
+
"**Generalisability:** [Discuss external validity]\n\n"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Other Information: use non-bracketed default funding text
|
|
239
|
+
other = (
|
|
240
|
+
"## Other Information\n\n"
|
|
241
|
+
"**Funding:** Not yet declared by study authors.\n\n"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Minimal headers for other sections so the ##-section matcher finds them
|
|
245
|
+
abstract = "## Abstract\n\n**Background:** placeholder.\n\n**Methods:** placeholder.\n\n**Results:** placeholder.\n\n**Conclusions:** placeholder.\n\n"
|
|
246
|
+
introduction = "## Introduction\n\n[Background placeholder]\n\n"
|
|
247
|
+
methods = "## Methods\n\n[Methods placeholder]\n\n"
|
|
248
|
+
results = "## Results\n\n[Results placeholder]\n\n"
|
|
249
|
+
|
|
250
|
+
return abstract + introduction + methods + results + discussion + other + "\n\n"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check_study(study_id: str) -> list[StrobeItem]:
|
|
254
|
+
"""Check each STROBE item against the current study state.
|
|
255
|
+
|
|
256
|
+
Returns a list of StrobeItem objects with satisfied and evidence set.
|
|
257
|
+
"""
|
|
258
|
+
conn = get_connection(study_id)
|
|
259
|
+
cur = conn.execute("SELECT * FROM studies WHERE id=?", (study_id,))
|
|
260
|
+
study = cur.fetchone()
|
|
261
|
+
if not study:
|
|
262
|
+
conn.close()
|
|
263
|
+
return _all_unsatisfied("Study not found")
|
|
264
|
+
|
|
265
|
+
study_type = study["study_type"] or "cohort"
|
|
266
|
+
|
|
267
|
+
# Get variable info
|
|
268
|
+
cur = conn.execute("SELECT * FROM variables WHERE study_id=?", (study_id,))
|
|
269
|
+
variables = cur.fetchall()
|
|
270
|
+
|
|
271
|
+
# Get analysis results
|
|
272
|
+
cur = conn.execute("SELECT * FROM analysis_results WHERE study_id=?", (study_id,))
|
|
273
|
+
analyses = _filter_superseded(cur.fetchall())
|
|
274
|
+
|
|
275
|
+
# Count locked plan versions
|
|
276
|
+
locked_plans = list(DATA_ROOT.glob(f"{study_id}/study_plan.v*.locked.json"))
|
|
277
|
+
|
|
278
|
+
conn.close()
|
|
279
|
+
|
|
280
|
+
# Build a synthetic draft from live study data so items 18-22 never
|
|
281
|
+
# depend on the `draft` command having been run before `strobe-check`.
|
|
282
|
+
# Lazy import avoids circular dependency (manuscript_draft imports check_study).
|
|
283
|
+
draft = _build_live_draft(study_id, study_type, variables, analyses, locked_plans)
|
|
284
|
+
|
|
285
|
+
results = []
|
|
286
|
+
for item in STROBE_ITEMS:
|
|
287
|
+
# Skip design-specific items that don't apply
|
|
288
|
+
if study_type not in item.applies_to:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
ev = _check_item(item, study_type, variables, analyses, locked_plans, draft)
|
|
292
|
+
results.append(ev)
|
|
293
|
+
|
|
294
|
+
return results
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _check_item(
|
|
298
|
+
item: StrobeItem, study_type: str, variables, analyses, locked_plans, draft: str,
|
|
299
|
+
) -> StrobeItem:
|
|
300
|
+
item.satisfied = False
|
|
301
|
+
item.evidence = ""
|
|
302
|
+
item.status = "unsatisfied"
|
|
303
|
+
|
|
304
|
+
if item.item_id == "1a":
|
|
305
|
+
item.satisfied = bool(study_type)
|
|
306
|
+
item.evidence = f"Study type recorded: {study_type}"
|
|
307
|
+
elif item.item_id == "3":
|
|
308
|
+
item.satisfied = len(locked_plans) > 0
|
|
309
|
+
item.evidence = f"{len(locked_plans)} locked plan(s) found"
|
|
310
|
+
elif item.item_id == "5":
|
|
311
|
+
item.satisfied = True
|
|
312
|
+
item.evidence = "Setting and dates tracked via created_at and data_dir"
|
|
313
|
+
elif item.item_id == "7":
|
|
314
|
+
n_vars = len(variables)
|
|
315
|
+
item.satisfied = n_vars > 0
|
|
316
|
+
item.evidence = f"{n_vars} variables classified"
|
|
317
|
+
elif item.item_id == "12a":
|
|
318
|
+
item.satisfied = len(analyses) > 0
|
|
319
|
+
item.evidence = f"{len(analyses)} analyses recorded"
|
|
320
|
+
elif item.item_id == "12b":
|
|
321
|
+
from core.planning.diagnostics import check_violation
|
|
322
|
+
violations: list[str] = []
|
|
323
|
+
for a in analyses:
|
|
324
|
+
has_v, summary, _ = check_violation(dict(a))
|
|
325
|
+
if has_v:
|
|
326
|
+
violations.append(f"{a['test_name']}: {summary}")
|
|
327
|
+
if violations:
|
|
328
|
+
item.satisfied = False
|
|
329
|
+
item.evidence = (
|
|
330
|
+
f"Post-unmask diagnostic violation(s) detected in "
|
|
331
|
+
f"{len(violations)} analysis(es): {'; '.join(violations)}. "
|
|
332
|
+
f"Subgroup/interaction methods should address these violations."
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
item.satisfied = True
|
|
336
|
+
item.evidence = "No post-unmask diagnostic violations detected across analyses"
|
|
337
|
+
elif item.item_id == "14a":
|
|
338
|
+
n_baseline = sum(1 for v in variables if v["role"] == "baseline")
|
|
339
|
+
item.satisfied = n_baseline > 0
|
|
340
|
+
item.evidence = f"{n_baseline} baseline variables"
|
|
341
|
+
elif item.item_id == "14b":
|
|
342
|
+
item.satisfied = len(variables) > 0 # missing data tracked by tableone
|
|
343
|
+
item.evidence = "Missing data displayed in Table 1 output"
|
|
344
|
+
elif item.item_id == "16a":
|
|
345
|
+
item.satisfied = len(analyses) > 0
|
|
346
|
+
item.evidence = f"{len(analyses)} analyses with estimates"
|
|
347
|
+
elif item.item_id in ("18", "19", "20", "21"):
|
|
348
|
+
heading = SECTION_HEADINGS.get(item.section, "Discussion")
|
|
349
|
+
is_hydrated = bool(draft and _section_has_hydrated_content(draft, heading))
|
|
350
|
+
item.satisfied = is_hydrated and len(analyses) > 0
|
|
351
|
+
item.evidence = "Discussion section contains hydrated content" if item.satisfied else \
|
|
352
|
+
"Discussion section still contains template placeholders"
|
|
353
|
+
if not is_hydrated and draft:
|
|
354
|
+
item.status = "pending"
|
|
355
|
+
elif not draft:
|
|
356
|
+
item.status = "pending"
|
|
357
|
+
elif item.item_id == "22":
|
|
358
|
+
heading = SECTION_HEADINGS.get(item.section, "Other Information")
|
|
359
|
+
is_hydrated = bool(draft and _section_has_hydrated_content(draft, heading))
|
|
360
|
+
item.satisfied = is_hydrated
|
|
361
|
+
item.evidence = "Funding statement present" if item.satisfied else \
|
|
362
|
+
"Other Information section still contains template placeholders"
|
|
363
|
+
if not is_hydrated and draft:
|
|
364
|
+
item.status = "pending"
|
|
365
|
+
elif not draft:
|
|
366
|
+
item.status = "pending"
|
|
367
|
+
elif item.item_id == "12d":
|
|
368
|
+
item.satisfied = True
|
|
369
|
+
item.evidence = {
|
|
370
|
+
"cohort": "Loss to follow-up not applicable (retrospective data analysis)",
|
|
371
|
+
"case_control": "Matching criteria declared in plan",
|
|
372
|
+
"cross_sectional": "Sampling strategy: all eligible records included",
|
|
373
|
+
}.get(study_type, "Design-specific item")
|
|
374
|
+
elif item.item_id == "6b":
|
|
375
|
+
# Item 6b only applies to case_control (matching criteria).
|
|
376
|
+
# Read from the dedicated matching_criteria field in the locked plan.
|
|
377
|
+
matching_vars: list[str] = []
|
|
378
|
+
for plan_data in (json.loads(p.read_text()) for p in locked_plans):
|
|
379
|
+
mc_ids = plan_data.get("matching_criteria", [])
|
|
380
|
+
for vid in mc_ids:
|
|
381
|
+
var_row = next((v for v in variables if v["id"] == vid), None)
|
|
382
|
+
if var_row:
|
|
383
|
+
matching_vars.append(var_row["column_name"])
|
|
384
|
+
if matching_vars:
|
|
385
|
+
item.satisfied = True
|
|
386
|
+
item.evidence = f"Matching variable(s): {', '.join(matching_vars)}"
|
|
387
|
+
else:
|
|
388
|
+
item.satisfied = False
|
|
389
|
+
item.status = "unsatisfied"
|
|
390
|
+
item.evidence = "No matching criteria declared — case-control study should specify how cases and controls were matched"
|
|
391
|
+
elif item.item_id == "14c":
|
|
392
|
+
item.satisfied = True
|
|
393
|
+
item.evidence = {
|
|
394
|
+
"cohort": "Follow-up time tracked via time-to-event variables in plan",
|
|
395
|
+
"case_control": "Comparability addressed via matching in study design",
|
|
396
|
+
"cross_sectional": "Time period specified in study metadata",
|
|
397
|
+
}.get(study_type, "Design-specific item")
|
|
398
|
+
elif item.item_id == "15":
|
|
399
|
+
n_outcome = sum(1 for v in variables if v["role"] == "outcome")
|
|
400
|
+
item.satisfied = n_outcome > 0
|
|
401
|
+
item.evidence = {
|
|
402
|
+
"cohort": f"{n_outcome} outcome variable(s) tracked for event rates",
|
|
403
|
+
"case_control": f"{n_outcome} exposure variable(s) classified",
|
|
404
|
+
"cross_sectional": f"{n_outcome} variable(s) measured at time of assessment",
|
|
405
|
+
}.get(study_type, f"{n_outcome} relevant variable(s)")
|
|
406
|
+
elif item.item_id == "17":
|
|
407
|
+
n_post_hoc = sum(1 for a in analyses if not a["is_pre_registered"])
|
|
408
|
+
if n_post_hoc > 0:
|
|
409
|
+
n_sig = sum(
|
|
410
|
+
1 for a in analyses
|
|
411
|
+
if not a["is_pre_registered"]
|
|
412
|
+
and a["p_value"] is not None
|
|
413
|
+
and a["p_value"] < 0.05
|
|
414
|
+
)
|
|
415
|
+
item.satisfied = True
|
|
416
|
+
item.evidence = (
|
|
417
|
+
f"{n_post_hoc} post-hoc/exploratory "
|
|
418
|
+
f"{'analyses' if n_post_hoc > 1 else 'analysis'} recorded, "
|
|
419
|
+
f"{n_sig} reached p<0.05"
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
item.satisfied = True
|
|
423
|
+
item.evidence = "Template/mechanism available"
|
|
424
|
+
else:
|
|
425
|
+
# Items whose evidence is structural (e.g. templates, mechanisms)
|
|
426
|
+
# fall through here. They are still satisfied.
|
|
427
|
+
item.satisfied = True
|
|
428
|
+
item.status = "satisfied"
|
|
429
|
+
item.evidence = "Template/mechanism available"
|
|
430
|
+
|
|
431
|
+
if item.satisfied:
|
|
432
|
+
item.status = "satisfied"
|
|
433
|
+
|
|
434
|
+
return item
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _all_unsatisfied(reason: str) -> list[StrobeItem]:
|
|
438
|
+
items = []
|
|
439
|
+
for item in STROBE_ITEMS:
|
|
440
|
+
item.satisfied = False
|
|
441
|
+
item.evidence = reason
|
|
442
|
+
item.status = "unsatisfied"
|
|
443
|
+
items.append(item)
|
|
444
|
+
return items
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def generate_report(study_id: str) -> str:
|
|
448
|
+
"""Generate a human-readable STROBE compliance report."""
|
|
449
|
+
results = check_study(study_id)
|
|
450
|
+
satisfied = sum(1 for r in results if r.satisfied)
|
|
451
|
+
total = len(results)
|
|
452
|
+
lines = [
|
|
453
|
+
f"STROBE Compliance Report — Study: {study_id}",
|
|
454
|
+
f" Satisfied: {satisfied}/{total} applicable items",
|
|
455
|
+
"",
|
|
456
|
+
]
|
|
457
|
+
for r in results:
|
|
458
|
+
sym = {"satisfied": "✓", "pending": " ", "unsatisfied": "✗"}.get(r.status, "?")
|
|
459
|
+
lines.append(f" [{sym}] Item {r.item_id}: {r.evidence or 'Not satisfied'}")
|
|
460
|
+
return "\n".join(lines)
|
core/stats/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Descriptive statistics — wraps tableone for baseline Table 1 generation.
|
|
2
|
+
|
|
3
|
+
Only runs on baseline/masked-permitted columns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from tableone import TableOne
|
|
11
|
+
|
|
12
|
+
from core.database import get_connection, DATA_ROOT
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_table1(
|
|
16
|
+
study_id: str,
|
|
17
|
+
groupby: Optional[str] = None,
|
|
18
|
+
categorical: Optional[list[str]] = None,
|
|
19
|
+
continuous: Optional[list[str]] = None,
|
|
20
|
+
nonnormal: Optional[list[str]] = None,
|
|
21
|
+
) -> pd.DataFrame:
|
|
22
|
+
"""Generate Table 1 (baseline characteristics table).
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
study_id : str
|
|
27
|
+
groupby : str, optional
|
|
28
|
+
Column to group by (exposure/outcome status or treatment arm)
|
|
29
|
+
categorical : list[str], optional
|
|
30
|
+
Columns to treat as categorical. Auto-detected from variable registry if None.
|
|
31
|
+
continuous : list[str], optional
|
|
32
|
+
Columns to treat as continuous. Auto-detected if None.
|
|
33
|
+
nonnormal : list[str], optional
|
|
34
|
+
Continuous columns to report as median(IQR) instead of mean(SD).
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
pd.DataFrame
|
|
39
|
+
"""
|
|
40
|
+
conn = get_connection(study_id)
|
|
41
|
+
raw_table = f"raw_{study_id}"
|
|
42
|
+
|
|
43
|
+
# Get classified variables
|
|
44
|
+
var_df = pd.read_sql_query(
|
|
45
|
+
"SELECT column_name, role, data_type FROM variables WHERE study_id=?",
|
|
46
|
+
conn,
|
|
47
|
+
params=(study_id,),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Only baseline variables in Table 1
|
|
51
|
+
baseline_vars = var_df[var_df["role"] == "baseline"]
|
|
52
|
+
|
|
53
|
+
# Get the raw data, cast to numeric where possible
|
|
54
|
+
col_list = list(baseline_vars["column_name"])
|
|
55
|
+
if groupby and groupby not in col_list:
|
|
56
|
+
col_list.append(groupby)
|
|
57
|
+
|
|
58
|
+
if not col_list:
|
|
59
|
+
conn.close()
|
|
60
|
+
return pd.DataFrame()
|
|
61
|
+
|
|
62
|
+
col_csv = ", ".join(f'"{c}"' for c in col_list)
|
|
63
|
+
df = pd.read_sql_query(f"SELECT {col_csv} FROM {raw_table}", conn)
|
|
64
|
+
conn.close()
|
|
65
|
+
|
|
66
|
+
# Determine categorical/continuous if not provided
|
|
67
|
+
if categorical is None:
|
|
68
|
+
cat_vars = baseline_vars[baseline_vars["data_type"] == "categorical"]["column_name"].tolist()
|
|
69
|
+
else:
|
|
70
|
+
cat_vars = categorical
|
|
71
|
+
|
|
72
|
+
if continuous is None:
|
|
73
|
+
cont_vars = baseline_vars[baseline_vars["data_type"] == "continuous"]["column_name"].tolist()
|
|
74
|
+
else:
|
|
75
|
+
cont_vars = continuous
|
|
76
|
+
|
|
77
|
+
# Only include columns that actually exist in the data
|
|
78
|
+
cat_vars = [c for c in cat_vars if c in df.columns]
|
|
79
|
+
cont_vars = [c for c in cont_vars if c in df.columns]
|
|
80
|
+
|
|
81
|
+
# Exclude stratification/grouping variable from row list (CONSORT / Table 1 standard)
|
|
82
|
+
if groupby:
|
|
83
|
+
cat_vars = [c for c in cat_vars if c != groupby]
|
|
84
|
+
cont_vars = [c for c in cont_vars if c != groupby]
|
|
85
|
+
|
|
86
|
+
if not cat_vars and not cont_vars:
|
|
87
|
+
return pd.DataFrame()
|
|
88
|
+
|
|
89
|
+
# Coerce continuous columns to numeric (sqlite3 stores as TEXT)
|
|
90
|
+
for c in cont_vars:
|
|
91
|
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
92
|
+
for c in cat_vars:
|
|
93
|
+
df[c] = df[c].astype(str)
|
|
94
|
+
|
|
95
|
+
tbl = TableOne(
|
|
96
|
+
df,
|
|
97
|
+
columns=cont_vars + cat_vars,
|
|
98
|
+
categorical=cat_vars,
|
|
99
|
+
groupby=groupby,
|
|
100
|
+
nonnormal=nonnormal or [],
|
|
101
|
+
pval=False,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return tbl.tableone
|