"""
Prompt Rubric Dashboard
Requires: pip install plotly pandas
Run: python rubric_dashboard.py
Output: rubric_dashboard.html — fully self-contained, works offline
"""
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
# ── Data ─────────────────────────────────────────────────────────────────────
DIMENSIONS = [
("instruction_clarity", "Instruction Clarity"),
("constraint_completeness", "Constraint Completeness"),
("output_specificity", "Output Specificity"),
("pii_handling", "PII Token Handling"),
("clinical_safety", "Clinical Safety"),
("duplication_risk", "Duplication Risk*"),
("structural_guidance", "Structural Guidance"),
("prompt_efficiency", "Prompt Efficiency"),
]
DIM_IDS = [d[0] for d in DIMENSIONS]
DIM_LABELS = [d[1] for d in DIMENSIONS]
PROMPTS = [
dict(name="reason_for_referral", label="Reason for Referral", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Strong example phrase list aids extraction. Missing explicit word count in prompt body. Low duplication risk as referral context is distinct."),
dict(name="history_of_presenting_complaint", label="History of Presenting Complaint", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Two-task structure discards Task 1 output — adds tokens without value. High duplication risk with Medical/Psychiatric History. Typo: 'transcriptd'."),
dict(name="psychiatric_history", label="Psychiatric History", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Very thorough extraction criteria. Two-task structure discards Task 1. Overlap risk with Medical History and Drug & Alcohol History addressed downstream."),
dict(name="medical_history", label="Medical History", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=4,
notes="Clear scope. Boundary with Psychiatric History is a known cross-contamination risk addressed downstream by the deduplication prompt."),
dict(name="family_medical_psychiatric_history", label="Family Medical & Psychiatric History", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=3, structural_guidance=5, prompt_efficiency=4,
notes="SCAN reference is a strong clinical anchor. Clear exclusion of patient's own history. Duplication risk mainly from family conditions bleeding into patient Medical History."),
dict(name="drug_alcohol_forensic", label="Drug, Alcohol, Forensic, Capacity & Safeguarding", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=3, pii_handling=5,
clinical_safety=5, duplication_risk=3, structural_guidance=4, prompt_efficiency=3,
notes="Four high-stakes sections in one prompt — failure in one affects all. Word count guidance is aggregate only. Typo: 'types od substances'."),
dict(name="adhd_diagnostic_formulation", label="ADHD Diagnostic Formulation", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Strongest prompt in the set. Mandatory opening statement is excellent. Binary outcome structure is well-defined. DSM-5 severity anchoring is precise."),
dict(name="adhd_follow_up", label="ADHD Follow-Up Review", group="ADHD Follow-Up",
instruction_clarity=4, constraint_completeness=4, output_specificity=5, pii_handling=5,
clinical_safety=3, duplication_risk=2, structural_guidance=5, prompt_efficiency=3,
notes="Highly prescriptive paragraph-by-paragraph structure. Clinical safety weaker — no explicit safeguarding instruction. Female-only conditional is a code-level dependency."),
dict(name="adhd_ia_deduplication", label="ADHD Deduplication Analysis", group="QA",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=3,
clinical_safety=4, duplication_risk=1, structural_guidance=5, prompt_efficiency=4,
notes="Excellent JSON schema with confidence scores. Cross-heading risk pairs are well-specified. PII less critical as it operates on already-generated output."),
dict(name="asd_deduplication", label="ASD Deduplication Analysis", group="QA",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=3,
clinical_safety=4, duplication_risk=1, structural_guidance=5, prompt_efficiency=4,
notes="Near-identical to ADHD deduplication with ASD-specific heading pairs. Both could be unified into a single parameterised prompt."),
dict(name="asd_dev_history_social_interaction", label="ASD Developmental History & Social Interaction", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Rich reporting verb list is a nice touch. Combined heading reduces API calls but raises duplication risk. Typo: 'thourough'."),
dict(name="asd_social_communication", label="ASD Social Communication", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Overlap with Developmental History is a significant risk. Expand-most-detailed-example instruction could conflict with word count targets."),
dict(name="asd_routines", label="ASD Routines / Repetitive Interests / Sensory", group="ASD Initial",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=3, structural_guidance=5, prompt_efficiency=4,
notes="Good coverage of all sensory domains. Target word count (~500) could be more explicitly embedded in the prompt body."),
dict(name="asd_diagnostic_formulation", label="ASD Diagnostic Formulation", group="ASD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Uses ADHD prefix (not ASD prefix) — potential bug. Otherwise mirrors the quality of the ADHD formulation prompt. DSM-5 Level 1/2/3 anchoring is well-specified."),
dict(name="asd_dev_social_combined", label="ASD Dev History + Social Comm (Combined)", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=5, structural_guidance=4, prompt_efficiency=2,
notes="Highest duplication risk in the set. Combines two sections known to overlap. 1000-2000 word output makes accuracy hard to verify across both sections simultaneously."),
dict(name="general_adult_dictation", label="General Adult Dictation", group="General Adult",
instruction_clarity=4, constraint_completeness=4, output_specificity=3, pii_handling=4,
clinical_safety=4, duplication_risk=2, structural_guidance=5, prompt_efficiency=3,
notes="Most flexible prompt — 21 headings with blanks allowed. Output specificity necessarily lower given open-ended dictation context. Manual Instructions heading is a thoughtful edge case handler."),
dict(name="general_adult_formulation", label="General Adult Diagnostic Formulation & Plan", group="General Adult",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=4, prompt_efficiency=4,
notes="Critical safety instruction (no diagnosis not explicitly stated) is well emphasised. Typo in guideline 4: 'DO NOT about the word transcript'."),
dict(name="adhd_prompt_prefix", label="ADHD Prompt Prefix (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=3, duplication_risk=1, structural_guidance=3, prompt_efficiency=5,
notes="Efficient and well-structured. Clinical safety intentionally minimal — constraints live in child prompts. Could add a safeguarding reminder as a safety net."),
dict(name="asd_prompt_prefix", label="ASD Prompt Prefix (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=3, duplication_risk=1, structural_guidance=3, prompt_efficiency=5,
notes="Well-adapted from ADHD prefix for multi-source input. Uses American 'anonymized' instead of British 'anonymised' — inconsistency with the style guide."),
dict(name="remove_leading_tabs", label="Remove Leading Tabs (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=1, structural_guidance=5, prompt_efficiency=5,
notes="Utility function — not an LLM prompt. Scores reflect it performs a single, well-defined mechanical task correctly."),
]
GROUP_COLORS = {
"ADHD Initial": "#3b82f6",
"ADHD Follow-Up": "#8b5cf6",
"ASD Initial": "#10b981",
"QA": "#f59e0b",
"General Adult": "#ef4444",
"Helper": "#94a3b8",
}
DARK_BG = "#0f1117"
CARD_BG = "#111827"
BORDER = "#1e2a3a"
TXT_MAIN = "#e2e8f0"
TXT_SUB = "#94a3b8"
TXT_DIM = "#475569"
COLORSCALE = [
[0.00, "#ef4444"],
[0.25, "#f97316"],
[0.50, "#fbbf24"],
[0.75, "#34d399"],
[1.00, "#10b981"],
]
SCORE_PAL = ["#ef4444", "#f97316", "#fbbf24", "#34d399", "#10b981"]
def score_color(val):
return SCORE_PAL[max(0, min(4, round(val) - 1))]
def hex_to_rgb(h):
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def adjusted_avg(row):
total = sum((6 - row[d]) if d == "duplication_risk" else row[d] for d in DIM_IDS)
return round(total / len(DIM_IDS), 2)
df = pd.DataFrame(PROMPTS)
df["adj_avg"] = df.apply(adjusted_avg, axis=1)
theta_closed = DIM_LABELS + [DIM_LABELS[0]]
# ── Figure 1: Heatmap ─────────────────────────────────────────────────────────
z = df[DIM_IDS].values.tolist()
labels = df["label"].tolist()
fig_heatmap = go.Figure(go.Heatmap(
z=z, x=DIM_LABELS, y=labels,
text=[[str(v) for v in row] for row in z],
texttemplate="%{text}",
textfont=dict(size=11, color="white", family="monospace"),
colorscale=COLORSCALE, zmin=1, zmax=5,
colorbar=dict(
title=dict(text="Score", font=dict(color=TXT_SUB)),
tickfont=dict(color=TXT_SUB, family="monospace"),
bgcolor=CARD_BG, bordercolor=BORDER, thickness=14,
),
hovertemplate="<b>%{y}</b><br>%{x}: %{z}/5<extra></extra>",
))
fig_heatmap.update_layout(
title=dict(text="Score Heatmap — All Prompts × All Dimensions",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=10),
xaxis=dict(tickangle=-35, showgrid=False, tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=10, color=TXT_MAIN), autorange="reversed"),
margin=dict(l=290, r=80, t=60, b=140), height=680,
)
# ── Figure 2: Adjusted average bar chart ─────────────────────────────────────
df_s = df.sort_values("adj_avg")
fig_bar = go.Figure(go.Bar(
x=df_s["adj_avg"], y=df_s["label"], orientation="h",
marker=dict(color=[GROUP_COLORS.get(g, "#6b7280") for g in df_s["group"]], line=dict(width=0)),
text=df_s["adj_avg"].astype(str), textposition="outside",
textfont=dict(color=TXT_SUB, size=10, family="monospace"),
hovertemplate="<b>%{y}</b><br>Adj. avg: %{x}<extra></extra>",
))
for grp, col in GROUP_COLORS.items():
fig_bar.add_trace(go.Scatter(x=[None], y=[None], mode="markers",
marker=dict(size=10, color=col), name=grp))
fig_bar.update_layout(
title=dict(text="Adjusted Average Score (duplication risk inverted)",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=10),
xaxis=dict(range=[0, 5.8], showgrid=True, gridcolor=BORDER,
tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=10, color=TXT_MAIN)),
legend=dict(bgcolor=CARD_BG, bordercolor=BORDER, borderwidth=1,
font=dict(color=TXT_SUB, size=10)),
margin=dict(l=290, r=60, t=60, b=40), height=620,
)
# ── Figure 3: Dimension averages ──────────────────────────────────────────────
dim_avgs = []
for dim, lbl in DIMENSIONS:
avg_val = round((6 - df[dim]).mean() if dim == "duplication_risk" else df[dim].mean(), 2)
dim_avgs.append((lbl + (" (inv.)" if dim == "duplication_risk" else ""), avg_val))
dim_avgs.sort(key=lambda x: x[1])
fig_dims = go.Figure(go.Bar(
x=[v for _, v in dim_avgs], y=[n for n, _ in dim_avgs], orientation="h",
marker=dict(color=[score_color(v) for _, v in dim_avgs], line=dict(width=0)),
text=[str(v) for _, v in dim_avgs], textposition="outside",
textfont=dict(color=TXT_SUB, size=11, family="monospace"),
hovertemplate="<b>%{y}</b><br>Average: %{x}<extra></extra>",
))
fig_dims.update_layout(
title=dict(text="Average Score per Dimension — All 20 Prompts",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=11),
xaxis=dict(range=[0, 5.6], showgrid=True, gridcolor=BORDER,
tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=11, color=TXT_MAIN)),
margin=dict(l=230, r=60, t=60, b=40), height=400,
)
# ── Figure 4: Group radar overlay ─────────────────────────────────────────────
fig_radar = go.Figure()
for grp, col in GROUP_COLORS.items():
gdf = df[df["group"] == grp]
if gdf.empty:
continue
scores = [round(gdf[d].mean(), 2) for d in DIM_IDS]
r_c = scores + [scores[0]]
r, g, b = hex_to_rgb(col)
fig_radar.add_trace(go.Scatterpolar(
r=r_c, theta=theta_closed, fill="toself",
fillcolor=f"rgba({r},{g},{b},0.12)", line=dict(color=col, width=2),
name=grp,
hovertemplate="%{theta}: %{r:.2f}<extra>" + grp + "</extra>",
))
fig_radar.update_layout(
title=dict(text="Group Average Radar — All Groups Overlaid",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
polar=dict(
bgcolor="#0d1117",
radialaxis=dict(visible=True, range=[0, 5], showticklabels=False,
gridcolor=BORDER, linecolor=BORDER),
angularaxis=dict(tickfont=dict(color=TXT_SUB, size=10, family="monospace"),
gridcolor=BORDER, linecolor=BORDER),
),
paper_bgcolor=CARD_BG,
legend=dict(bgcolor=CARD_BG, bordercolor=BORDER, borderwidth=1,
font=dict(color=TXT_SUB, size=11)),
margin=dict(l=60, r=60, t=60, b=60), height=500,
)
# ── Figure 5: Per-prompt radar grid ──────────────────────────────────────────
COLS = 4
N = len(PROMPTS)
ROWS = -(-N // COLS)
specs = [[{"type": "polar"}] * COLS for _ in range(ROWS)]
titles = [p["label"] for p in PROMPTS] + [""] * (ROWS * COLS - N)
fig_grid = make_subplots(rows=ROWS, cols=COLS, specs=specs,
subplot_titles=titles,
vertical_spacing=0.08, horizontal_spacing=0.04)
for i, pd_ in enumerate(PROMPTS):
ri = i // COLS + 1
ci = i % COLS + 1
scores = [pd_[d] for d in DIM_IDS]
r_c = scores + [scores[0]]
col = GROUP_COLORS.get(pd_["group"], "#6b7280")
r, g, b = hex_to_rgb(col)
fig_grid.add_trace(go.Scatterpolar(
r=r_c, theta=theta_closed, fill="toself",
fillcolor=f"rgba({r},{g},{b},0.18)", line=dict(color=col, width=1.5),
name=pd_["label"], showlegend=False,
hovertemplate="%{theta}: %{r}<extra>" + pd_["label"] + "</extra>",
), row=ri, col=ci)
for i in range(1, ROWS * COLS + 1):
key = f"polar{i}" if i > 1 else "polar"
fig_grid.update_layout(**{key: dict(
bgcolor="#0d1117",
radialaxis=dict(visible=True, range=[0, 5], showticklabels=False,
gridcolor=BORDER, linecolor=BORDER),
angularaxis=dict(tickfont=dict(color=TXT_DIM, size=7),
gridcolor=BORDER, linecolor=BORDER),
)})
for ann in fig_grid.layout.annotations:
ann.font = dict(color=TXT_SUB, size=9, family="monospace")
fig_grid.update_layout(
title=dict(text="Individual Prompt Radar Grid",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
paper_bgcolor=CARD_BG, height=ROWS * 200 + 80,
margin=dict(l=20, r=20, t=60, b=20),
)
# ── Reviewer notes table ──────────────────────────────────────────────────────
notes_rows_html = ""
for _, row in df.sort_values("adj_avg").iterrows():
col = GROUP_COLORS.get(row["group"], "#6b7280")
avg = row["adj_avg"]
acol = score_color(avg)
notes_rows_html += f"""
<tr>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};">
<span style="font-size:10px;color:{col};font-family:monospace;
text-transform:uppercase;display:block;margin-bottom:3px;">{row['group']}</span>
<span style="font-size:12px;color:{TXT_MAIN};">{row['label']}</span>
</td>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};font-size:18px;
font-weight:bold;font-family:monospace;color:{acol};text-align:center;">{avg}</td>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};font-size:12px;
color:{TXT_SUB};line-height:1.6;font-family:'Helvetica Neue',sans-serif;">{row['notes']}</td>
</tr>"""
# ── Build full HTML with Plotly embedded inline (no CDN needed) ───────────────
# Render each figure as a div (no plotly JS included yet)
def fig_div(fig):
return fig.to_html(full_html=False, include_plotlyjs=False)
# Get the plotly.min.js source from the local package
import os, plotly
plotly_js_path = os.path.join(os.path.dirname(plotly.__file__),
"package_data", "plotly.min.js")
with open(plotly_js_path, "r", encoding="utf-8") as f:
plotly_js = f.read()
HTML = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompt Rubric Dashboard</title>
<script>{plotly_js}</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
body {{background:{DARK_BG};color:{TXT_MAIN};font-family:Georgia,serif;}}
header {{background:#1a1f2e;border-bottom:1px solid {BORDER};padding:28px 40px;}}
header p.eyebrow {{color:{TXT_DIM};font-size:11px;letter-spacing:.2em;
font-family:monospace;text-transform:uppercase;margin-bottom:6px;}}
header h1 {{font-size:28px;font-weight:normal;color:{TXT_MAIN};margin-bottom:6px;}}
header p.sub {{color:{TXT_DIM};font-size:12px;font-family:'Helvetica Neue',sans-serif;}}
main {{max-width:1300px;margin:0 auto;padding:32px 40px;}}
.card {{background:{CARD_BG};border:1px solid {BORDER};border-radius:10px;
padding:20px;margin-bottom:24px;}}
h2.st {{font-size:13px;font-weight:normal;color:{TXT_SUB};font-family:monospace;
letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px;}}
.two-col {{display:grid;grid-template-columns:1fr 1fr;gap:24px;}}
table {{width:100%;border-collapse:collapse;}}
th {{text-align:left;padding:8px 12px;color:{TXT_DIM};font-size:11px;
font-family:monospace;letter-spacing:.05em;border-bottom:2px solid {BORDER};}}
@media(max-width:800px){{.two-col{{grid-template-columns:1fr;}}main{{padding:16px;}}}}
</style>
</head>
<body>
<header>
<p class="eyebrow">Prompt Quality Assessment</p>
<h1>Rubric Dashboard</h1>
<p class="sub">20 prompts · 8 dimensions ·
* Duplication Risk is inverted — lower score = lower risk = better</p>
</header>
<main>
<div class="card"><h2 class="st">Score Heatmap</h2>{fig_div(fig_heatmap)}</div>
<div class="two-col">
<div class="card"><h2 class="st">Adjusted Average by Prompt</h2>{fig_div(fig_bar)}</div>
<div class="card"><h2 class="st">Dimension Averages — All Prompts</h2>{fig_div(fig_dims)}</div>
</div>
<div class="two-col">
<div class="card"><h2 class="st">Group Radar Overlay</h2>{fig_div(fig_radar)}</div>
<div class="card"><h2 class="st">Individual Prompt Radar Grid</h2>{fig_div(fig_grid)}</div>
</div>
<div class="card">
<h2 class="st">Reviewer Notes</h2>
<table>
<thead><tr>
<th style="width:240px;">Prompt</th>
<th style="width:80px;text-align:center;">Adj. Avg</th>
<th>Notes</th>
</tr></thead>
<tbody>{notes_rows_html}</tbody>
</table>
</div>
</main>
</body>
</html>"""
output_path = "rubric_dashboard.html"
with open(output_path, "w", encoding="utf-8") as f:
f.write(HTML)
file_size = os.path.getsize(output_path) // 1024
print(f"Saved: {output_path} ({file_size} KB)")
# ── Serve locally and open in Android browser ─────────────────────────────────
import subprocess
import threading
import http.server
import socketserver
import webbrowser
import signal
PORT = 8080
directory = os.path.dirname(os.path.abspath(output_path))
filename = os.path.basename(output_path)
url = f"http://127.0.0.1:{PORT}/{filename}"
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=directory, **kwargs)
def log_message(self, format, *args):
pass # suppress request logs
socketserver.TCPServer.allow_reuse_address = True
httpd = socketserver.TCPServer(("127.0.0.1", PORT), Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
print(f"\nServer running at {url}")
print("Opening browser — if it does not launch automatically,")
print(f"open your browser and go to: {url}")
print("Press Ctrl+C to stop the server when done.\n")
# Launch browser via Android activity manager
try:
result = subprocess.run(
["am", "start", "--user", "0",
"-a", "android.intent.action.VIEW",
"-d", url],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
print("Browser launched.")
except Exception:
webbrowser.open(url)
# Keep server alive until Ctrl+C
try:
signal.pause() # efficient wait on Linux/Android
except (AttributeError, OSError):
try:
input("Press Enter to stop the server...")
except EOFError:
import time
while True:
time.sleep(1) # fallback: keep alive indefinitely
finally:
httpd.shutdown()
Prompt Rubric Dashboard
Requires: pip install plotly pandas
Run: python rubric_dashboard.py
Output: rubric_dashboard.html — fully self-contained, works offline
"""
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
# ── Data ─────────────────────────────────────────────────────────────────────
DIMENSIONS = [
("instruction_clarity", "Instruction Clarity"),
("constraint_completeness", "Constraint Completeness"),
("output_specificity", "Output Specificity"),
("pii_handling", "PII Token Handling"),
("clinical_safety", "Clinical Safety"),
("duplication_risk", "Duplication Risk*"),
("structural_guidance", "Structural Guidance"),
("prompt_efficiency", "Prompt Efficiency"),
]
DIM_IDS = [d[0] for d in DIMENSIONS]
DIM_LABELS = [d[1] for d in DIMENSIONS]
PROMPTS = [
dict(name="reason_for_referral", label="Reason for Referral", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Strong example phrase list aids extraction. Missing explicit word count in prompt body. Low duplication risk as referral context is distinct."),
dict(name="history_of_presenting_complaint", label="History of Presenting Complaint", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Two-task structure discards Task 1 output — adds tokens without value. High duplication risk with Medical/Psychiatric History. Typo: 'transcriptd'."),
dict(name="psychiatric_history", label="Psychiatric History", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Very thorough extraction criteria. Two-task structure discards Task 1. Overlap risk with Medical History and Drug & Alcohol History addressed downstream."),
dict(name="medical_history", label="Medical History", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=4,
notes="Clear scope. Boundary with Psychiatric History is a known cross-contamination risk addressed downstream by the deduplication prompt."),
dict(name="family_medical_psychiatric_history", label="Family Medical & Psychiatric History", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=3, structural_guidance=5, prompt_efficiency=4,
notes="SCAN reference is a strong clinical anchor. Clear exclusion of patient's own history. Duplication risk mainly from family conditions bleeding into patient Medical History."),
dict(name="drug_alcohol_forensic", label="Drug, Alcohol, Forensic, Capacity & Safeguarding", group="ADHD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=3, pii_handling=5,
clinical_safety=5, duplication_risk=3, structural_guidance=4, prompt_efficiency=3,
notes="Four high-stakes sections in one prompt — failure in one affects all. Word count guidance is aggregate only. Typo: 'types od substances'."),
dict(name="adhd_diagnostic_formulation", label="ADHD Diagnostic Formulation", group="ADHD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Strongest prompt in the set. Mandatory opening statement is excellent. Binary outcome structure is well-defined. DSM-5 severity anchoring is precise."),
dict(name="adhd_follow_up", label="ADHD Follow-Up Review", group="ADHD Follow-Up",
instruction_clarity=4, constraint_completeness=4, output_specificity=5, pii_handling=5,
clinical_safety=3, duplication_risk=2, structural_guidance=5, prompt_efficiency=3,
notes="Highly prescriptive paragraph-by-paragraph structure. Clinical safety weaker — no explicit safeguarding instruction. Female-only conditional is a code-level dependency."),
dict(name="adhd_ia_deduplication", label="ADHD Deduplication Analysis", group="QA",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=3,
clinical_safety=4, duplication_risk=1, structural_guidance=5, prompt_efficiency=4,
notes="Excellent JSON schema with confidence scores. Cross-heading risk pairs are well-specified. PII less critical as it operates on already-generated output."),
dict(name="asd_deduplication", label="ASD Deduplication Analysis", group="QA",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=3,
clinical_safety=4, duplication_risk=1, structural_guidance=5, prompt_efficiency=4,
notes="Near-identical to ADHD deduplication with ASD-specific heading pairs. Both could be unified into a single parameterised prompt."),
dict(name="asd_dev_history_social_interaction", label="ASD Developmental History & Social Interaction", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Rich reporting verb list is a nice touch. Combined heading reduces API calls but raises duplication risk. Typo: 'thourough'."),
dict(name="asd_social_communication", label="ASD Social Communication", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=4, structural_guidance=4, prompt_efficiency=3,
notes="Overlap with Developmental History is a significant risk. Expand-most-detailed-example instruction could conflict with word count targets."),
dict(name="asd_routines", label="ASD Routines / Repetitive Interests / Sensory", group="ASD Initial",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=3, structural_guidance=5, prompt_efficiency=4,
notes="Good coverage of all sensory domains. Target word count (~500) could be more explicitly embedded in the prompt body."),
dict(name="asd_diagnostic_formulation", label="ASD Diagnostic Formulation", group="ASD Initial",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=5, prompt_efficiency=4,
notes="Uses ADHD prefix (not ASD prefix) — potential bug. Otherwise mirrors the quality of the ADHD formulation prompt. DSM-5 Level 1/2/3 anchoring is well-specified."),
dict(name="asd_dev_social_combined", label="ASD Dev History + Social Comm (Combined)", group="ASD Initial",
instruction_clarity=4, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=4, duplication_risk=5, structural_guidance=4, prompt_efficiency=2,
notes="Highest duplication risk in the set. Combines two sections known to overlap. 1000-2000 word output makes accuracy hard to verify across both sections simultaneously."),
dict(name="general_adult_dictation", label="General Adult Dictation", group="General Adult",
instruction_clarity=4, constraint_completeness=4, output_specificity=3, pii_handling=4,
clinical_safety=4, duplication_risk=2, structural_guidance=5, prompt_efficiency=3,
notes="Most flexible prompt — 21 headings with blanks allowed. Output specificity necessarily lower given open-ended dictation context. Manual Instructions heading is a thoughtful edge case handler."),
dict(name="general_adult_formulation", label="General Adult Diagnostic Formulation & Plan", group="General Adult",
instruction_clarity=4, constraint_completeness=5, output_specificity=4, pii_handling=5,
clinical_safety=5, duplication_risk=2, structural_guidance=4, prompt_efficiency=4,
notes="Critical safety instruction (no diagnosis not explicitly stated) is well emphasised. Typo in guideline 4: 'DO NOT about the word transcript'."),
dict(name="adhd_prompt_prefix", label="ADHD Prompt Prefix (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=3, duplication_risk=1, structural_guidance=3, prompt_efficiency=5,
notes="Efficient and well-structured. Clinical safety intentionally minimal — constraints live in child prompts. Could add a safeguarding reminder as a safety net."),
dict(name="asd_prompt_prefix", label="ASD Prompt Prefix (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=4, output_specificity=4, pii_handling=5,
clinical_safety=3, duplication_risk=1, structural_guidance=3, prompt_efficiency=5,
notes="Well-adapted from ADHD prefix for multi-source input. Uses American 'anonymized' instead of British 'anonymised' — inconsistency with the style guide."),
dict(name="remove_leading_tabs", label="Remove Leading Tabs (Helper)", group="Helper",
instruction_clarity=5, constraint_completeness=5, output_specificity=5, pii_handling=5,
clinical_safety=5, duplication_risk=1, structural_guidance=5, prompt_efficiency=5,
notes="Utility function — not an LLM prompt. Scores reflect it performs a single, well-defined mechanical task correctly."),
]
GROUP_COLORS = {
"ADHD Initial": "#3b82f6",
"ADHD Follow-Up": "#8b5cf6",
"ASD Initial": "#10b981",
"QA": "#f59e0b",
"General Adult": "#ef4444",
"Helper": "#94a3b8",
}
DARK_BG = "#0f1117"
CARD_BG = "#111827"
BORDER = "#1e2a3a"
TXT_MAIN = "#e2e8f0"
TXT_SUB = "#94a3b8"
TXT_DIM = "#475569"
COLORSCALE = [
[0.00, "#ef4444"],
[0.25, "#f97316"],
[0.50, "#fbbf24"],
[0.75, "#34d399"],
[1.00, "#10b981"],
]
SCORE_PAL = ["#ef4444", "#f97316", "#fbbf24", "#34d399", "#10b981"]
def score_color(val):
return SCORE_PAL[max(0, min(4, round(val) - 1))]
def hex_to_rgb(h):
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def adjusted_avg(row):
total = sum((6 - row[d]) if d == "duplication_risk" else row[d] for d in DIM_IDS)
return round(total / len(DIM_IDS), 2)
df = pd.DataFrame(PROMPTS)
df["adj_avg"] = df.apply(adjusted_avg, axis=1)
theta_closed = DIM_LABELS + [DIM_LABELS[0]]
# ── Figure 1: Heatmap ─────────────────────────────────────────────────────────
z = df[DIM_IDS].values.tolist()
labels = df["label"].tolist()
fig_heatmap = go.Figure(go.Heatmap(
z=z, x=DIM_LABELS, y=labels,
text=[[str(v) for v in row] for row in z],
texttemplate="%{text}",
textfont=dict(size=11, color="white", family="monospace"),
colorscale=COLORSCALE, zmin=1, zmax=5,
colorbar=dict(
title=dict(text="Score", font=dict(color=TXT_SUB)),
tickfont=dict(color=TXT_SUB, family="monospace"),
bgcolor=CARD_BG, bordercolor=BORDER, thickness=14,
),
hovertemplate="<b>%{y}</b><br>%{x}: %{z}/5<extra></extra>",
))
fig_heatmap.update_layout(
title=dict(text="Score Heatmap — All Prompts × All Dimensions",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=10),
xaxis=dict(tickangle=-35, showgrid=False, tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=10, color=TXT_MAIN), autorange="reversed"),
margin=dict(l=290, r=80, t=60, b=140), height=680,
)
# ── Figure 2: Adjusted average bar chart ─────────────────────────────────────
df_s = df.sort_values("adj_avg")
fig_bar = go.Figure(go.Bar(
x=df_s["adj_avg"], y=df_s["label"], orientation="h",
marker=dict(color=[GROUP_COLORS.get(g, "#6b7280") for g in df_s["group"]], line=dict(width=0)),
text=df_s["adj_avg"].astype(str), textposition="outside",
textfont=dict(color=TXT_SUB, size=10, family="monospace"),
hovertemplate="<b>%{y}</b><br>Adj. avg: %{x}<extra></extra>",
))
for grp, col in GROUP_COLORS.items():
fig_bar.add_trace(go.Scatter(x=[None], y=[None], mode="markers",
marker=dict(size=10, color=col), name=grp))
fig_bar.update_layout(
title=dict(text="Adjusted Average Score (duplication risk inverted)",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=10),
xaxis=dict(range=[0, 5.8], showgrid=True, gridcolor=BORDER,
tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=10, color=TXT_MAIN)),
legend=dict(bgcolor=CARD_BG, bordercolor=BORDER, borderwidth=1,
font=dict(color=TXT_SUB, size=10)),
margin=dict(l=290, r=60, t=60, b=40), height=620,
)
# ── Figure 3: Dimension averages ──────────────────────────────────────────────
dim_avgs = []
for dim, lbl in DIMENSIONS:
avg_val = round((6 - df[dim]).mean() if dim == "duplication_risk" else df[dim].mean(), 2)
dim_avgs.append((lbl + (" (inv.)" if dim == "duplication_risk" else ""), avg_val))
dim_avgs.sort(key=lambda x: x[1])
fig_dims = go.Figure(go.Bar(
x=[v for _, v in dim_avgs], y=[n for n, _ in dim_avgs], orientation="h",
marker=dict(color=[score_color(v) for _, v in dim_avgs], line=dict(width=0)),
text=[str(v) for _, v in dim_avgs], textposition="outside",
textfont=dict(color=TXT_SUB, size=11, family="monospace"),
hovertemplate="<b>%{y}</b><br>Average: %{x}<extra></extra>",
))
fig_dims.update_layout(
title=dict(text="Average Score per Dimension — All 20 Prompts",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
plot_bgcolor=CARD_BG, paper_bgcolor=CARD_BG,
font=dict(color=TXT_SUB, family="monospace", size=11),
xaxis=dict(range=[0, 5.6], showgrid=True, gridcolor=BORDER,
tickfont=dict(size=10, color=TXT_SUB)),
yaxis=dict(showgrid=False, tickfont=dict(size=11, color=TXT_MAIN)),
margin=dict(l=230, r=60, t=60, b=40), height=400,
)
# ── Figure 4: Group radar overlay ─────────────────────────────────────────────
fig_radar = go.Figure()
for grp, col in GROUP_COLORS.items():
gdf = df[df["group"] == grp]
if gdf.empty:
continue
scores = [round(gdf[d].mean(), 2) for d in DIM_IDS]
r_c = scores + [scores[0]]
r, g, b = hex_to_rgb(col)
fig_radar.add_trace(go.Scatterpolar(
r=r_c, theta=theta_closed, fill="toself",
fillcolor=f"rgba({r},{g},{b},0.12)", line=dict(color=col, width=2),
name=grp,
hovertemplate="%{theta}: %{r:.2f}<extra>" + grp + "</extra>",
))
fig_radar.update_layout(
title=dict(text="Group Average Radar — All Groups Overlaid",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
polar=dict(
bgcolor="#0d1117",
radialaxis=dict(visible=True, range=[0, 5], showticklabels=False,
gridcolor=BORDER, linecolor=BORDER),
angularaxis=dict(tickfont=dict(color=TXT_SUB, size=10, family="monospace"),
gridcolor=BORDER, linecolor=BORDER),
),
paper_bgcolor=CARD_BG,
legend=dict(bgcolor=CARD_BG, bordercolor=BORDER, borderwidth=1,
font=dict(color=TXT_SUB, size=11)),
margin=dict(l=60, r=60, t=60, b=60), height=500,
)
# ── Figure 5: Per-prompt radar grid ──────────────────────────────────────────
COLS = 4
N = len(PROMPTS)
ROWS = -(-N // COLS)
specs = [[{"type": "polar"}] * COLS for _ in range(ROWS)]
titles = [p["label"] for p in PROMPTS] + [""] * (ROWS * COLS - N)
fig_grid = make_subplots(rows=ROWS, cols=COLS, specs=specs,
subplot_titles=titles,
vertical_spacing=0.08, horizontal_spacing=0.04)
for i, pd_ in enumerate(PROMPTS):
ri = i // COLS + 1
ci = i % COLS + 1
scores = [pd_[d] for d in DIM_IDS]
r_c = scores + [scores[0]]
col = GROUP_COLORS.get(pd_["group"], "#6b7280")
r, g, b = hex_to_rgb(col)
fig_grid.add_trace(go.Scatterpolar(
r=r_c, theta=theta_closed, fill="toself",
fillcolor=f"rgba({r},{g},{b},0.18)", line=dict(color=col, width=1.5),
name=pd_["label"], showlegend=False,
hovertemplate="%{theta}: %{r}<extra>" + pd_["label"] + "</extra>",
), row=ri, col=ci)
for i in range(1, ROWS * COLS + 1):
key = f"polar{i}" if i > 1 else "polar"
fig_grid.update_layout(**{key: dict(
bgcolor="#0d1117",
radialaxis=dict(visible=True, range=[0, 5], showticklabels=False,
gridcolor=BORDER, linecolor=BORDER),
angularaxis=dict(tickfont=dict(color=TXT_DIM, size=7),
gridcolor=BORDER, linecolor=BORDER),
)})
for ann in fig_grid.layout.annotations:
ann.font = dict(color=TXT_SUB, size=9, family="monospace")
fig_grid.update_layout(
title=dict(text="Individual Prompt Radar Grid",
font=dict(color=TXT_MAIN, size=15, family="Georgia"), x=0.01),
paper_bgcolor=CARD_BG, height=ROWS * 200 + 80,
margin=dict(l=20, r=20, t=60, b=20),
)
# ── Reviewer notes table ──────────────────────────────────────────────────────
notes_rows_html = ""
for _, row in df.sort_values("adj_avg").iterrows():
col = GROUP_COLORS.get(row["group"], "#6b7280")
avg = row["adj_avg"]
acol = score_color(avg)
notes_rows_html += f"""
<tr>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};">
<span style="font-size:10px;color:{col};font-family:monospace;
text-transform:uppercase;display:block;margin-bottom:3px;">{row['group']}</span>
<span style="font-size:12px;color:{TXT_MAIN};">{row['label']}</span>
</td>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};font-size:18px;
font-weight:bold;font-family:monospace;color:{acol};text-align:center;">{avg}</td>
<td style="padding:10px 12px;border-bottom:1px solid {BORDER};font-size:12px;
color:{TXT_SUB};line-height:1.6;font-family:'Helvetica Neue',sans-serif;">{row['notes']}</td>
</tr>"""
# ── Build full HTML with Plotly embedded inline (no CDN needed) ───────────────
# Render each figure as a div (no plotly JS included yet)
def fig_div(fig):
return fig.to_html(full_html=False, include_plotlyjs=False)
# Get the plotly.min.js source from the local package
import os, plotly
plotly_js_path = os.path.join(os.path.dirname(plotly.__file__),
"package_data", "plotly.min.js")
with open(plotly_js_path, "r", encoding="utf-8") as f:
plotly_js = f.read()
HTML = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prompt Rubric Dashboard</title>
<script>{plotly_js}</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
body {{background:{DARK_BG};color:{TXT_MAIN};font-family:Georgia,serif;}}
header {{background:#1a1f2e;border-bottom:1px solid {BORDER};padding:28px 40px;}}
header p.eyebrow {{color:{TXT_DIM};font-size:11px;letter-spacing:.2em;
font-family:monospace;text-transform:uppercase;margin-bottom:6px;}}
header h1 {{font-size:28px;font-weight:normal;color:{TXT_MAIN};margin-bottom:6px;}}
header p.sub {{color:{TXT_DIM};font-size:12px;font-family:'Helvetica Neue',sans-serif;}}
main {{max-width:1300px;margin:0 auto;padding:32px 40px;}}
.card {{background:{CARD_BG};border:1px solid {BORDER};border-radius:10px;
padding:20px;margin-bottom:24px;}}
h2.st {{font-size:13px;font-weight:normal;color:{TXT_SUB};font-family:monospace;
letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px;}}
.two-col {{display:grid;grid-template-columns:1fr 1fr;gap:24px;}}
table {{width:100%;border-collapse:collapse;}}
th {{text-align:left;padding:8px 12px;color:{TXT_DIM};font-size:11px;
font-family:monospace;letter-spacing:.05em;border-bottom:2px solid {BORDER};}}
@media(max-width:800px){{.two-col{{grid-template-columns:1fr;}}main{{padding:16px;}}}}
</style>
</head>
<body>
<header>
<p class="eyebrow">Prompt Quality Assessment</p>
<h1>Rubric Dashboard</h1>
<p class="sub">20 prompts · 8 dimensions ·
* Duplication Risk is inverted — lower score = lower risk = better</p>
</header>
<main>
<div class="card"><h2 class="st">Score Heatmap</h2>{fig_div(fig_heatmap)}</div>
<div class="two-col">
<div class="card"><h2 class="st">Adjusted Average by Prompt</h2>{fig_div(fig_bar)}</div>
<div class="card"><h2 class="st">Dimension Averages — All Prompts</h2>{fig_div(fig_dims)}</div>
</div>
<div class="two-col">
<div class="card"><h2 class="st">Group Radar Overlay</h2>{fig_div(fig_radar)}</div>
<div class="card"><h2 class="st">Individual Prompt Radar Grid</h2>{fig_div(fig_grid)}</div>
</div>
<div class="card">
<h2 class="st">Reviewer Notes</h2>
<table>
<thead><tr>
<th style="width:240px;">Prompt</th>
<th style="width:80px;text-align:center;">Adj. Avg</th>
<th>Notes</th>
</tr></thead>
<tbody>{notes_rows_html}</tbody>
</table>
</div>
</main>
</body>
</html>"""
output_path = "rubric_dashboard.html"
with open(output_path, "w", encoding="utf-8") as f:
f.write(HTML)
file_size = os.path.getsize(output_path) // 1024
print(f"Saved: {output_path} ({file_size} KB)")
# ── Serve locally and open in Android browser ─────────────────────────────────
import subprocess
import threading
import http.server
import socketserver
import webbrowser
import signal
PORT = 8080
directory = os.path.dirname(os.path.abspath(output_path))
filename = os.path.basename(output_path)
url = f"http://127.0.0.1:{PORT}/{filename}"
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=directory, **kwargs)
def log_message(self, format, *args):
pass # suppress request logs
socketserver.TCPServer.allow_reuse_address = True
httpd = socketserver.TCPServer(("127.0.0.1", PORT), Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
print(f"\nServer running at {url}")
print("Opening browser — if it does not launch automatically,")
print(f"open your browser and go to: {url}")
print("Press Ctrl+C to stop the server when done.\n")
# Launch browser via Android activity manager
try:
result = subprocess.run(
["am", "start", "--user", "0",
"-a", "android.intent.action.VIEW",
"-d", url],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip())
print("Browser launched.")
except Exception:
webbrowser.open(url)
# Keep server alive until Ctrl+C
try:
signal.pause() # efficient wait on Linux/Android
except (AttributeError, OSError):
try:
input("Press Enter to stop the server...")
except EOFError:
import time
while True:
time.sleep(1) # fallback: keep alive indefinitely
finally:
httpd.shutdown()