Spaces:
Sleeping
Sleeping
File size: 9,193 Bytes
33d7314 00746d1 33d7314 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | """Narrative interpretation of module outputs.
Takes the raw, numeric outputs produced by the evidence modules (today: Layla's
historical-comparator / PubTime module, protocol completeness, publication
outlook β tomorrow: more) and turns them into a short, plain-language read for a
trial PI. An LLM does the interpreting so the prose tracks whatever the modules
actually returned instead of a fixed template; a deterministic writer is the
fallback when no model is configured or a call fails.
The LLM is given *only* the numbers the modules produced and is told to ground
every claim in them β it interprets, it does not invent.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from llm import LlmError, build_client
SYSTEM_PROMPT = (
"You are a clinical-trial methodologist helping an investigator read a "
"pre-submission design check. You are given a JSON brief of numbers produced "
"by analysis modules β the main one compares this planned trial against "
"matched historical trials in the same disease area.\n\n"
"Write a short interpretation for the investigator. Rules:\n"
"- Ground every statement in the numbers in the brief. Never invent a number, "
"rate, or fact that is not present.\n"
"- The rates come from comparable past trials, not a validated predictive "
"model. Convey this once, in natural language (e.g. 'trials like yours'), and "
"do not repeat a disclaimer.\n"
"- Lead with what matters: where the plan diverges from comparators and what "
"that implies for publication and results reporting.\n"
"- Be concrete and concise. Short declarative sentences, active voice. No "
"filler, no 'it is important to note', no 'please consult', no restating the "
"inputs back, no hedging padding.\n\n"
"You also rewrite the flagged issues into clear, actionable recommendations. "
"Use only the items in 'design_flags' and 'protocol_completeness.weakest_sections' "
"β do not invent new issues. Each action is one short imperative sentence telling "
"the investigator what to check or change and, briefly, why. Merge duplicates. "
"Tag each action 'review' for a design/methodology issue or 'completeness' for a "
"missing registry field.\n\n"
"Return a JSON object with exactly three keys:\n"
' "takeaway": one sentence the investigator reads first (<= 30 words).\n'
' "summary": an array of 2 to 4 sentences expanding on the takeaway.\n'
' "actions": an array of objects, each {"severity": "review"|"completeness", '
'"text": "..."}, ordered most important first. Empty array if nothing is flagged.\n'
)
def build_evidence_brief(profile: dict[str, Any], capabilities: dict[str, Any]) -> dict[str, Any]:
comparator = capabilities.get("historical_comparator") or {}
summary = comparator.get("summary", {})
comparison = comparator.get("comparison", {})
outlook = capabilities.get("publication_outlook") or {}
completeness = capabilities.get("protocol_completeness") or {}
return {
"planned_trial": {
"domain": profile.get("domain_label"),
"phase": profile.get("phase"),
"primary_purpose": profile.get("primary_purpose"),
"allocation": profile.get("allocation"),
"masking": profile.get("masking"),
"enrollment": profile.get("enrollment"),
"arms": profile.get("number_of_arms"),
"facilities": profile.get("number_of_facilities"),
"primary_outcomes": profile.get("number_of_primary_outcomes"),
"secondary_outcomes": profile.get("number_of_secondary_outcomes"),
"has_dmc": profile.get("has_dmc"),
},
"comparator_cohort": {
"matched_trials": comparator.get("used_rows"),
"match_strategy": comparator.get("match_strategy"),
"publication_rate": _as_percent(summary.get("publication_rate")),
"results_reported_rate": _as_percent(summary.get("results_reported_rate")),
"median_time_to_publication_days": summary.get("median_time_to_publication_days"),
"median_enrollment": summary.get("median_enrollment"),
"median_facilities": summary.get("median_facilities"),
"median_arms": summary.get("median_arms"),
"median_duration_months": summary.get("median_duration_months"),
"median_primary_outcomes": summary.get("median_primary_outcomes"),
"median_secondary_outcomes": summary.get("median_secondary_outcomes"),
},
"publication_outlook": {
"publication_likelihood": _as_percent(outlook.get("publication_likelihood")),
"results_reporting_likelihood": _as_percent(outlook.get("results_reporting_likelihood")),
"source": outlook.get("provenance_label"),
"model_type": outlook.get("model_type"),
},
"protocol_completeness": {
"completion_percent": _as_percent(completeness.get("completion_ratio")),
"weakest_sections": [
{"section": section.get("section"), "missing": section.get("missing")}
for section in completeness.get("weakest_sections", [])
],
},
"design_flags": comparison.get("flags", []),
"review_priority": comparison.get("review_priority"),
}
def summarize_with_llm(profile: dict[str, Any], capabilities: dict[str, Any], project_root: Path) -> dict[str, Any]:
"""Interpret module outputs with the LLM; fall back to the template on any failure."""
client = build_client(Path(project_root) / ".env")
if client is None:
result = template_summary(profile, capabilities)
result["warning"] = "LLM not configured (no OpenAI API key); used deterministic summary."
return result
brief = build_evidence_brief(profile, capabilities)
user_prompt = "Evidence brief:\n" + json.dumps(brief, indent=2, default=str)
try:
raw = client.complete_json(system_prompt=SYSTEM_PROMPT, user_prompt=user_prompt)
return _coerce_llm_output(raw)
except (LlmError, ValueError, KeyError) as exc:
result = template_summary(profile, capabilities)
result["warning"] = f"LLM interpretation failed ({exc}); used deterministic summary."
return result
def _coerce_llm_output(raw: dict[str, Any]) -> dict[str, Any]:
takeaway = str(raw.get("takeaway", "")).strip()
summary_field = raw.get("summary", [])
if isinstance(summary_field, str):
summary = [summary_field.strip()]
else:
summary = [str(item).strip() for item in summary_field if str(item).strip()]
if not takeaway and summary:
takeaway = summary[0]
if not takeaway:
raise LlmError("Model returned an empty summary.")
return {
"takeaway": takeaway,
"summary": summary or [takeaway],
"actions": _coerce_actions(raw.get("actions")),
"source": "llm",
}
def _coerce_actions(raw: Any) -> list[dict[str, str]]:
if not isinstance(raw, list):
return []
actions: list[dict[str, str]] = []
for item in raw:
if isinstance(item, dict):
text = str(item.get("text", "")).strip()
severity = str(item.get("severity", "review")).strip().lower()
else:
text, severity = str(item).strip(), "review"
if not text:
continue
if severity not in {"review", "completeness"}:
severity = "review"
actions.append({"severity": severity, "text": text})
return actions
def template_summary(profile: dict[str, Any], capabilities: dict[str, Any]) -> dict[str, Any]:
"""Deterministic, concise fallback β grounded, no boilerplate disclaimers."""
comparator = capabilities.get("historical_comparator") or {}
summary = comparator.get("summary", {})
outlook = capabilities.get("publication_outlook") or {}
comparison = comparator.get("comparison", {})
flags = comparison.get("flags", [])
pub = _pct(outlook.get("publication_likelihood"))
results = _pct(outlook.get("results_reporting_likelihood"))
matched = comparator.get("used_rows")
domain = profile.get("domain_label")
phase = profile.get("phase")
takeaway = (
f"Among {matched} comparable {domain} trials, {pub} reached publication and "
f"{results} posted results; "
+ (f"{len(flags)} design issue(s) warrant review." if flags else "no design issues were flagged.")
)
lines = [
f"This {domain} {phase} plan was matched to {matched} historical trials with similar design.",
f"In that group, publication ran at {pub} and results reporting at {results}.",
]
if flags:
lines.append("Key divergence: " + flags[0])
return {"takeaway": takeaway, "summary": lines, "source": "template"}
def _pct(value: float | None) -> str:
if value is None:
return "an unknown share of"
return f"{round(value * 100)}%"
def _as_percent(value: float | None) -> str | None:
if value is None:
return None
return f"{round(value * 100)}%"
|