from __future__ import annotations
import hashlib
import html
import json
import re
from html import escape
from pathlib import Path
import gradio as gr
import pandas as pd
import gradio_client.utils as gradio_client_utils
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
from smoke_signal_tab import smoke_signal_tab, SS_CSS
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
CODEX_CATALOGUE_PATH = Path("data/codex_catalogue.xlsx")
def _patch_gradio_schema_bool_compat() -> None:
"""
Compatibility shim for Gradio API schema parsing where boolean JSON schema
nodes can appear as `additionalProperties: true` in newer Pydantic output.
"""
original_get_type = gradio_client_utils.get_type
def _safe_get_type(schema):
if isinstance(schema, bool):
return "boolean"
return original_get_type(schema)
gradio_client_utils.get_type = _safe_get_type
_patch_gradio_schema_bool_compat()
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; }
.gradio-container [role="tablist"] {
position: sticky !important;
top: 0 !important;
z-index: 60 !important;
background: #fffaf0 !important;
border-bottom: 1px solid var(--studio-line);
padding: 8px 10px;
gap: 8px;
overflow: visible !important;
}
.gradio-container [role="tab"] {
opacity: 1 !important;
visibility: visible !important;
color: var(--studio-ink) !important;
background: #f4ecd6 !important;
border: 1px solid var(--studio-line) !important;
border-radius: 8px !important;
padding: 8px 14px !important;
font-weight: 700 !important;
}
.gradio-container [role="tab"][aria-selected="true"] {
background: linear-gradient(90deg, #f5c93c, #e5a721) !important;
color: white !important;
border-color: #d99e1b !important;
}
.nav-item[role="button"] {
cursor: pointer;
}
#hidden-export, #hidden-status {
max-width: 1180px;
margin: 0 auto 18px auto;
}
#studio-actions {
max-width: 1180px;
margin: 18px 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;
}
}
"""
TOTEM_CSS = """
.totem-shell {
--totem-navy: #07122D;
--totem-indigo: #0D1733;
--totem-panel: #101A3A;
--totem-panel-2: #111E44;
--totem-gold: #F2C14E;
--totem-violet: #8A4DFF;
--totem-pink: #FF5FD2;
--totem-emerald: #22B573;
--totem-cyan: #39C9FF;
--totem-ivory: #F6F1E8;
--totem-muted: #AEB7CC;
--totem-danger: #E04B45;
--totem-warning: #F2A93B;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 40px;
--radius-panel: 18px;
--radius-card: 14px;
--font-display: "Cormorant Garamond", "Playfair Display", Georgia, serif;
--font-ui: Inter, Aptos, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
min-height: 880px;
display: flex;
background: linear-gradient(180deg, #040816 0%, #07122d 100%);
color: var(--totem-ivory);
}
.totem-sidebar {
width: 256px;
flex: 0 0 256px;
min-height: 100%;
border-right: 1px solid rgba(242, 193, 78, 0.22);
background: linear-gradient(180deg, #050d24 0%, #07122d 65%, #091733 100%);
padding: 20px 18px;
}
.totem-main {
flex: 1;
min-width: 0;
padding: 20px 22px;
background: linear-gradient(180deg, #07122d 0%, #0b1633 100%);
}
.totem-main-inner {
width: 100%;
}
.totem-topbar,
.totem-hero,
.totem-status-row,
.totem-metrics-grid,
.totem-lower-grid {
border-radius: var(--radius-panel);
border: 1px solid rgba(242, 193, 78, 0.22);
background: linear-gradient(180deg, rgba(16, 26, 58, 0.96), rgba(17, 30, 68, 0.95));
margin-bottom: var(--space-4);
}
.totem-topbar {
min-height: 64px;
display: grid;
grid-template-columns: auto auto minmax(260px, 1fr) auto;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-5);
}
.totem-hero {
position: relative;
min-height: 222px;
padding: 44px 52px;
overflow: hidden;
border: 1px solid rgba(242, 193, 78, 0.35);
background:
radial-gradient(circle at 85% 50%, rgba(138, 77, 255, 0.24), transparent 30%),
linear-gradient(120deg, rgba(13, 23, 51, 0.98), rgba(35, 15, 62, 0.92) 58%, rgba(8, 18, 45, 0.98));
}
.totem-hero::after {
content: "";
position: absolute;
left: 18%;
right: 10%;
top: 44%;
height: 76px;
transform: rotate(-10deg);
border-radius: 999px;
filter: blur(12px);
background: linear-gradient(90deg, transparent, rgba(138, 77, 255, 0.45), rgba(242, 193, 78, 0.38), transparent);
}
.totem-hero-title {
position: relative;
z-index: 1;
margin: 0;
font-family: var(--font-display);
font-size: 44px;
line-height: 1.05;
color: var(--totem-gold);
}
.totem-hero-subtitle {
position: relative;
z-index: 1;
margin-top: 10px;
font-size: 22px;
color: rgba(246, 241, 232, 0.92);
font-family: var(--font-ui);
}
.totem-signal-wrap {
position: absolute;
right: 46px;
top: 31px;
width: 160px;
height: 160px;
}
.totem-signal {
width: 100%;
height: 100%;
}
.signal-track {
fill: none;
stroke: rgba(174, 183, 204, 0.28);
stroke-width: 8;
}
.signal-progress {
fill: none;
stroke: var(--totem-gold);
stroke-width: 8;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 80px 80px;
}
.signal-core-ring {
fill: rgba(7, 18, 45, 0.7);
stroke: rgba(57, 201, 255, 0.55);
stroke-width: 1.5;
}
.signal-star {
fill: var(--totem-gold);
}
.totem-signal-label {
position: absolute;
left: 0;
right: 0;
bottom: -18px;
text-align: center;
font-family: var(--font-ui);
font-size: 12px;
color: rgba(174, 183, 204, 0.98);
}
.totem-status-row {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-5);
border-radius: 10px;
}
.totem-status-row.state-ready,
.totem-status-row.state-complete {
background: rgba(34, 181, 115, 0.1);
border-color: rgba(34, 181, 115, 0.35);
}
.totem-status-row.state-running {
background: rgba(242, 169, 59, 0.1);
border-color: rgba(242, 169, 59, 0.35);
}
.totem-status-row.state-error {
background: rgba(224, 75, 69, 0.1);
border-color: rgba(224, 75, 69, 0.35);
}
.totem-run-btn {
min-height: 34px;
border-radius: 8px;
border: 1px solid rgba(34, 181, 115, 0.5);
background: linear-gradient(180deg, rgba(34, 181, 115, 0.25), rgba(10, 40, 29, 0.45));
color: rgba(217, 251, 232, 0.95);
padding: 0 var(--space-4);
font-family: var(--font-ui);
font-weight: 600;
}
.totem-metrics-grid {
min-height: 168px;
padding: var(--space-5);
}
.totem-lower-grid {
min-height: 240px;
padding: var(--space-5);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.totem-panel {
min-width: 0;
border-radius: var(--radius-card);
border: 1px solid rgba(174, 183, 204, 0.3);
background: linear-gradient(180deg, rgba(16, 26, 58, 0.95), rgba(8, 18, 45, 0.98));
overflow: hidden;
}
.totem-panel-title {
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid rgba(174, 183, 204, 0.22);
color: rgba(246, 241, 232, 0.98);
font-family: var(--font-display);
font-size: 34px;
line-height: 1.15;
}
.totem-panel-title small {
margin-left: var(--space-2);
font-family: var(--font-ui);
font-size: 14px;
color: rgba(174, 183, 204, 0.92);
}
.totem-empty-state {
min-height: 132px;
padding: 18px;
display: grid;
align-items: center;
color: rgba(174, 183, 204, 0.95);
font-family: var(--font-ui);
font-size: 14px;
}
.totem-queue-table {
width: 100%;
}
.totem-queue-head,
.totem-queue-row {
display: grid;
grid-template-columns: 1fr 1fr .86fr .72fr 1.34fr;
gap: 10px;
align-items: start;
padding: 12px 18px;
}
.totem-queue-head {
font-family: var(--font-ui);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.7px;
color: rgba(174, 183, 204, 0.9);
border-bottom: 1px solid rgba(174, 183, 204, 0.22);
}
.totem-queue-row {
font-family: var(--font-ui);
font-size: 13px;
color: rgba(246, 241, 232, 0.95);
border-bottom: 1px solid rgba(174, 183, 204, 0.15);
}
.totem-queue-row:last-child {
border-bottom: none;
}
.totem-queue-row span:last-child {
line-height: 1.35;
color: rgba(174, 183, 204, 0.98);
}
.totem-pill {
justify-self: start;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid transparent;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.totem-pill.tone-high {
color: #ffd7ea;
border-color: rgba(224, 75, 69, 0.55);
background: rgba(224, 75, 69, 0.2);
}
.totem-pill.tone-medium {
color: #ffe9b2;
border-color: rgba(242, 169, 59, 0.55);
background: rgba(242, 169, 59, 0.18);
}
.totem-pill.tone-low {
color: #c5ffeb;
border-color: rgba(57, 201, 255, 0.55);
background: rgba(57, 201, 255, 0.15);
}
.totem-risk-list {
width: 100%;
}
.totem-risk-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr) auto 150px;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(174, 183, 204, 0.15);
}
.totem-risk-row:last-child {
border-bottom: none;
}
.totem-risk-icon {
width: 46px;
height: 46px;
border-radius: 999px;
display: grid;
place-items: center;
font-family: var(--font-ui);
font-size: 18px;
font-weight: 700;
color: rgba(246, 241, 232, 0.98);
border: 1px solid rgba(174, 183, 204, 0.45);
background: rgba(7, 18, 45, 0.45);
}
.totem-risk-icon.tone-high {
border-color: rgba(224, 75, 69, 0.6);
box-shadow: 0 0 20px rgba(224, 75, 69, 0.25);
}
.totem-risk-icon.tone-medium {
border-color: rgba(242, 169, 59, 0.62);
box-shadow: 0 0 20px rgba(242, 169, 59, 0.22);
}
.totem-risk-icon.tone-low {
border-color: rgba(57, 201, 255, 0.6);
box-shadow: 0 0 20px rgba(57, 201, 255, 0.22);
}
.totem-risk-body {
min-width: 0;
}
.totem-risk-body b {
display: block;
font-family: var(--font-display);
font-size: 36px;
line-height: 1.05;
color: rgba(246, 241, 232, 0.98);
}
.totem-risk-body small {
display: block;
margin-top: 5px;
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.3;
color: rgba(174, 183, 204, 0.98);
white-space: normal;
word-break: break-word;
}
.totem-sparkline {
width: 150px;
height: 40px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 5px;
}
.totem-sparkline span {
flex: 1;
border-radius: 3px 3px 0 0;
background: rgba(174, 183, 204, 0.85);
}
.totem-sparkline.tone-high span {
background: linear-gradient(180deg, #ff7ea6, #e04b45);
}
.totem-sparkline.tone-medium span {
background: linear-gradient(180deg, #ffd37a, #f2a93b);
}
.totem-sparkline.tone-low span {
background: linear-gradient(180deg, #6ce6ff, #39c9ff);
}
.totem-shell .totem-brand {
font-family: var(--font-display);
font-size: 34px;
color: var(--totem-gold);
line-height: 1.1;
margin-bottom: var(--space-6);
}
.totem-shell .totem-brand small {
display: block;
margin-top: var(--space-2);
font-family: var(--font-ui);
font-size: 12px;
letter-spacing: 1.5px;
color: rgba(174, 183, 204, 0.9);
}
.totem-shell .totem-quote-card {
margin-top: calc(var(--space-8) + var(--space-8));
padding: var(--space-5);
border-radius: var(--radius-card);
border: 1px solid rgba(242, 193, 78, 0.28);
background: radial-gradient(circle at 82% 70%, rgba(138, 77, 255, 0.22), transparent 45%),
linear-gradient(180deg, rgba(16, 26, 58, 0.95), rgba(11, 23, 51, 0.96));
color: rgba(246, 241, 232, 0.95);
font-family: var(--font-display);
font-size: 24px;
line-height: 1.25;
}
.totem-shell .totem-nav-item {
height: 48px;
display: flex;
align-items: center;
padding: 0 var(--space-4);
border-radius: 12px;
color: rgba(246, 241, 232, 0.88);
margin-bottom: var(--space-2);
border: 1px solid transparent;
font-family: var(--font-ui);
}
.totem-shell .totem-nav-item.active {
border-color: rgba(242, 193, 78, 0.45);
background: linear-gradient(90deg, rgba(242, 193, 78, 0.2), rgba(138, 77, 255, 0.2));
}
.totem-shell .totem-muted {
color: var(--totem-muted);
font-family: var(--font-ui);
}
.totem-shell .totem-chip {
min-height: 36px;
border-radius: 10px;
border: 1px solid rgba(174, 183, 204, 0.3);
background: rgba(7, 18, 45, 0.5);
color: rgba(246, 241, 232, 0.9);
display: inline-flex;
align-items: center;
padding: 0 var(--space-4);
font-family: var(--font-ui);
margin-right: var(--space-2);
}
.totem-shell .totem-chip.active {
border-color: rgba(242, 193, 78, 0.5);
background: linear-gradient(90deg, rgba(242, 193, 78, 0.18), rgba(138, 77, 255, 0.18));
}
.totem-shell .totem-search {
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(174, 183, 204, 0.35);
background: rgba(7, 18, 45, 0.42);
color: rgba(174, 183, 204, 0.95);
display: flex;
align-items: center;
padding: 0 var(--space-4);
font-family: var(--font-ui);
}
.totem-shell .totem-workspace {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: rgba(246, 241, 232, 0.96);
font-family: var(--font-ui);
}
.totem-shell .totem-avatar {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(242, 193, 78, 0.45);
display: grid;
place-items: center;
background: rgba(242, 193, 78, 0.16);
}
.totem-shell .totem-badge {
border: 1px solid rgba(242, 193, 78, 0.45);
border-radius: 8px;
padding: 3px 7px;
color: var(--totem-gold);
font-size: 12px;
}
.totem-shell .totem-placeholder {
border: 1px dashed rgba(174, 183, 204, 0.45);
border-radius: var(--radius-card);
min-height: 78px;
display: grid;
place-items: center;
color: rgba(174, 183, 204, 0.95);
font-family: var(--font-ui);
}
.totem-shell .totem-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
.totem-metric-card {
min-height: 178px;
padding: 18px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(16, 26, 58, 0.96), rgba(8, 18, 45, 0.98));
border: 1px solid rgba(174, 183, 204, 0.3);
font-family: var(--font-ui);
}
.totem-metric-head {
display: grid;
grid-template-columns: 56px 1fr;
gap: 12px;
align-items: center;
}
.totem-metric-icon {
width: 56px;
height: 56px;
border-radius: 999px;
display: grid;
place-items: center;
color: #fff;
font-size: 24px;
background: rgba(7, 18, 45, 0.45);
border: 1px solid var(--metric-color);
box-shadow: 0 0 22px color-mix(in srgb, var(--metric-color), transparent 55%);
}
.totem-metric-label {
font-size: 14px;
color: rgba(246, 241, 232, 0.95);
}
.totem-metric-score {
margin-top: 12px;
font-size: 42px;
line-height: 1;
color: var(--metric-color);
font-variant-numeric: tabular-nums;
}
.totem-metric-score small {
font-size: 17px;
color: rgba(174, 183, 204, 0.95);
}
.totem-metric-bars {
display: flex;
align-items: end;
gap: 6px;
height: 32px;
margin-top: 14px;
}
.totem-metric-bars span {
width: 4px;
border-radius: 3px 3px 0 0;
background: var(--metric-color);
opacity: .95;
}
.totem-metric-hint {
margin-top: 12px;
font-size: 12px;
color: rgba(174, 183, 204, 0.98);
}
@media (max-width: 1200px) {
.totem-topbar {
grid-template-columns: auto minmax(200px, 1fr);
row-gap: var(--space-2);
}
.totem-shell .totem-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.totem-lower-grid {
grid-template-columns: 1fr;
}
.totem-panel-title {
font-size: 30px;
}
.totem-risk-body b {
font-size: 32px;
}
.totem-signal-wrap {
width: 132px;
height: 132px;
right: 28px;
}
}
@media (max-width: 900px) {
.totem-shell {
flex-direction: column;
}
.totem-sidebar {
width: 100%;
flex-basis: auto;
}
.totem-topbar {
grid-template-columns: 1fr;
}
.totem-hero {
padding: 30px 24px;
}
.totem-signal-wrap {
position: relative;
right: auto;
top: auto;
margin-top: 24px;
}
.totem-shell .totem-row {
grid-template-columns: 1fr;
}
.totem-queue-head {
display: none;
}
.totem-queue-row {
grid-template-columns: 1fr;
gap: 7px;
border-bottom: 1px solid rgba(174, 183, 204, 0.2);
}
.totem-risk-row {
grid-template-columns: 40px minmax(0, 1fr);
}
.totem-risk-row .totem-pill,
.totem-risk-row .totem-sparkline {
grid-column: 2;
}
.totem-panel-title {
font-size: 27px;
}
.totem-risk-body b {
font-size: 28px;
}
}
"""
HEAD = """
"""
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.",
}
DASHBOARD_STATE_KEYS = (
"project_name",
"workbook_loaded",
"analysis_status",
"last_analysis_at",
"totem_signal",
"metrics",
"metric_history",
"revision_queue",
"risk_clusters",
)
def esc(value) -> str:
"""HTML-escape UI text payloads safely."""
return html.escape(str(value or ""))
def _state_bool(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
low = value.strip().lower()
if low in {"1", "true", "yes", "y", "on"}:
return True
if low in {"0", "false", "no", "n", "off", "", "none", "null"}:
return False
return bool(value)
def _state_float(value, default: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(default)
def compute_totem_signal(
metrics: dict,
workbook_loaded: bool,
analysis_timestamp: str | None,
) -> int:
"""
Hero gauge contract from the design manual:
- 0 when no workbook is loaded.
- 35 when workbook is loaded but analysis has not run yet.
- Otherwise weighted metric blend, clamped 0..100.
"""
if not _state_bool(workbook_loaded):
return 0
if not analysis_timestamp:
return 35
weights = {
"overall_publishability": 0.30,
"read_aloud_flow": 0.15,
"emotional_truth": 0.20,
"visual_strength": 0.20,
"commercial_viability": 0.15,
}
total = 0.0
metric_map = metrics or {}
for key, weight in weights.items():
total += _state_float(metric_map.get(key, 0.0), 0.0) * weight
return max(0, min(100, int(round(total))))
def get_initial_dashboard_state() -> dict:
"""
Stage 2 state contract.
Placeholder values are deliberate before first analysis run.
"""
metrics = {
"overall_publishability": 0,
"read_aloud_flow": 0,
"emotional_truth": 0,
"visual_strength": 0,
"commercial_viability": 0,
}
return {
"project_name": "Editorial Workspace",
"workbook_loaded": False,
"analysis_status": "idle", # idle | ready | running | complete | error
"last_analysis_at": None,
"totem_signal": compute_totem_signal(metrics, workbook_loaded=False, analysis_timestamp=None),
"metrics": metrics,
"metric_history": {
"overall_publishability": [],
"read_aloud_flow": [],
"emotional_truth": [],
"visual_strength": [],
"commercial_viability": [],
},
"revision_queue": [],
"risk_clusters": [],
}
def normalize_dashboard_state(raw_existing_outputs) -> dict:
"""
Boundary adapter that normalizes scattered callback outputs into the Stage 2 state contract.
This adapter is UI-boundary only and does not alter extractor/scoring logic internals.
"""
state = get_initial_dashboard_state()
if raw_existing_outputs is None:
return state
if not isinstance(raw_existing_outputs, dict):
return state
state["project_name"] = str(raw_existing_outputs.get("project_name") or state["project_name"])
state["workbook_loaded"] = _state_bool(raw_existing_outputs.get("workbook_loaded", state["workbook_loaded"]))
status = str(raw_existing_outputs.get("analysis_status") or state["analysis_status"]).strip().lower()
if status not in {"idle", "ready", "running", "complete", "error"}:
status = state["analysis_status"]
state["analysis_status"] = status
ts_value = raw_existing_outputs.get("last_analysis_at")
state["last_analysis_at"] = str(ts_value).strip() if ts_value else None
incoming_metrics = raw_existing_outputs.get("metrics")
if isinstance(incoming_metrics, dict):
for key in state["metrics"]:
state["metrics"][key] = int(round(_state_float(incoming_metrics.get(key, state["metrics"][key]))))
state["metrics"][key] = max(0, min(100, state["metrics"][key]))
incoming_history = raw_existing_outputs.get("metric_history")
if isinstance(incoming_history, dict):
normalized_history = {}
for key in state["metric_history"].keys():
values = incoming_history.get(key, [])
if isinstance(values, list):
normalized_history[key] = [
max(0, min(100, int(round(_state_float(v)))))
for v in values
]
else:
normalized_history[key] = []
state["metric_history"] = normalized_history
revision_queue = raw_existing_outputs.get("revision_queue")
if isinstance(revision_queue, list):
state["revision_queue"] = revision_queue
risk_clusters = raw_existing_outputs.get("risk_clusters")
if isinstance(risk_clusters, list):
state["risk_clusters"] = risk_clusters
if "totem_signal" in raw_existing_outputs:
explicit = int(round(_state_float(raw_existing_outputs.get("totem_signal"), state["totem_signal"])))
state["totem_signal"] = max(0, min(100, explicit))
else:
state["totem_signal"] = compute_totem_signal(
state["metrics"],
workbook_loaded=state["workbook_loaded"],
analysis_timestamp=state["last_analysis_at"],
)
return state
def _dashboard_state_contract_smoke_test() -> tuple[bool, list[str]]:
state = get_initial_dashboard_state()
missing = [key for key in DASHBOARD_STATE_KEYS if key not in state]
return len(missing) == 0, missing
def _clean_path(uploaded_file) -> Path:
return workbook_path(uploaded_file)
def render_sidebar(active: str = "Home") -> str:
nav_labels = [
"Home",
"Projects",
"Workbook Upload",
"TOTEM Analytics",
"Revision Queue",
"Risk Clusters",
"Codex Extractor",
"Export",
]
active_label = str(active or "").strip()
items = []
for label in nav_labels:
classes = ["totem-nav-item"]
attrs = []
if label == active_label:
classes.append("active")
if label == "Codex Extractor":
classes.append("js-open-codex")
attrs.append('role="button"')
attrs.append('tabindex="0"')
attrs.append('aria-label="Open Codex Extractor tab"')
class_attr = " ".join(classes)
extra_attrs = f" {' '.join(attrs)}" if attrs else ""
items.append(f'
{esc(label)}
')
return f"""
"""
def render_topbar(state: dict) -> str:
project_name = esc(state.get("project_name", "Editorial Workspace"))
return f"""
Dashboard
Codex Extractor
Smoke Signal
Search projects, workbooks, blocks...
{project_name}
๐
T
TOTEM Studio
"""
def render_signal_gauge(value: int) -> str:
clamped = max(0, min(100, int(value or 0)))
circumference = 427.26
progress = clamped / 100.0
dash_offset = circumference * (1 - progress)
return f"""
"""
def render_hero(state: dict) -> str:
signal = int(state.get("totem_signal", 0) or 0)
return f"""
TOTEM Studio
Data-driven insight for stronger stories.
{render_signal_gauge(signal)}
"""
def render_status_row(state: dict) -> str:
status = str(state.get("analysis_status", "idle") or "idle").strip().lower()
text_by_status = {
"idle": "Dashboard ready. Click Run TOTEM Analysis to refresh metrics.",
"ready": "Dashboard ready. Click Run TOTEM Analysis to refresh metrics.",
"running": "TOTEM analysis running. Updating dashboard metrics...",
"complete": "TOTEM analysis complete. Dashboard metrics updated.",
"error": "TOTEM analysis error. Review logs and retry.",
}
css_state = status if status in {"ready", "running", "complete", "error"} else "ready"
message = esc(text_by_status.get(status, text_by_status["ready"]))
return f"""
{message}
Run TOTEM Analysis
"""
def _metric_bar_heights(score: int, history_values: list) -> list[int]:
history = []
if isinstance(history_values, list):
for v in history_values:
history.append(max(0, min(100, int(round(_state_float(v, score))))))
if len(history) >= 12:
values = history[-12:]
else:
# Deterministic fallback wave around current score when no/short history is available.
base = max(0, min(100, int(score)))
offsets = [-16, -10, -6, -4, -2, 0, 2, 4, 6, 8, 10, 12]
values = [max(0, min(100, base + off)) for off in offsets]
if history:
values[: len(history)] = history
return [max(6, min(34, int(round(6 + (v * 0.28))))) for v in values]
def render_metric_cards(state: dict) -> str:
metrics = state.get("metrics", {}) if isinstance(state, dict) else {}
history = state.get("metric_history", {}) if isinstance(state, dict) else {}
card_defs = [
("overall_publishability", "Overall Publishability", "โฆ", "var(--totem-gold)", "Source: viability lens"),
("read_aloud_flow", "Read-Aloud Flow", "โ", "var(--totem-pink)", "Weakest live pressure"),
("emotional_truth", "Emotional Truth", "โค", "var(--totem-emerald)", "Strongest story signal"),
("visual_strength", "Visual Strength", "โ", "var(--totem-cyan)", "Drawable page value"),
("commercial_viability", "Commercial Viability", "โ", "var(--totem-violet)", "Publisher-facing lens"),
]
cards = []
for key, label, icon, color, hint in card_defs:
score = max(0, min(100, int(round(_state_float(metrics.get(key, 0), 0)))))
bars = _metric_bar_heights(score, history.get(key, []) if isinstance(history, dict) else [])
bars_html = "".join(f" " for h in bars)
cards.append(
f"""
{score}/100
{bars_html}
{esc(hint)}
"""
)
return f"""
"""
def _risk_tone(value: str) -> tuple[str, str]:
text = str(value or "").strip().lower()
if "high" in text:
return "tone-high", "High Risk"
if "low" in text:
return "tone-low", "Low Risk"
return "tone-medium", "Medium Risk"
def _priority_tone(value: str, gate: str) -> tuple[str, str]:
raw = str(value or "").strip().lower()
if not raw:
gate_value = str(gate or "").strip().lower()
if "hard fail" in gate_value:
raw = "high"
elif "soft fail" in gate_value:
raw = "medium"
else:
raw = "low"
if raw.startswith("high"):
return "tone-high", "High"
if raw.startswith("low"):
return "tone-low", "Low"
return "tone-medium", "Medium"
def _sparkline_values(values, tone_class: str) -> list[int]:
parsed: list[int] = []
if isinstance(values, list):
for value in values:
try:
parsed.append(int(round(float(value))))
except Exception:
continue
if not parsed:
if tone_class == "tone-high":
parsed = [22, 30, 19, 34, 28, 37, 24, 31, 27]
elif tone_class == "tone-low":
parsed = [9, 13, 8, 15, 10, 14, 9, 13, 11]
else:
parsed = [14, 18, 12, 20, 16, 22, 14, 19, 16]
if len(parsed) < 8:
parsed.extend(parsed[-1:] * (8 - len(parsed)))
parsed = parsed[:10]
return [max(6, min(40, v)) for v in parsed]
def render_revision_queue(state: dict) -> str:
items = state.get("revision_queue", []) if isinstance(state, dict) else []
rows_html: list[str] = []
if isinstance(items, list):
for item in items[:10]:
if not isinstance(item, dict):
continue
block = item.get("block") or item.get("Block") or item.get("stanza_id") or item.get("sequence") or "Block"
weakest = (
item.get("weakest_dimension")
or item.get("weakestDimension")
or item.get("metric")
or item.get("Priority Fix")
or "Read-aloud Flow"
)
gate = item.get("gate") or item.get("Gate") or "Soft Fail"
priority_source = item.get("priority") or item.get("Priority") or ""
tone_class, priority_label = _priority_tone(priority_source, gate)
action = (
item.get("recommended_action")
or item.get("recommendedAction")
or item.get("Next action")
or REVISION_ACTIONS.get(str(weakest), "Revise this block before the next pass.")
)
rows_html.append(
f"""
{esc(block)}
{esc(weakest)}
{esc(gate)}
{esc(priority_label)}
{esc(action)}
"""
)
live_count = len(rows_html)
if not rows_html:
body = 'No priority revisions yet. Run analysis to generate the queue.
'
else:
body = f"""
Block
Weakest Dimension
Gate
Priority
Recommended Action
{''.join(rows_html)}
"""
return f"""
Revision Priority Queue {live_count} live item(s)
{body}
"""
def render_risk_clusters(state: dict) -> str:
clusters = state.get("risk_clusters", []) if isinstance(state, dict) else []
rows_html: list[str] = []
if isinstance(clusters, list):
for item in clusters[:10]:
if not isinstance(item, dict):
continue
name = item.get("name") or item.get("metric") or item.get("cluster") or "Risk Cluster"
description = item.get("description") or item.get("detail") or "No detail provided."
tone_class, risk_label = _risk_tone(item.get("risk") or item.get("level"))
icon = str(item.get("icon") or str(name)[:1] or "!")
bars = _sparkline_values(item.get("sparkline"), tone_class)
bars_html = "".join(f" " for height in bars)
rows_html.append(
f"""
{esc(icon[:1].upper())}
{esc(name)}
{esc(description)}
{esc(risk_label)}
{bars_html}
"""
)
if not rows_html:
body = 'No risk clusters detected.
'
else:
body = f'{"" .join(rows_html)}
'
return f"""
"""
def render_dashboard(state: dict) -> str:
"""
Stage 3/4 static shell wrapper.
Callback wiring remains untouched until later stages.
"""
s = normalize_dashboard_state(state)
return f"""
{render_sidebar(active='Home')}
{render_topbar(s)}
{render_hero(s)}
{render_status_row(s)}
{render_metric_cards(s)}
{render_revision_queue(s)}
{render_risk_clusters(s)}
"""
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 ๐ T TOTEMStudio
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 _extract_uploaded_path(file_obj) -> str | None:
"""Handle Gradio file payload variants and return a filesystem path."""
if file_obj is None:
return None
if isinstance(file_obj, str):
return file_obj
if isinstance(file_obj, dict):
return file_obj.get("path") or file_obj.get("name")
if hasattr(file_obj, "name"):
return file_obj.name
return None
def _normalise_lookup_key(value: str) -> str:
text = str(value or "").lower()
text = re.sub(r"[_\-]+", " ", text)
text = re.sub(r"[^a-z0-9 ]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _generate_codex_author_id(author_name: str) -> str:
"""
Deterministic fallback author ID when missing in catalogue.
Format: CA-<3 letters>-<3 digits>
"""
cleaned = re.sub(r"[^A-Za-z]", "", author_name or "").upper()
prefix = (cleaned[:3] or "AUT").ljust(3, "X")
digest = hashlib.md5((author_name or "").strip().lower().encode("utf-8")).hexdigest()
suffix = int(digest[:4], 16) % 1000
return f"CA-{prefix}-{suffix:03d}"
def _empty_catalogue_df() -> pd.DataFrame:
return pd.DataFrame(columns=["authour_id", "author_name", "title"])
def _load_codex_catalogue(path: Path = CODEX_CATALOGUE_PATH) -> pd.DataFrame:
"""
Load catalogue workbook with required columns:
`authour_id`, `author_name`, `title`
"""
if not path.exists():
return _empty_catalogue_df()
try:
raw = pd.read_excel(path)
except Exception:
return _empty_catalogue_df()
if raw is None or raw.empty:
return _empty_catalogue_df()
col_lookup = {str(col).strip().lower(): col for col in raw.columns}
id_col = col_lookup.get("authour_id") or col_lookup.get("author_id")
name_col = col_lookup.get("author_name")
title_col = col_lookup.get("title")
if name_col is None or title_col is None:
return _empty_catalogue_df()
if id_col is None:
raw["__authour_id"] = ""
id_col = "__authour_id"
cat = raw[[id_col, name_col, title_col]].copy()
cat.columns = ["authour_id", "author_name", "title"]
for col in ["authour_id", "author_name", "title"]:
cat[col] = cat[col].fillna("").astype(str).str.strip()
cat = cat[(cat["author_name"] != "") & (cat["title"] != "")]
return cat
def _match_catalogue_row(file_path: str, catalogue: pd.DataFrame) -> pd.Series | None:
if catalogue.empty:
return None
stem_key = _normalise_lookup_key(Path(file_path).stem)
if not stem_key:
return None
title_keys = catalogue["title"].map(_normalise_lookup_key)
exact = catalogue[title_keys == stem_key]
if not exact.empty:
return exact.iloc[0]
contains = catalogue[
title_keys.apply(lambda t: bool(t) and (t in stem_key or stem_key in t))
]
if not contains.empty:
return contains.assign(_key_len=contains["title"].map(lambda t: len(_normalise_lookup_key(t)))) \
.sort_values("_key_len", ascending=False) \
.iloc[0]
return None
def autofill_codex_details(
file_obj,
current_author_name: str,
current_author_id: str,
current_works: str,
) -> tuple[str, str, str, str]:
"""
Auto-populate author fields from data/codex_catalogue.xlsx on file upload.
Expected columns: authour_id, author_name, title.
"""
file_path = _extract_uploaded_path(file_obj)
if not file_path:
return (
current_author_name,
current_author_id or "CA-XXX",
current_works,
"Ready.",
)
catalogue = _load_codex_catalogue()
if catalogue.empty:
return (
current_author_name,
current_author_id or "CA-XXX",
current_works,
"No catalogue match: add rows to data/codex_catalogue.xlsx with authour_id, author_name, title.",
)
row = _match_catalogue_row(file_path, catalogue)
if row is None:
return (
current_author_name,
current_author_id or "CA-XXX",
current_works,
"No title match found in catalogue for this filename. You can still fill fields manually.",
)
author_name = str(row["author_name"]).strip()
author_id = str(row["authour_id"]).strip() or _generate_codex_author_id(author_name)
# Populate only the matched title to avoid confusion during extraction.
works_sampled = str(row["title"]).strip()
return (
author_name,
author_id,
works_sampled,
f"Auto-filled from catalogue: {author_name} ({author_id}).",
)
def run_codex_extraction(
file_obj,
author_name: str,
author_id: str,
works_sampled: str,
) -> tuple[str, 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.",
"",
"ERROR: No file uploaded.",
)
if not author_name.strip():
return (
"Please enter the author's full name before extracting.",
"",
"ERROR: Author name is required.",
)
# Gradio may pass a filepath string or a file-like payload depending on runtime.
file_path = _extract_uploaded_path(file_obj)
if not file_path:
return (
"Unable to read uploaded file path. Please re-upload and try again.",
"",
"ERROR: File payload missing path.",
)
try:
report, fp_dict = process_upload(
file_path=file_path,
author_name=author_name.strip(),
author_id=author_id.strip() or "CA-XXX",
works_sampled=works_sampled.strip(),
)
if not fp_dict:
return report, "", "ERROR: Extraction failed. See report for details."
# Format JSON output for workbook entry
json_out = json.dumps(fp_dict, indent=2)
status = f"SUCCESS: Fingerprint extracted ({fp_dict.get('Sample_Words', 0)} words analysed)."
return report, json_out, status
except Exception as e:
return (
f"Extraction error: {type(e).__name__}: {str(e)}",
"",
f"ERROR: {type(e).__name__}",
)
def clear_codex_form() -> tuple[None, str, str, str, str, str, str]:
"""Reset the Codex extraction form."""
return None, "", "CA-XXX", "", "", "", "Ready."
# โโ 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)
def initial_dashboard_html() -> str:
"""
Render dashboard shell at startup so sidebar/nav are visible immediately.
Falls back gracefully if workbook read fails.
"""
state = get_initial_dashboard_state()
state["workbook_loaded"] = bool(DEFAULT_WORKBOOK.exists())
state["analysis_status"] = "ready" if state["workbook_loaded"] else "idle"
state["project_name"] = "Editorial Workspace"
state["totem_signal"] = compute_totem_signal(
state["metrics"],
workbook_loaded=state["workbook_loaded"],
analysis_timestamp=state["last_analysis_at"],
)
return render_dashboard(state)
# โโ GRADIO INTERFACE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Blocks(title="TOTEM Studio") 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(value=initial_dashboard_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,
)
codex_status = gr.Textbox(
label="Extractor Status",
lines=2,
interactive=False,
value="Ready.",
placeholder="Status and extraction errors will appear here.",
)
gr.HTML("""
File requirements:
โข Selectable text preferred; scanned PDFs are OCR-processed automatically
โข 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.
""")
# โโ SMOKE SIGNAL TAB โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
smoke_signal_tab()
# โโ EVENT WIRING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Dashboard tab
# demo.load removed: user triggers load via Run TOTEM Analysis button
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_status],
trigger_mode="multiple",
)
# Auto-fill author metadata from catalogue on file upload.
codex_file.upload(
autofill_codex_details,
inputs=[codex_file, codex_author_name, codex_author_id, codex_works],
outputs=[codex_author_name, codex_author_id, codex_works, codex_status],
trigger_mode="always_last",
)
codex_clear_btn.click(
clear_codex_form,
outputs=[codex_file, codex_author_name, codex_author_id, codex_works,
codex_report, codex_json, codex_status],
)
if __name__ == "__main__":
state_ok, missing_keys = _dashboard_state_contract_smoke_test()
if state_ok:
print("[stage2] dashboard_state contract OK")
else:
print(f"[stage2] dashboard_state missing keys: {missing_keys}")
demo.launch(ssr_mode=False, css=CSS + TOTEM_CSS + SS_CSS, head=HEAD)