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.
Files changed (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -0,0 +1,20 @@
1
+ """Patient flow diagram (CONSORT/STROBE style) for study pipeline stages."""
2
+ from __future__ import annotations
3
+
4
+ from .flowchart import (
5
+ FlowStage,
6
+ FlowchartData,
7
+ load_flowchart_data,
8
+ render_ascii,
9
+ render_svg,
10
+ render_flowchart,
11
+ )
12
+
13
+ __all__ = [
14
+ "FlowStage",
15
+ "FlowchartData",
16
+ "load_flowchart_data",
17
+ "render_ascii",
18
+ "render_svg",
19
+ "render_flowchart",
20
+ ]
@@ -0,0 +1,511 @@
1
+ """Patient flow diagram (CONSORT/STROBE style) for study pipeline stages."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from core.database import DATA_ROOT, get_connection
9
+ from core.reporting import (
10
+ format_label as _format_label,
11
+ latest_locked_plan as _latest_locked_plan,
12
+ svg_escape as _svg_escape,
13
+ )
14
+
15
+
16
+ @dataclass
17
+ class FlowStage:
18
+ name: str
19
+ total: int
20
+ excluded: int
21
+ remaining: int
22
+ details: dict = field(default_factory=dict)
23
+ note: str | None = None
24
+
25
+
26
+ @dataclass
27
+ class FlowchartData:
28
+ study_id: str
29
+ stages: list[FlowStage]
30
+ study_name: str = ""
31
+ study_type: str = ""
32
+ arm_column: str | None = None
33
+ arm_counts: dict[str, int] = field(default_factory=dict)
34
+ arm_analyzed_counts: dict[str, int] = field(default_factory=dict)
35
+ primary_analysis_label: str = ""
36
+
37
+
38
+ def _get_plan_arm_col(study_id: str) -> str | None:
39
+ plan = _latest_locked_plan(study_id)
40
+ cox_models = plan.get("cox_ph_models", [])
41
+ if cox_models:
42
+ return cox_models[0].get("primary_treatment_col")
43
+ return None
44
+
45
+
46
+ def load_flowchart_data(study_id: str) -> FlowchartData:
47
+ conn = get_connection(study_id)
48
+ raw = f"raw_{study_id}"
49
+
50
+ study_name = ""
51
+ study_type = ""
52
+ row = conn.execute("SELECT name, study_type FROM studies WHERE id=?", (study_id,)).fetchone()
53
+ if row:
54
+ study_name = row[0] or ""
55
+ study_type = row[1] or ""
56
+
57
+ stages: list[FlowStage] = []
58
+
59
+ # Stage 1: Assessed for eligibility
60
+ total_ingested = conn.execute(f'SELECT COUNT(*) FROM {raw}').fetchone()[0]
61
+ stages.append(FlowStage(
62
+ name="Assessed for eligibility",
63
+ total=total_ingested,
64
+ excluded=0,
65
+ remaining=total_ingested,
66
+ ))
67
+
68
+ # Stage 2: Excluded at ingest (duplicate patient IDs)
69
+ dupes = []
70
+ try:
71
+ from core.ingestion.csv_loader import find_duplicate_patient_ids
72
+ dupes = find_duplicate_patient_ids(study_id)
73
+ except Exception:
74
+ dupes = []
75
+ n_dupes = sum(cnt - 1 for _, cnt in dupes) if dupes else 0
76
+ remaining_after_ingest = total_ingested - n_dupes
77
+ stages.append(FlowStage(
78
+ name="Excluded at ingest (duplicate patient IDs)",
79
+ total=total_ingested,
80
+ excluded=n_dupes,
81
+ remaining=remaining_after_ingest,
82
+ details={"duplicates": dupes} if dupes else {},
83
+ note="Duplicates are retained in analysis; verify they are genuine repeat records" if dupes else None,
84
+ ))
85
+
86
+ # Stage 3: Eligible cohort
87
+ stages.append(FlowStage(
88
+ name="Eligible cohort",
89
+ total=remaining_after_ingest,
90
+ excluded=0,
91
+ remaining=remaining_after_ingest,
92
+ note="No row exclusions at classification step; purely a labeling step",
93
+ ))
94
+
95
+ # Stage 4: Allocated to each arm/group
96
+ arm_col = _get_plan_arm_col(study_id)
97
+ arm_counts = {}
98
+ if arm_col:
99
+ rows = conn.execute(f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} GROUP BY "{arm_col}"').fetchall()
100
+ for r in rows:
101
+ val = r[0] if r[0] is not None else "missing"
102
+ arm_counts[str(val)] = r[1]
103
+ stages.append(FlowStage(
104
+ name="Allocated to each arm/group",
105
+ total=remaining_after_ingest,
106
+ excluded=0,
107
+ remaining=remaining_after_ingest,
108
+ details={"arm_counts": arm_counts} if arm_counts else {},
109
+ ))
110
+
111
+ # Stage 5: Analyzed
112
+ # Determine primary analysis label from plan
113
+ primary_analysis_label = "Primary Endpoints"
114
+ plan = _latest_locked_plan(study_id)
115
+ if plan:
116
+ try:
117
+ cox_models = plan.get("cox_ph_models", [])
118
+ if cox_models:
119
+ mn = cox_models[0].get("model_name") or ""
120
+ if mn and mn.lower() not in ("pfs_multivariable", "cox_ph_model", "pfs_model"):
121
+ primary_analysis_label = f"Primary Endpoints ({_format_label(mn)})"
122
+ else:
123
+ primary_analysis_label = "Primary Endpoints"
124
+ else:
125
+ all_tests = plan.get("planned_tests", []) + plan.get("post_hoc_tests", [])
126
+ if all_tests:
127
+ tn = all_tests[0].get("test_name", "")
128
+ if tn:
129
+ primary_analysis_label = f"Primary Endpoints ({_format_label(tn)})"
130
+ except Exception:
131
+ pass
132
+
133
+ analyzed_n = remaining_after_ingest
134
+ excluded_from_analysis = 0
135
+ latest_result = conn.execute("""
136
+ SELECT sample_counts_json
137
+ FROM analysis_results
138
+ WHERE study_id=? AND test_name='cox_ph_model'
139
+ AND id NOT IN (
140
+ SELECT COALESCE(superseded_previous_result_id, -1)
141
+ FROM analysis_results
142
+ WHERE study_id=? AND test_name='cox_ph_model'
143
+ AND superseded_previous_result_id IS NOT NULL
144
+ )
145
+ ORDER BY id DESC LIMIT 1
146
+ """, (study_id, study_id)).fetchone()
147
+
148
+ if latest_result and latest_result[0]:
149
+ sc = json.loads(latest_result[0])
150
+ analyzed_n = sc.get("n_analyzed", remaining_after_ingest)
151
+ excluded_from_analysis = sc.get("n_excluded", 0)
152
+
153
+ stages.append(FlowStage(
154
+ name="Analyzed (primary analysis: Cox PH model)",
155
+ total=remaining_after_ingest,
156
+ excluded=excluded_from_analysis,
157
+ remaining=analyzed_n,
158
+ details={"n_total": remaining_after_ingest, "n_analyzed": analyzed_n, "n_excluded": excluded_from_analysis},
159
+ ))
160
+
161
+ # Per-arm analyzed counts: rows where all Cox model columns are non-null
162
+ arm_analyzed_counts: dict[str, int] = {}
163
+ if arm_col and analyzed_n > 0:
164
+ plan = _latest_locked_plan(study_id)
165
+ cox_cols = [arm_col]
166
+ try:
167
+ m = plan.get("cox_ph_models", [{}])[0]
168
+ if m.get("survival_time_col"):
169
+ cox_cols.append(m["survival_time_col"])
170
+ if m.get("event_col"):
171
+ cox_cols.append(m["event_col"])
172
+ for c in m.get("covariate_cols", []):
173
+ cox_cols.append(c)
174
+ except Exception:
175
+ pass
176
+ nonnull_conditions = " AND ".join(f'"{c}" IS NOT NULL' for c in set(cox_cols))
177
+ rows = conn.execute(
178
+ f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} WHERE {nonnull_conditions} GROUP BY "{arm_col}"'
179
+ ).fetchall()
180
+ for r in rows:
181
+ val = r[0] if r[0] is not None else "missing"
182
+ arm_analyzed_counts[str(val)] = r[1]
183
+
184
+ # Stage 6: Final analyzed set per arm
185
+ final_arm_counts = {}
186
+ if arm_col and analyzed_n > 0:
187
+ rows = conn.execute(f'SELECT "{arm_col}", COUNT(*) as cnt FROM {raw} GROUP BY "{arm_col}"').fetchall()
188
+ for r in rows:
189
+ val = r[0] if r[0] is not None else "missing"
190
+ final_arm_counts[str(val)] = r[1]
191
+
192
+ stages.append(FlowStage(
193
+ name="Final analyzed set per arm",
194
+ total=analyzed_n,
195
+ excluded=0,
196
+ remaining=analyzed_n,
197
+ details={"arm_counts": final_arm_counts} if final_arm_counts else {},
198
+ ))
199
+
200
+ conn.close()
201
+
202
+ return FlowchartData(
203
+ study_id=study_id,
204
+ stages=stages,
205
+ study_name=study_name,
206
+ study_type=study_type,
207
+ arm_column=arm_col,
208
+ arm_counts=arm_counts,
209
+ arm_analyzed_counts=arm_analyzed_counts,
210
+ primary_analysis_label=primary_analysis_label,
211
+ )
212
+
213
+
214
+ # ── SVG renderer ─────────────────────────────────────────────────────────────
215
+
216
+
217
+
218
+ def _draw_box(parts: list, x: int, y: int, w: int, h: int,
219
+ stroke: str = "#1F4E78", fill: str = "#F8F9FA") -> None:
220
+ parts.append(f' <rect x="{x}" y="{y}" width="{w}" height="{h}" '
221
+ f'rx="6" ry="6" fill="{fill}" stroke="{stroke}" stroke-width="2" />')
222
+
223
+
224
+ def _draw_text(parts: list, x: int, y: int, text: str, font_size: int = 13,
225
+ fill: str = "#333", bold: bool = False,
226
+ italic: bool = False, anchor: str | None = None) -> None:
227
+ attrs = f'font-size="{font_size}" fill="{fill}"'
228
+ if bold:
229
+ attrs += ' font-weight="bold"'
230
+ if italic:
231
+ attrs += ' font-style="italic"'
232
+ if anchor:
233
+ attrs += f' text-anchor="{anchor}"'
234
+ parts.append(f' <text x="{x}" y="{y}" {attrs}>{_svg_escape(text)}</text>')
235
+
236
+
237
+ def _draw_arrow(parts: list, x1: int, y1: int, x2: int, y2: int) -> None:
238
+ parts.append(f' <line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
239
+ f'stroke="#1F4E78" stroke-width="2" marker-end="url(#arrowhead)" />')
240
+
241
+
242
+ def _draw_line(parts: list, x1: int, y1: int, x2: int, y2: int) -> None:
243
+ parts.append(f' <line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
244
+ f'stroke="#1F4E78" stroke-width="2" />')
245
+
246
+
247
+ def _draw_pill(parts: list, x: int, y: int, w: int, h: int, label: str) -> None:
248
+ """Rounded pill-box for CONSORT phase labels."""
249
+ ry = h // 2
250
+ parts.append(f' <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{ry}" ry="{ry}" '
251
+ f'fill="#D6EAF8" stroke="#85C1E9" stroke-width="1" />')
252
+ cx = x + w // 2
253
+ cy = y + h // 2 + 4 # vertical center + font baseline offset
254
+ parts.append(f' <text x="{cx}" y="{cy}" font-size="9" fill="#1F4E78" font-weight="bold" '
255
+ f'text-anchor="middle">{label}</text>')
256
+
257
+
258
+ def render_svg(data: FlowchartData, output_path: str,
259
+ show_title: bool = True, show_watermark: bool = False,
260
+ verbose: bool = False,
261
+ show_study_name: bool = False) -> None:
262
+ box_h = 65
263
+ main_w = 340
264
+ arm_w = 240
265
+ excl_w = 185
266
+ excl_h = 42
267
+ vgap = 30
268
+ hgap = 25
269
+ col_gap = 35
270
+ pill_w = 125
271
+ pill_h = 22
272
+ pill_x = 8
273
+ header_h = 80
274
+ margin = 40
275
+
276
+ stages = data.stages
277
+ arms = list(data.arm_counts.keys())
278
+ n_arms = len(arms)
279
+ has_arms = n_arms > 0
280
+
281
+ needs_final_row = False
282
+ if has_arms:
283
+ for a in arms:
284
+ if data.arm_analyzed_counts.get(a, 0) != data.arm_counts.get(a, 0):
285
+ needs_final_row = True
286
+ break
287
+
288
+ # Compute arm column positions first — these are the centering reference
289
+ if has_arms:
290
+ total_arm_w = n_arms * arm_w + (n_arms - 1) * col_gap
291
+ else:
292
+ total_arm_w = main_w
293
+
294
+ # SVG width: arms centered, with room for pill labels + exclusion overflow
295
+ svg_w = max(total_arm_w + 2 * (pill_x + pill_w + 15), margin + main_w + hgap + excl_w + margin, 700)
296
+
297
+ # Center arms in svg_w
298
+ arm_start_x = (svg_w - total_arm_w) // 2
299
+ arm_xs = [arm_start_x + i * (arm_w + col_gap) for i in range(n_arms)]
300
+ arm_cx = [x + arm_w // 2 for x in arm_xs]
301
+
302
+ # Enrollment centered on arm midpoint, exclusion overflows right
303
+ if has_arms:
304
+ main_cx = (arm_cx[0] + arm_cx[-1]) // 2
305
+ else:
306
+ main_cx = svg_w // 2
307
+ main_x = main_cx - main_w // 2
308
+ excl_x = main_x + main_w + hgap
309
+
310
+ # Expand SVG if exclusion box overflows
311
+ need_w = excl_x + excl_w + margin
312
+ if need_w > svg_w:
313
+ svg_w = need_w
314
+
315
+ # Y positions
316
+ y0 = margin + header_h # Enrollment
317
+ y3 = y0 + box_h + vgap # Arm row
318
+ y4 = y3 + box_h + vgap # Analyzed
319
+ y5 = y4 + box_h + vgap # Final (if needed)
320
+ excl_center_y = y0 + box_h // 2
321
+ svg_h = (y5 + box_h + 40) if (has_arms and needs_final_row) else (y4 + box_h + 40)
322
+
323
+ # Phase label Y — center of each phase row
324
+ pill_enroll_y = y0 + box_h // 2 - pill_h // 2
325
+ pill_alloc_y = y3 + box_h // 2 - pill_h // 2
326
+ pill_analy_y = y4 + box_h // 2 - pill_h // 2
327
+
328
+ parts: list[str] = []
329
+ parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" '
330
+ f'font-family="Arial, Helvetica, sans-serif" font-size="13">')
331
+
332
+ # ── Title ──────────────────────────────────────────────────────────
333
+ # CONSORT for RCTs, STROBE for observational studies
334
+ if data.study_type == "rct":
335
+ flow_label = "CONSORT Participant Flow Diagram"
336
+ else:
337
+ flow_label = "STROBE Participant Flow Diagram"
338
+ if show_title:
339
+ _draw_text(parts, main_cx, margin + 22, flow_label,
340
+ 18, "#1F4E78", bold=True, anchor="middle")
341
+ if show_study_name and data.study_name:
342
+ _draw_text(parts, main_cx, margin + 40, data.study_name,
343
+ 12, "#777", italic=True, anchor="middle")
344
+
345
+ # Phase pills (left margin) — CONSORT for RCTs, STROBE for observational
346
+ if data.study_type == "rct":
347
+ _draw_pill(parts, pill_x, pill_enroll_y, pill_w, pill_h, "ENROLLMENT")
348
+ _draw_pill(parts, pill_x, pill_alloc_y, pill_w, pill_h, "ALLOCATION")
349
+ _draw_pill(parts, pill_x, pill_analy_y, pill_w, pill_h, "ANALYSIS")
350
+ else:
351
+ _draw_pill(parts, pill_x, pill_enroll_y, pill_w, pill_h, "ELIGIBILITY")
352
+ _draw_pill(parts, pill_x, pill_alloc_y, pill_w, pill_h, "COHORT ASSIGNMENT")
353
+ _draw_pill(parts, pill_x, pill_analy_y, pill_w, pill_h, "ANALYSIS")
354
+
355
+ # ═══════════════════════════════════════════════════════════════════
356
+ # Stage 0: Enrollment (assessed + eligible merged — no exclusion here)
357
+ # ═══════════════════════════════════════════════════════════════════
358
+ s0 = stages[0]
359
+ _draw_box(parts, main_x, y0, main_w, box_h)
360
+ _draw_text(parts, main_cx, y0 + 22, "Assessed for eligibility", 14, "#1F4E78", bold=True, anchor="middle")
361
+ _draw_text(parts, main_cx, y0 + 42, f"n = {s0.remaining}", 13, "#333", anchor="middle")
362
+
363
+ # Exclusion side box (stage 1) — branches off enrollment
364
+ s1 = stages[1]
365
+ _draw_box(parts, excl_x, y0, excl_w, excl_h, "#bbb", "#fafafa")
366
+ _draw_text(parts, excl_x + 8, y0 + 16, f"Excluded (n = {s1.excluded})", 11, "#1F4E78", bold=True)
367
+ _draw_text(parts, excl_x + 8, y0 + 33, f"• Duplicate patient ID (n = {s1.excluded})", 10, "#333")
368
+
369
+ _draw_arrow(parts, main_x + main_w, excl_center_y, excl_x, excl_center_y)
370
+
371
+ # Enrollment → arm split (straight vertical arrows)
372
+ if has_arms and n_arms > 1:
373
+ arm_split_y = y0 + box_h + vgap // 2
374
+ _draw_line(parts, main_cx, y0 + box_h, main_cx, arm_split_y)
375
+ _draw_line(parts, arm_cx[0], arm_split_y, arm_cx[-1], arm_split_y)
376
+ for cx in arm_cx:
377
+ _draw_arrow(parts, cx, arm_split_y, cx, y3)
378
+ else:
379
+ _draw_arrow(parts, main_cx, y0 + box_h, main_cx, y3)
380
+
381
+ # ═══════════════════════════════════════════════════════════════════
382
+ # Arm columns
383
+ # ═══════════════════════════════════════════════════════════════════
384
+ if has_arms:
385
+ for i, arm_name in enumerate(arms):
386
+ ax = arm_xs[i]
387
+ cx = arm_cx[i]
388
+ arm_cnt = data.arm_counts.get(arm_name, 0)
389
+ arm_analyzed = data.arm_analyzed_counts.get(arm_name, 0)
390
+ analy_label = data.primary_analysis_label
391
+
392
+ # Arm label: raw value from data, no synthetic "Arm " prefix
393
+ _draw_box(parts, ax, y3, arm_w, box_h)
394
+ cx_box = ax + arm_w // 2
395
+ _draw_text(parts, cx_box, y3 + 28, f"{arm_name} (n = {arm_cnt})", 14, "#1F4E78",
396
+ bold=True, anchor="middle")
397
+
398
+ # Arrow: arm label → analyzed
399
+ _draw_arrow(parts, cx, y3 + box_h, cx, y4)
400
+
401
+ when_analyzed_eq_allocated = (arm_analyzed == arm_cnt)
402
+
403
+ if when_analyzed_eq_allocated:
404
+ # Two-line: bold "Analyzed", subtext "PFS Multivariable (n = 10)"
405
+ _draw_box(parts, ax, y4, arm_w, box_h)
406
+ _draw_text(parts, cx_box, y4 + 22, "Analyzed", 14, "#1F4E78", bold=True, anchor="middle")
407
+ _draw_text(parts, cx_box, y4 + 42, f"{analy_label} (n = {arm_analyzed})",
408
+ 12, "#555", anchor="middle")
409
+ else:
410
+ # Two distinct boxes: analyzed + final
411
+ _draw_box(parts, ax, y4, arm_w, box_h)
412
+ _draw_text(parts, cx_box, y4 + 22, "Analyzed", 14, "#1F4E78", bold=True, anchor="middle")
413
+ _draw_text(parts, cx_box, y4 + 42, f"{analy_label} (n = {arm_analyzed})",
414
+ 12, "#555", anchor="middle")
415
+
416
+ _draw_arrow(parts, cx, y4 + box_h, cx, y5)
417
+
418
+ _draw_box(parts, ax, y5, arm_w, box_h)
419
+ _draw_text(parts, cx_box, y5 + 22, "Final analyzed set", 14, "#1F4E78",
420
+ bold=True, anchor="middle")
421
+ _draw_text(parts, cx_box, y5 + 42, f"n = {arm_cnt}",
422
+ 12, "#555", anchor="middle")
423
+
424
+ else:
425
+ # No arm data — single column
426
+ _draw_arrow(parts, main_cx, y0 + box_h, main_cx, y3)
427
+ _draw_box(parts, main_x, y3, main_w, box_h)
428
+ _draw_text(parts, main_x + 15, y3 + 22, stages[4].name, 14, "#1F4E78", bold=True)
429
+ _draw_text(parts, main_x + 15, y3 + 42, f"N = {stages[4].remaining}")
430
+
431
+ _draw_arrow(parts, main_cx, y3 + box_h, main_cx, y4)
432
+ _draw_box(parts, main_x, y4, main_w, box_h)
433
+ _draw_text(parts, main_x + 15, y4 + 22, stages[5].name, 14, "#1F4E78", bold=True)
434
+ _draw_text(parts, main_x + 15, y4 + 42, f"N = {stages[5].remaining}")
435
+
436
+ # ── Watermark (opt-in) ───────────────────────────────────────────────
437
+ if show_watermark:
438
+ wm_y = (y5 + box_h + 25) if has_arms and needs_final_row else (y4 + box_h + 25)
439
+ _draw_text(parts, margin, wm_y, "Generated by research-tool flowchart command", 11, "#aaa")
440
+
441
+ # ── Arrowhead marker ─────────────────────────────────────────────────
442
+ parts.append(' <defs>')
443
+ parts.append(' <marker id="arrowhead" markerWidth="10" markerHeight="7" '
444
+ 'refX="9" refY="3.5" orient="auto" markerUnits="strokeWidth">')
445
+ parts.append(' <path d="M0,0 L10,3.5 L0,7 Z" fill="#1F4E78" />')
446
+ parts.append(' </marker>')
447
+ parts.append(' </defs>')
448
+ parts.append("</svg>")
449
+
450
+ Path(output_path).write_text("\n".join(parts))
451
+
452
+
453
+ # ── ASCII renderer (unchanged - for terminal use) ───────────────────────────
454
+
455
+ def render_ascii(data: FlowchartData) -> str:
456
+ lines = []
457
+ box_w = 72
458
+ connector = "│"
459
+
460
+ title = f"Patient Flow Diagram — {data.study_name or data.study_id}"
461
+ lines.append(title)
462
+ lines.append("=" * len(title))
463
+ lines.append("")
464
+
465
+ for i, stage in enumerate(data.stages):
466
+ lines.append(f"┌{'─' * box_w}┐")
467
+ name = stage.name[:box_w - 2]
468
+ lines.append(f"│ {name.ljust(box_w - 2)} │")
469
+
470
+ if stage.excluded > 0:
471
+ nums = f"N = {stage.total} | Excluded = {stage.excluded} | Remaining = {stage.remaining}"
472
+ else:
473
+ nums = f"N = {stage.remaining}"
474
+ lines.append(f"│ {nums.ljust(box_w - 2)} │")
475
+
476
+ if stage.details.get("arm_counts"):
477
+ arms = stage.details["arm_counts"]
478
+ arm_str = " | ".join(f"{k}: {v}" for k, v in arms.items())
479
+ if len(arm_str) > box_w - 4:
480
+ arm_str = arm_str[:box_w - 7] + "..."
481
+ lines.append(f"│ {arm_str.ljust(box_w - 2)} │")
482
+ elif stage.note:
483
+ note = stage.note[:box_w - 4]
484
+ lines.append(f"│ {note.ljust(box_w - 2)} │")
485
+ else:
486
+ lines.append(f"│{' ' * box_w}│")
487
+
488
+ lines.append(f"└{'─' * box_w}┘")
489
+
490
+ if i < len(data.stages) - 1:
491
+ lines.append(f" {connector}")
492
+ lines.append(f" {connector}")
493
+ lines.append("")
494
+
495
+ return "\n".join(lines)
496
+
497
+
498
+ # ── Entry point ──────────────────────────────────────────────────────────────
499
+
500
+ def render_flowchart(study_id: str, output_path: str | None = None,
501
+ ascii: bool = False,
502
+ show_title: bool = True, show_watermark: bool = False,
503
+ verbose: bool = False,
504
+ show_study_name: bool = False) -> Path | str:
505
+ data = load_flowchart_data(study_id)
506
+ if ascii:
507
+ return render_ascii(data)
508
+ path = output_path or str(DATA_ROOT / study_id / "flowchart.svg")
509
+ render_svg(data, path, show_title=show_title, show_watermark=show_watermark,
510
+ verbose=verbose, show_study_name=show_study_name)
511
+ return Path(path)