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
{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"""
TOTEM Signal {clamped}%
""" 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}
""" 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"""
{icon}
{esc(label)}
{score}/100
{bars_html}
{esc(hint)}
""" ) return f"""
{''.join(cards)}
""" 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(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"""

Risk Clusters

{body}
""" 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"""
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)

BlockWeakest DimensionGatePriorityRecommended Action
{_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)