from __future__ import annotations from pathlib import Path from typing import Any from module_runner import run_registry from trial_profile import normalize_trial DEFAULT_PROJECT_ROOT = Path(__file__).resolve().parents[1] def build_report(raw_form: dict[str, Any], project_root: Path | None = None) -> dict[str, Any]: profile = normalize_trial(raw_form) validation = profile["validation"] if validation["errors"]: return { "ok": False, "trial_feature_profile": profile, "modules": {}, "final_output": { "status": "needs_input", "message": "Complete required fields before running the evidence summary.", "plain_language": [ "The protocol entry is incomplete.", "Fix the required fields shown in validation errors, then run the report again.", ], }, } root = _resolve_project_root(project_root) bundle = run_registry(profile, root) modules = bundle["modules"] capabilities = bundle["capabilities"] pipeline = bundle["pipeline"] evidence = modules["historical_comparator"]["result"] final_output = _final_output(profile, modules, capabilities, pipeline) return { "ok": True, "trial_feature_profile": profile, "modules": modules, "historical_evidence": evidence, "final_output": final_output, } def _resolve_project_root(project_root: Path | None) -> Path: if project_root is None: return DEFAULT_PROJECT_ROOT resolved = project_root.resolve() if resolved.is_file() or resolved.suffix.lower() == ".zip": return resolved.parent return resolved def _final_output( profile: dict[str, Any], modules: dict[str, Any], capabilities: dict[str, Any], pipeline: dict[str, Any], ) -> dict[str, Any]: evidence = modules["historical_comparator"]["result"] comparison = evidence["comparison"] summary = evidence["summary"] completeness = modules["protocol_completeness"]["result"] outlook = capabilities.get("publication_outlook", {}) narrative = capabilities.get("narrative_summary", {}) title = profile.get("brief_title") or "Untitled trial" module_statuses = { name: { "status": module.get("status"), "language": module.get("language"), "selection": module.get("selection"), "capability": module.get("capability"), "provenance": module.get("provenance"), "reason": module.get("reason"), "description": module.get("description"), } for name, module in modules.items() } review_flags = comparison["flags"] display_flags = review_flags or ["No major review flags from this simple comparator pass."] return { "title": title, "review_priority": comparison["review_priority"], "takeaway": narrative.get("takeaway", ""), "summary": narrative.get("summary", []), "summary_source": narrative.get("source", "template"), "summary_warning": narrative.get("warning"), "headline": _headline(profile, evidence, outlook, completeness, comparison), "actions": narrative.get("actions") or _actions(review_flags, completeness), "key_numbers": { "planned_enrollment": profile.get("enrollment"), "comparator_median_enrollment": summary["median_enrollment"], "planned_facilities": profile.get("number_of_facilities"), "comparator_median_facilities": summary["median_facilities"], "planned_arms": profile.get("number_of_arms"), "comparator_median_arms": summary["median_arms"], "planned_primary_outcomes": profile.get("number_of_primary_outcomes"), "comparator_median_primary_outcomes": summary["median_primary_outcomes"], "planned_secondary_outcomes": profile.get("number_of_secondary_outcomes"), "comparator_median_secondary_outcomes": summary["median_secondary_outcomes"], "comparator_publication_rate": summary["publication_rate"], "comparator_results_reported_rate": summary["results_reported_rate"], "comparator_median_time_to_publication_days": summary["median_time_to_publication_days"], }, "review_flags": display_flags, "module_statuses": module_statuses, "pipeline": pipeline, "plain_language": narrative.get("summary", []), "model_status": outlook.get("provenance_detail", comparison["note"]), } def _headline( profile: dict[str, Any], evidence: dict[str, Any], outlook: dict[str, Any], completeness: dict[str, Any], comparison: dict[str, Any], ) -> list[dict[str, Any]]: """The skimmable top-of-report numbers, most decision-relevant first.""" pub = outlook.get("publication_likelihood") results = outlook.get("results_reporting_likelihood") priority = comparison["review_priority"] priority_tone = {"high": "alert", "focused": "watch", "standard": "good"}.get(priority, "neutral") return [ { "label": "Publication likelihood", "value": _percent(pub), "raw": pub, "tone": _rate_tone(pub), "provenance": outlook.get("provenance_label", "Historical comparator"), "sub": f"among {outlook.get('basis_rows', evidence.get('used_rows'))} comparable trials", }, { "label": "Results reporting likelihood", "value": _percent(results), "raw": results, "tone": _rate_tone(results), "provenance": outlook.get("provenance_label", "Historical comparator"), "sub": "of comparable trials posted results", }, { "label": "Review priority", "value": priority.title(), "raw": priority, "tone": priority_tone, "provenance": "Design flags", "sub": f"{len(comparison['flags'])} flag(s) raised", }, { "label": "Protocol completeness", "value": _percent(completeness["completion_ratio"]), "raw": completeness["completion_ratio"], "tone": _rate_tone(completeness["completion_ratio"], good=0.9, watch=0.7), "provenance": "Protocol entry", "sub": f"{completeness['filled_fields']}/{completeness['total_fields']} fields", }, ] def _actions(review_flags: list[str], completeness: dict[str, Any]) -> list[dict[str, Any]]: """Actionable improvement items, design review first then completeness gaps.""" actions: list[dict[str, Any]] = [ {"severity": "review", "text": flag} for flag in review_flags ] for section in completeness["weakest_sections"]: if section["missing"]: fields = ", ".join(field.replace("_", " ") for field in section["missing"]) actions.append( { "severity": "completeness", "text": f"Complete the {section['section'].replace('_', ' ')} section — add: {fields}.", } ) if not actions: actions.append({"severity": "ok", "text": "No design or completeness issues flagged in this pass."}) return actions def _rate_tone(value: float | None, good: float = 0.6, watch: float = 0.35) -> str: if value is None: return "neutral" if value >= good: return "good" if value >= watch: return "watch" return "alert" def _percent(value: float | None) -> str: if value is None: return "NA" return f"{round(value * 100)}%"