from __future__ import annotations
import json
from html import escape
from pathlib import Path
import gradio as gr
import pandas as pd
from src.totem_workbook import (
DEFAULT_WORKBOOK,
LOG_COLUMNS,
METRICS,
export_updated_workbook,
manuscript_tracker_table,
recalculate_log,
score_log,
score_single_row,
viability_table,
workbook_overview,
workbook_path,
workstack_table,
)
from src.codex_extractor import process_upload, format_fingerprint_report
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
CSS = """
:root {
--studio-green: #0e4a1d;
--studio-green-2: #17642a;
--studio-gold: #e4aa1a;
--studio-cream: #fffaf0;
--studio-ink: #15351d;
--studio-muted: #6d725f;
--studio-line: #eadfbd;
--studio-red: #cf4b3f;
}
.gradio-container {
max-width: none !important;
padding: 0 !important;
background: #fffaf0 !important;
color: var(--studio-ink) !important;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
}
footer { display: none !important; }
#hidden-export, #hidden-status {
max-width: 1180px;
margin: 0 auto 18px auto;
}
#studio-actions {
max-width: 1180px;
margin: -705px auto 16px auto;
padding-left: 18px;
position: relative;
z-index: 5;
}
#studio-actions .wrap {
max-width: 430px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
#studio-actions button {
border-radius: 8px !important;
min-height: 48px !important;
font-weight: 800 !important;
}
#path-panel {
max-width: 1180px;
margin: 18px auto;
padding: 0 18px;
}
#path-panel .wrap {
display: grid;
grid-template-columns: minmax(360px, 1fr) 210px;
gap: 12px;
max-width: 760px;
}
#path-panel textarea,
#path-panel input {
border-radius: 8px !important;
border: 1px solid var(--studio-line) !important;
background: white !important;
}
#path-panel button {
border-radius: 8px !important;
min-height: 52px !important;
font-weight: 800 !important;
}
#score-panel {
max-width: 1180px;
margin: 18px auto 44px auto;
padding: 0 18px;
}
#score-panel .score-card {
border: 1px solid var(--studio-line);
background: #fffef8;
border-radius: 8px;
padding: 18px;
}
#score-panel h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: var(--studio-green);
}
#score-panel button {
border-radius: 8px !important;
font-weight: 800 !important;
}
#score-panel .wrap {
gap: 12px;
}
#codex-panel {
max-width: 1180px;
margin: 18px auto 44px auto;
padding: 0 18px;
}
#codex-panel .codex-header {
background: linear-gradient(135deg, #0e4a1d, #17642a);
border-radius: 8px 8px 0 0;
padding: 20px 24px;
color: white;
}
#codex-panel .codex-header h3 {
margin: 0;
color: #f8e838;
font-size: 20px;
font-family: Georgia, serif;
}
#codex-panel .codex-header p {
margin: 6px 0 0;
color: #d9ead0;
font-size: 13px;
}
#codex-panel .codex-body {
border: 1px solid var(--studio-line);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 24px;
background: #fffef8;
}
#codex-panel .confidence-high {
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 10px 14px;
color: #1b5e20;
font-weight: 700;
}
#codex-panel .confidence-medium {
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 6px;
padding: 10px 14px;
color: #e65100;
font-weight: 700;
}
#codex-panel .confidence-low {
background: #ffebee;
border: 1px solid #ef9a9a;
border-radius: 6px;
padding: 10px 14px;
color: #b71c1c;
font-weight: 700;
}
.dataframe, .table-wrap, .sheet, .tabs, .tabitem {
border-radius: 8px !important;
}
@media (max-width: 900px) {
#studio-actions {
margin-top: 0;
padding: 14px;
}
#studio-actions .wrap,
#path-panel .wrap {
grid-template-columns: 1fr;
}
}
"""
REVISION_ACTIONS = {
"Clarity": "Simplify the line and sharpen the subject/action.",
"Rhythm": "Rework beat pattern and remove drag.",
"Read-aloud Flow": "Run a speak-test pass and cut mouth knots.",
"Emotional Truth": "Anchor the feeling in the child-facing moment.",
"Visual Strength": "Sharpen the drawable page beat.",
"Commercial Publishability": "Tighten hook, age fit, and list-readiness.",
}
def _clean_path(uploaded_file) -> Path:
return workbook_path(uploaded_file)
def _validate_workbook_path(path: Path) -> Path:
if not path.exists():
raise gr.Error(f"Workbook path does not exist: {path}")
if path.suffix.lower() not in {".xlsx", ".xlsm"}:
raise gr.Error("Upload or load an Excel workbook: .xlsx or .xlsm.")
return path
def _score_summary(log_df: pd.DataFrame | None) -> str:
if log_df is None or log_df.empty:
return "No scored rows yet."
scored = log_df[log_df["Weighted Score"].astype(str) != ""].copy()
if scored.empty:
return "No scored rows yet."
scored["Weighted Score"] = pd.to_numeric(scored["Weighted Score"], errors="coerce")
average = round(float(scored["Weighted Score"].mean()), 1)
revisions = int((scored["Revision Flag"] == "Yes").sum())
return f"{len(scored)} scored rows. Average weighted score {average}/10. Revision flags {revisions}."
def _metric_value(log_df: pd.DataFrame, metric: str, fallback: float) -> int:
if log_df is not None and not log_df.empty and metric in log_df:
values = pd.to_numeric(log_df[metric], errors="coerce").dropna()
if not values.empty:
return int(round(float(values.mean()) * 10))
return int(round(fallback * 10))
def _small_spark(value: int, tone: str) -> str:
heights = [19, 23, 16, 18, 17, 20, 22, 30, 25, 29, 34, 27]
color = "#3f8f2f" if tone == "green" else "#dda10c" if tone == "gold" else "#d84f45"
bars = "".join(f"" for h in heights)
return f"
{bars}
"
def _kpi_card(title: str, value: int, icon: str, tone: str, delta: str) -> str:
return f"""
{icon}
{escape(title)}{value}/100
{_small_spark(value, tone)}
{escape(delta)}
"""
def _priority_badge(gate: str) -> tuple[str, str]:
if gate in {"HARD FAIL", "SOFT FAIL", "READ-ALOUD BLOCK"}:
return "High", "high"
if gate in {"COMMERCIAL CHECK", "REVISE"}:
return "Medium", "medium"
return "Low", "low"
def _revision_rows(log_df: pd.DataFrame, tracker_df: pd.DataFrame) -> str:
rows = []
if log_df is not None and not log_df.empty:
working = log_df.copy()
working["Weighted Score"] = pd.to_numeric(working["Weighted Score"], errors="coerce")
working = working.sort_values(["Revision Flag", "Weighted Score"], ascending=[False, True])
for _, row in working.head(5).iterrows():
metric = str(row.get("Priority Fix") or "Read-aloud Flow")
gate = str(row.get("Gate") or "REVISE")
priority, cls = _priority_badge(gate)
block = str(row.get("Stanza ID") or row.get("Sequence") or "Live block")
action = REVISION_ACTIONS.get(metric, "Revise the weakest pressure point first.")
rows.append(
f"""
{escape(block)}
{escape(metric)}
{escape(gate.title())}
{priority}
{escape(action)}
"""
)
if len(rows) < 5 and tracker_df is not None and not tracker_df.empty:
for _, row in tracker_df.head(5 - len(rows)).iterrows():
block = str(row.get("Block", "Block"))
metric = str(row.get("TOTEM priority", "Read-aloud Flow"))
action = str(row.get("Next action", REVISION_ACTIONS.get(metric, "Continue next pass.")))
rows.append(
f"""
{escape(block)}
{escape(metric)}
Development Gate
Medium
{escape(action)}
"""
)
return "\n".join(rows) or "No revision rows found.
"
def _risk_cards(log_df: pd.DataFrame) -> str:
if log_df is None or log_df.empty:
risks = [("Workbook Intake", "No scored rows detected yet.", "Medium Risk", "medium", "▁▂▃▂▁▂")]
else:
counts: dict[str, int] = {}
for metric in METRICS:
values = pd.to_numeric(log_df[metric], errors="coerce").dropna()
weak = int((values < 7).sum())
if weak:
counts[metric] = weak
if not counts:
counts = {"Commercial Publishability": 1}
ordered = sorted(counts.items(), key=lambda item: item[1], reverse=True)[:3]
risks = []
for metric, count in ordered:
tone = "high" if count >= 2 else "medium"
label = "High Risk" if tone == "high" else "Medium Risk"
risks.append((metric, f"Detected under target in {count} scored block(s).", label, tone, "▂▅▃▇▁▆▂▅"))
cards = []
icons = {
"Read-aloud Flow": "≋", "Rhythm": "≋", "Visual Strength": "◉",
"Emotional Truth": "♡", "Commercial Publishability": "↗",
}
for metric, detail, label, tone, bars in risks:
cards.append(
f"""
{icons.get(metric, "!")}
{escape(metric)}{escape(detail)}
{escape(label)}
{escape(bars)}
"""
)
return "\n".join(cards)
def _recent_workbooks(path: Path, overall: int) -> str:
name = path.stem.replace("_", " ")
return f"""
TOTEM
{escape(name[:38])}
Current workbook · Loaded now
{overall}%
"""
def dashboard_html(path: Path, notice: str = "") -> str:
path = _validate_workbook_path(path)
overview = workbook_overview(path)
log_df = score_log(path)
viability_df, viability_summary = viability_table(path)
tracker_df = manuscript_tracker_table(path)
workstack_df = workstack_table(path)
avg_viability = float(viability_df["Score"].mean()) if viability_df is not None and not viability_df.empty else 0
overall = int(round(avg_viability * 10)) if avg_viability else 0
read_flow = _metric_value(log_df, "Read-aloud Flow", 6.4)
emotional = _metric_value(log_df, "Emotional Truth", 7.8)
visual = _metric_value(log_df, "Visual Strength", 6.9)
commercial = _metric_value(log_df, "Commercial Publishability", avg_viability or 7.1)
weakest = "Read-aloud Flow"
strongest = "Emotional Truth"
if log_df is not None and not log_df.empty:
metric_means = {
metric: pd.to_numeric(log_df[metric], errors="coerce").dropna().mean()
for metric in METRICS
}
metric_means = {metric: value for metric, value in metric_means.items() if pd.notna(value)}
if metric_means:
weakest = min(metric_means, key=metric_means.get)
strongest = max(metric_means, key=metric_means.get)
next_item = ""
if workstack_df is not None and not workstack_df.empty and "Status" in workstack_df:
active = workstack_df[workstack_df["Status"].isin(["Active", "Queued"])]
if not active.empty:
next_item = str(active.iloc[0].get("Next item", "Run next pass"))
next_item = next_item or "Run the next TOTEM pass"
notice_block = f"{escape(notice)}
" if notice else ""
return f"""
Search projects, workbooks, blocks...
Editorial Workspace🔔TTOTEM
Studio
TOTEM Studio.
Data-driven insight for stronger stories.
{notice_block}
{_kpi_card("Overall Publishability", overall, "✦", "green", "Source: viability lens")}
{_kpi_card("Read-Aloud Flow", read_flow, "≋", "red" if read_flow < 65 else "gold", "Weakest live pressure")}
{_kpi_card("Emotional Truth", emotional, "♡", "green", "Strongest story signal")}
{_kpi_card("Visual Strength", visual, "◉", "gold" if visual < 75 else "green", "Drawable page value")}
{_kpi_card("Commercial Viability", commercial, "↗", "green", "Publisher-facing lens")}
Revision Priority Queue {len(log_df) if log_df is not None else 0} live item(s)
{_revision_rows(log_df, tracker_df)}
Risk Clusters
{_risk_cards(log_df)}
Recent Workbook
{_recent_workbooks(path, overall)}
TOTEM Snapshot Based on latest run
Weakest Dimension{escape(weakest)}{read_flow}/100
Strongest Dimension{escape(strongest)}{max(emotional, visual)}/100
Next Work{escape(next_item[:42])}{escape(viability_summary)}
Loaded {escape(path.name)} · {overview['sheet_count']} sheets read privately · matrix hidden from the product surface.
"""
# ── CODEX EXTRACTOR FUNCTIONS ─────────────────────────────────────────────────
def run_codex_extraction(
file_obj,
author_name: str,
author_id: str,
works_sampled: str,
) -> tuple[str, str]:
"""
Gradio handler for the Codex Extraction tab.
Returns (report_text, json_output) tuple.
"""
if file_obj is None:
return "No file uploaded. Please upload a .txt or .pdf file.", ""
if not author_name.strip():
return "Please enter the author's full name before extracting.", ""
try:
report, fp_dict = process_upload(
file_path=file_obj,
author_name=author_name.strip(),
author_id=author_id.strip() or "CA-XXX",
works_sampled=works_sampled.strip(),
)
if not fp_dict:
return report, ""
# Format JSON output for workbook entry
json_out = json.dumps(fp_dict, indent=2)
return report, json_out
except Exception as e:
return f"Extraction error: {type(e).__name__}: {str(e)}", ""
def clear_codex_form() -> tuple[None, str, str, str, str, str]:
"""Reset the Codex extraction form."""
return None, "", "CA-XXX", "", "", ""
# ── WORKBOOK FUNCTIONS ────────────────────────────────────────────────────────
def load_workbook(uploaded_file=None, notice: str = ""):
path = _validate_workbook_path(_clean_path(uploaded_file))
log_df = score_log(path)
return str(path), dashboard_html(path, notice), log_df, _score_summary(log_df)
def load_default():
return load_workbook(None, "Bundled workbook reloaded.")
def load_uploaded(uploaded_file):
if uploaded_file is None:
raise gr.Error("Choose an .xlsx or .xlsm workbook first.")
return load_workbook(uploaded_file, "Workbook uploaded and analysed.")
def load_local_path(path_text: str):
path = _validate_workbook_path(Path(path_text or "").expanduser())
log_df = score_log(path)
return str(path), dashboard_html(path, "Local workbook loaded."), log_df, _score_summary(log_df)
def run_analysis(active_path: str):
path = _validate_workbook_path(Path(active_path) if active_path else DEFAULT_WORKBOOK)
log_df = score_log(path)
return dashboard_html(path, "TOTEM analysis refreshed."), log_df, _score_summary(log_df)
def recalc_log(log_df, active_path: str):
path = _validate_workbook_path(Path(active_path) if active_path else DEFAULT_WORKBOOK)
recalculated = recalculate_log(log_df, path)
return dashboard_html(path, "Gates recalculated."), recalculated, _score_summary(recalculated)
def export_log(log_df, active_path: str):
path = _validate_workbook_path(Path(active_path) if active_path else DEFAULT_WORKBOOK)
return export_updated_workbook(log_df, path)
def single_score(active_path, sequence, stanza_id, draft_pass, clarity, rhythm, flow,
emotional_truth, visual_strength, commercial, notes):
path = _validate_workbook_path(Path(active_path) if active_path else DEFAULT_WORKBOOK)
df = score_single_row(path, sequence, stanza_id, draft_pass, clarity, rhythm,
flow, emotional_truth, visual_strength, commercial, notes)
return df, _score_summary(df)
# ── GRADIO INTERFACE ──────────────────────────────────────────────────────────
with gr.Blocks(title="TOTEM Studio", css=CSS) as demo:
active_path = gr.State(str(DEFAULT_WORKBOOK))
log_state = gr.State(pd.DataFrame(columns=LOG_COLUMNS))
with gr.Tabs():
# ── TAB 1: DASHBOARD ─────────────────────────────────────────────────
with gr.TabItem("Dashboard"):
dashboard = gr.HTML()
with gr.Row(elem_id="studio-actions"):
with gr.Column(elem_classes=["wrap"]):
workbook_upload = gr.UploadButton(
"Upload Workbook",
file_types=[".xlsx", ".xlsm"],
type="filepath",
variant="primary",
scale=1,
)
run_button = gr.Button("Run TOTEM Analysis", variant="secondary", scale=1)
with gr.Row(elem_id="path-panel"):
with gr.Column(elem_classes=["wrap"]):
path_input = gr.Textbox(label="Local workbook path", value=ORIGINAL_WORKBOOK_PATH)
path_button = gr.Button("Load Local Path", variant="primary")
with gr.Accordion("Private scoring controls", open=False, elem_id="score-panel"):
gr.HTML("Live Score A Block
")
with gr.Row():
sequence = gr.Textbox(label="Sequence", value="Live pass")
stanza_id = gr.Textbox(label="Stanza ID", value="New block")
draft_pass = gr.Textbox(label="Draft / Pass", value="First score")
with gr.Row():
clarity = gr.Slider(1, 10, value=7, step=0.5, label="Clarity")
rhythm = gr.Slider(1, 10, value=7, step=0.5, label="Rhythm")
flow = gr.Slider(1, 10, value=7, step=0.5, label="Read-aloud Flow")
with gr.Row():
emotional_truth = gr.Slider(1, 10, value=7, step=0.5, label="Emotional Truth")
visual_strength = gr.Slider(1, 10, value=7, step=0.5, label="Visual Strength")
commercial = gr.Slider(1, 10, value=7, step=0.5, label="Commercial Publishability")
notes = gr.Textbox(label="Notes", lines=2)
with gr.Row():
single_button = gr.Button("Score Block", variant="primary")
recalc_button = gr.Button("Recalculate Gates")
export_button = gr.Button("Download Updated Workbook")
single_df = gr.Dataframe(label="Latest scorecard", interactive=False, visible=False)
score_status = gr.Markdown(elem_id="hidden-status")
exported_file = gr.File(label="Export appears here", elem_id="hidden-export")
# ── TAB 2: CODEX EXTRACTOR ───────────────────────────────────────────
with gr.TabItem("◈ Codex Extractor"):
gr.HTML("""
Codex Fingerprint Extractor
Upload an author's text or PDF. The extractor computes 17 Tier 1 voice metrics
(VM-001 to VM-013, VM-024 to VM-028) mathematically from the text.
Copy the output into CODEX_03_FINGERPRINTS in the workbook.
Use Codex Build Prompt 2 (ChatGPT/Gemini) for the 10 Tier 2 qualitative metrics.
""")
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### Author Details")
codex_author_name = gr.Textbox(
label="Author Full Name",
placeholder="e.g. Julia Donaldson",
)
codex_author_id = gr.Textbox(
label="Codex Author ID",
placeholder="e.g. CA-001",
value="CA-XXX",
)
codex_works = gr.Textbox(
label="Works Sampled (comma-separated)",
placeholder="e.g. The Gruffalo, Room on the Broom, Zog",
lines=2,
)
codex_file = gr.File(
label="Upload Text or PDF",
file_types=[".txt", ".pdf"],
type="filepath",
)
with gr.Row():
codex_extract_btn = gr.Button(
"Extract Fingerprint",
variant="primary",
scale=2,
)
codex_clear_btn = gr.Button(
"Clear",
variant="secondary",
scale=1,
)
gr.HTML("""
File requirements:
• PDF must contain selectable text (not scanned images)
• Minimum 1,000 words for HIGH confidence fingerprint
• Combine multiple works in one file to increase sample size
• Visual-primary books (Van Allsburg, Jeffers) will flag LOW confidence
""")
with gr.Column(scale=2):
gr.Markdown("### Extraction Report — Tier 1 Metrics")
codex_report = gr.Textbox(
label="",
lines=32,
interactive=False,
placeholder="Upload a file and click Extract Fingerprint to see results here...",
elem_id="codex-report",
)
gr.Markdown("### Raw Output — Copy into CODEX_03_FINGERPRINTS")
codex_json = gr.Textbox(
label="",
lines=20,
interactive=False,
placeholder="JSON values appear here after extraction. Copy individual metric values into the workbook row.",
elem_id="codex-json",
)
gr.HTML("""
Tier 2 reminder:
VM-014 (Narrative person) through VM-023 (Animal/nature imagery ratio) require
qualitative judgment. Use Codex Build Prompt 2 from the Codex Build Prompts document
with the same text in ChatGPT or Gemini to complete the remaining 10 metrics.
""")
# ── EVENT WIRING ─────────────────────────────────────────────────────────
# Dashboard tab
demo.load(load_workbook, outputs=[active_path, dashboard, log_state, score_status])
workbook_upload.upload(load_uploaded, inputs=[workbook_upload],
outputs=[active_path, dashboard, log_state, score_status])
run_button.click(run_analysis, inputs=[active_path],
outputs=[dashboard, log_state, score_status])
path_button.click(load_local_path, inputs=[path_input],
outputs=[active_path, dashboard, log_state, score_status])
recalc_button.click(recalc_log, inputs=[log_state, active_path],
outputs=[dashboard, log_state, score_status])
export_button.click(export_log, inputs=[log_state, active_path], outputs=[exported_file])
single_button.click(
single_score,
inputs=[active_path, sequence, stanza_id, draft_pass, clarity, rhythm,
flow, emotional_truth, visual_strength, commercial, notes],
outputs=[single_df, score_status],
)
# Codex Extractor tab
codex_extract_btn.click(
run_codex_extraction,
inputs=[codex_file, codex_author_name, codex_author_id, codex_works],
outputs=[codex_report, codex_json],
)
codex_clear_btn.click(
clear_codex_form,
outputs=[codex_file, codex_author_name, codex_author_id, codex_works,
codex_report, codex_json],
)
if __name__ == "__main__":
demo.launch()