Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import hashlib | |
| 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; | |
| } | |
| } | |
| """ | |
| HEAD = """ | |
| <script> | |
| (() => { | |
| const switchToCodexTab = () => { | |
| const tabs = Array.from(document.querySelectorAll('[role="tab"]')); | |
| if (!tabs.length) return false; | |
| const target = tabs.find((tab) => /codex extractor/i.test((tab.textContent || "").trim())); | |
| if (target) { | |
| target.click(); | |
| return true; | |
| } | |
| if (tabs.length > 1) { | |
| tabs[1].click(); | |
| return true; | |
| } | |
| return false; | |
| }; | |
| const bindSidebarCodexLinks = () => { | |
| document.querySelectorAll('.js-open-codex').forEach((el) => { | |
| if (el.dataset.boundCodexNav === "1") return; | |
| el.dataset.boundCodexNav = "1"; | |
| el.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| switchToCodexTab(); | |
| }); | |
| el.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| switchToCodexTab(); | |
| } | |
| }); | |
| }); | |
| }; | |
| const observer = new MutationObserver(() => bindSidebarCodexLinks()); | |
| observer.observe(document.documentElement, { childList: true, subtree: true }); | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', bindSidebarCodexLinks); | |
| } else { | |
| bindSidebarCodexLinks(); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| REVISION_ACTIONS = { | |
| "Clarity": "Simplify the line and sharpen the subject/action.", | |
| "Rhythm": "Rework beat pattern and remove drag.", | |
| "Read-aloud Flow": "Run a speak-test pass and cut mouth knots.", | |
| "Emotional Truth": "Anchor the feeling in the child-facing moment.", | |
| "Visual Strength": "Sharpen the drawable page beat.", | |
| "Commercial Publishability": "Tighten hook, age fit, and list-readiness.", | |
| } | |
| def _clean_path(uploaded_file) -> Path: | |
| return workbook_path(uploaded_file) | |
| def _validate_workbook_path(path: Path) -> Path: | |
| if not path.exists(): | |
| raise gr.Error(f"Workbook path does not exist: {path}") | |
| if path.suffix.lower() not in {".xlsx", ".xlsm"}: | |
| raise gr.Error("Upload or load an Excel workbook: .xlsx or .xlsm.") | |
| return path | |
| def _score_summary(log_df: pd.DataFrame | None) -> str: | |
| if log_df is None or log_df.empty: | |
| return "No scored rows yet." | |
| scored = log_df[log_df["Weighted Score"].astype(str) != ""].copy() | |
| if scored.empty: | |
| return "No scored rows yet." | |
| scored["Weighted Score"] = pd.to_numeric(scored["Weighted Score"], errors="coerce") | |
| average = round(float(scored["Weighted Score"].mean()), 1) | |
| revisions = int((scored["Revision Flag"] == "Yes").sum()) | |
| return f"{len(scored)} scored rows. Average weighted score {average}/10. Revision flags {revisions}." | |
| def _metric_value(log_df: pd.DataFrame, metric: str, fallback: float) -> int: | |
| if log_df is not None and not log_df.empty and metric in log_df: | |
| values = pd.to_numeric(log_df[metric], errors="coerce").dropna() | |
| if not values.empty: | |
| return int(round(float(values.mean()) * 10)) | |
| return int(round(fallback * 10)) | |
| def _small_spark(value: int, tone: str) -> str: | |
| heights = [19, 23, 16, 18, 17, 20, 22, 30, 25, 29, 34, 27] | |
| color = "#3f8f2f" if tone == "green" else "#dda10c" if tone == "gold" else "#d84f45" | |
| bars = "".join(f"<i style='height:{h}px;background:{color}'></i>" for h in heights) | |
| return f"<div class='spark' aria-label='score trend {value}'>{bars}</div>" | |
| def _kpi_card(title: str, value: int, icon: str, tone: str, delta: str) -> str: | |
| return f""" | |
| <article class="kpi-card"> | |
| <div class="kpi-top"> | |
| <span class="kpi-icon {tone}">{icon}</span> | |
| <div><b>{escape(title)}</b><strong>{value}<em>/100</em></strong></div> | |
| </div> | |
| {_small_spark(value, tone)} | |
| <p class="{tone}">{escape(delta)}</p> | |
| </article> | |
| """ | |
| 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""" | |
| <div class="queue-row"> | |
| <span>{escape(block)}</span> | |
| <span>{escape(metric)}</span> | |
| <span>{escape(gate.title())}</span> | |
| <b class="{cls}">{priority}</b> | |
| <span>{escape(action)}</span> | |
| </div> | |
| """ | |
| ) | |
| 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""" | |
| <div class="queue-row"> | |
| <span>{escape(block)}</span> | |
| <span>{escape(metric)}</span> | |
| <span>Development Gate</span> | |
| <b class="medium">Medium</b> | |
| <span>{escape(action)}</span> | |
| </div> | |
| """ | |
| ) | |
| return "\n".join(rows) or "<p class='empty'>No revision rows found.</p>" | |
| 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""" | |
| <div class="risk-row"> | |
| <span class="risk-icon {tone}">{icons.get(metric, "!")}</span> | |
| <div><b>{escape(metric)}</b><small>{escape(detail)}</small></div> | |
| <em class="{tone}">{escape(label)}</em> | |
| <code>{escape(bars)}</code> | |
| </div> | |
| """ | |
| ) | |
| return "\n".join(cards) | |
| def _recent_workbooks(path: Path, overall: int) -> str: | |
| name = path.stem.replace("_", " ") | |
| return f""" | |
| <div class="book-card"> | |
| <div class="book-cover">TOTEM</div> | |
| <div> | |
| <b>{escape(name[:38])}</b> | |
| <small>Current workbook Β· Loaded now</small> | |
| <div class="progress"><i style="width:{max(8, min(overall, 100))}%"></i></div> | |
| </div> | |
| <strong>{overall}%</strong> | |
| </div> | |
| """ | |
| 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"<div class='notice'>{escape(notice)}</div>" if notice else "" | |
| return f""" | |
| <style> | |
| .studio-shell {{ | |
| min-height: 900px; | |
| display: grid; | |
| grid-template-columns: 250px 1fr; | |
| background: var(--studio-cream); | |
| }} | |
| .studio-sidebar {{ | |
| background: linear-gradient(180deg, #145b23 0%, #063f18 100%); | |
| color: white; | |
| padding: 22px 13px; | |
| }} | |
| .brand {{ | |
| padding: 4px 10px 22px 10px; | |
| }} | |
| .brand h1 {{ | |
| margin: 0; | |
| color: #f8e838; | |
| font-size: 28px; | |
| line-height: .82; | |
| text-shadow: 0 2px 0 #2b8c23; | |
| letter-spacing: 0; | |
| }} | |
| .brand small {{ | |
| display: block; | |
| margin-top: 13px; | |
| letter-spacing: 4px; | |
| font-size: 11px; | |
| }} | |
| .nav-item {{ | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 14px 13px; | |
| margin: 6px 0; | |
| border-radius: 8px; | |
| color: #eef6e8; | |
| font-weight: 700; | |
| }} | |
| .nav-item.active {{ | |
| background: linear-gradient(90deg, #f5c93c, #e5a721); | |
| color: white; | |
| }} | |
| .sidebar-foot {{ | |
| margin-top: 170px; | |
| border-top: 1px solid rgba(255,255,255,.12); | |
| padding: 24px 10px; | |
| color: #d9ead0; | |
| font-size: 13px; | |
| }} | |
| .studio-main {{ | |
| background: linear-gradient(180deg, #fffdf7 0%, #fff7de 42%, #fffaf0 100%); | |
| }} | |
| .topbar {{ | |
| height: 72px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 24px; | |
| padding: 0 28px; | |
| border-bottom: 1px solid var(--studio-line); | |
| }} | |
| .search {{ | |
| flex: 1; | |
| max-width: 640px; | |
| border: 1px solid var(--studio-line); | |
| border-radius: 999px; | |
| padding: 13px 18px; | |
| background: rgba(255,255,255,.78); | |
| color: #8d8d78; | |
| }} | |
| .profile {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| font-weight: 800; | |
| }} | |
| .avatar {{ | |
| width: 46px; | |
| height: 46px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #c8915e, #f3d3ad); | |
| display: grid; | |
| place-items: center; | |
| color: #5b2b13; | |
| }} | |
| .hero {{ | |
| position: relative; | |
| padding: 34px 36px 28px; | |
| min-height: 205px; | |
| overflow: hidden; | |
| }} | |
| .hero::before {{ | |
| content: ""; | |
| position: absolute; | |
| inset: 44px 0 0 0; | |
| background: radial-gradient(circle at 72% 32%, rgba(246,207,80,.28), transparent 18%), | |
| radial-gradient(circle at 86% 65%, rgba(89,145,49,.12), transparent 20%), | |
| linear-gradient(160deg, transparent 0 35%, rgba(238,192,66,.18) 36% 52%, transparent 53%); | |
| }} | |
| .hero h2 {{ | |
| position: relative; | |
| margin: 0; | |
| font-family: Georgia, serif; | |
| font-size: 44px; | |
| color: #0e4a1d; | |
| letter-spacing: 0; | |
| }} | |
| .hero p {{ | |
| position: relative; | |
| margin: 8px 0 22px; | |
| font-size: 20px; | |
| color: #355b35; | |
| }} | |
| .mascot {{ | |
| position: absolute; | |
| right: 88px; | |
| top: 10px; | |
| width: 156px; | |
| height: 156px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 48% 43%, #fff7db 0 18%, #e7b96c 19% 48%, #3f8f2f 49% 61%, transparent 62%), | |
| radial-gradient(circle at 32% 11%, #f6d08f 0 13%, transparent 14%), | |
| radial-gradient(circle at 70% 8%, #f6d08f 0 13%, transparent 14%); | |
| box-shadow: 0 15px 35px rgba(120,88,24,.18); | |
| }} | |
| .content {{ | |
| padding: 0 28px 34px; | |
| }} | |
| .kpi-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(5, minmax(150px, 1fr)); | |
| gap: 14px; | |
| }} | |
| .kpi-card, .panel {{ | |
| background: rgba(255,255,255,.88); | |
| border: 1px solid var(--studio-line); | |
| border-radius: 8px; | |
| box-shadow: 0 10px 28px rgba(58,44,14,.06); | |
| }} | |
| .kpi-card {{ | |
| padding: 18px; | |
| }} | |
| .kpi-top {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| }} | |
| .kpi-icon {{ | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| color: white; | |
| font-weight: 900; | |
| font-size: 24px; | |
| }} | |
| .green {{ color: #348426; }} | |
| .gold {{ color: #d69200; }} | |
| .red {{ color: #d84f45; }} | |
| .kpi-icon.green {{ background: #4e982f; color: white; }} | |
| .kpi-icon.gold {{ background: #e6a500; color: white; }} | |
| .kpi-icon.red {{ background: #d84f45; color: white; }} | |
| .kpi-card b {{ | |
| display: block; | |
| font-size: 13px; | |
| color: var(--studio-ink); | |
| }} | |
| .kpi-card strong {{ | |
| display: block; | |
| color: var(--studio-ink); | |
| font-size: 34px; | |
| line-height: 1; | |
| }} | |
| .kpi-card em {{ | |
| font-size: 13px; | |
| font-style: normal; | |
| color: var(--studio-muted); | |
| }} | |
| .spark {{ | |
| height: 42px; | |
| display: flex; | |
| align-items: end; | |
| gap: 8px; | |
| margin: 12px 0 5px; | |
| }} | |
| .spark i {{ | |
| width: 4px; | |
| border-radius: 4px; | |
| }} | |
| .kpi-card p {{ | |
| margin: 0; | |
| font-size: 12px; | |
| background: transparent !important; | |
| }} | |
| .dashboard-grid {{ | |
| display: grid; | |
| grid-template-columns: 1.55fr 1fr; | |
| gap: 14px; | |
| margin-top: 16px; | |
| }} | |
| .panel h3 {{ | |
| margin: 0; | |
| padding: 16px 18px; | |
| border-bottom: 1px solid var(--studio-line); | |
| color: var(--studio-ink); | |
| }} | |
| .queue-row {{ | |
| display: grid; | |
| grid-template-columns: 1.15fr .9fr .85fr .55fr 1.35fr; | |
| gap: 10px; | |
| align-items: center; | |
| padding: 12px 18px; | |
| border-bottom: 1px solid #f0e7cf; | |
| font-size: 13px; | |
| }} | |
| .queue-row b, .risk-row em {{ | |
| text-align: center; | |
| border-radius: 999px; | |
| padding: 5px 9px; | |
| font-style: normal; | |
| }} | |
| .high {{ background: #ffe3da; color: var(--studio-red); }} | |
| .medium {{ background: #fff0c5; color: #c28600; }} | |
| .low {{ background: #e9f5dc; color: #4b8e2e; }} | |
| .risk-row {{ | |
| display: grid; | |
| grid-template-columns: 54px 1fr 96px 86px; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 14px 18px; | |
| border-bottom: 1px solid #f0e7cf; | |
| }} | |
| .risk-icon {{ | |
| width: 46px; | |
| height: 46px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 50%; | |
| color: white; | |
| font-weight: 900; | |
| background: #e6a500; | |
| }} | |
| .risk-icon.high {{ background: #d84f45; color: white; }} | |
| .risk-icon.medium {{ background: #e6a500; color: white; }} | |
| .risk-row small {{ | |
| display: block; | |
| color: var(--studio-muted); | |
| margin-top: 4px; | |
| }} | |
| .risk-row code {{ | |
| color: #d84f45; | |
| font-size: 24px; | |
| letter-spacing: 1px; | |
| }} | |
| .bottom-grid {{ | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 14px; | |
| margin-top: 16px; | |
| }} | |
| .book-card {{ | |
| display: grid; | |
| grid-template-columns: 58px 1fr 48px; | |
| align-items: center; | |
| gap: 14px; | |
| padding: 14px 18px; | |
| }} | |
| .book-cover {{ | |
| width: 54px; | |
| height: 76px; | |
| display: grid; | |
| place-items: center; | |
| border-radius: 6px; | |
| background: linear-gradient(160deg, #072d18, #135b25); | |
| color: #f2d444; | |
| font-weight: 900; | |
| font-size: 11px; | |
| }} | |
| .book-card small {{ | |
| display: block; | |
| color: var(--studio-muted); | |
| margin: 5px 0 12px; | |
| }} | |
| .progress {{ | |
| height: 7px; | |
| border-radius: 999px; | |
| background: #eadfbd; | |
| }} | |
| .progress i {{ | |
| display: block; | |
| height: 7px; | |
| border-radius: 999px; | |
| background: #4e982f; | |
| }} | |
| .snapshot {{ | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 8px; | |
| padding: 18px; | |
| }} | |
| .snapshot div {{ | |
| border-right: 1px solid var(--studio-line); | |
| min-height: 82px; | |
| }} | |
| .snapshot div:last-child {{ | |
| border-right: none; | |
| }} | |
| .snapshot small {{ | |
| color: var(--studio-muted); | |
| display: block; | |
| }} | |
| .snapshot b {{ | |
| display: block; | |
| margin-top: 9px; | |
| }} | |
| .snapshot strong {{ | |
| font-size: 28px; | |
| }} | |
| .notice {{ | |
| margin: 0 28px 16px; | |
| padding: 12px 14px; | |
| background: #e9f5dc; | |
| border: 1px solid #c5ddb5; | |
| border-radius: 8px; | |
| font-weight: 700; | |
| }} | |
| @media (max-width: 1100px) {{ | |
| .studio-shell {{ grid-template-columns: 1fr; }} | |
| .studio-sidebar {{ display: none; }} | |
| .kpi-grid, .dashboard-grid, .bottom-grid {{ grid-template-columns: 1fr; }} | |
| .mascot {{ display: none; }} | |
| .queue-row {{ grid-template-columns: 1fr; }} | |
| }} | |
| </style> | |
| <div class="studio-shell"> | |
| <aside class="studio-sidebar"> | |
| <div class="brand"> | |
| <h1>TOTEM<br>Studio</h1> | |
| <small>PUBLISHING INTERFACE</small> | |
| </div> | |
| <div class="nav-item active">β Home</div> | |
| <div class="nav-item">β£ Projects</div> | |
| <div class="nav-item">β€ Workbook Upload</div> | |
| <div class="nav-item">β₯ TOTEM Analytics</div> | |
| <div class="nav-item">β· Revision Queue</div> | |
| <div class="nav-item">⬑ Risk Clusters</div> | |
| <div class="nav-item js-open-codex" role="button" tabindex="0" aria-label="Open Codex Extractor tab">β Codex Extractor</div> | |
| <div class="nav-item">β© Export</div> | |
| <div class="sidebar-foot">Knowledge. Structure.<br>Story. Performance.</div> | |
| </aside> | |
| <main class="studio-main"> | |
| <div class="topbar"> | |
| <div class="search">Search projects, workbooks, blocks...</div> | |
| <div class="profile"><span>Editorial Workspace</span><span>π</span><span class="avatar">T</span><span>TOTEM<br><small>Studio</small></span></div> | |
| </div> | |
| <section class="hero"> | |
| <h2>TOTEM Studio.</h2> | |
| <p>Data-driven insight for stronger stories.</p> | |
| <div class="mascot" aria-label="TOTEM Studio mascot"></div> | |
| </section> | |
| {notice_block} | |
| <section class="content"> | |
| <div class="kpi-grid"> | |
| {_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")} | |
| </div> | |
| <div class="dashboard-grid"> | |
| <section class="panel"> | |
| <h3>Revision Priority Queue <small>{len(log_df) if log_df is not None else 0} live item(s)</small></h3> | |
| <div class="queue-row header"><b>Block</b><b>Weakest Dimension</b><b>Gate</b><b>Priority</b><b>Recommended Action</b></div> | |
| {_revision_rows(log_df, tracker_df)} | |
| </section> | |
| <section class="panel"> | |
| <h3>Risk Clusters</h3> | |
| {_risk_cards(log_df)} | |
| </section> | |
| </div> | |
| <div class="bottom-grid"> | |
| <section class="panel"> | |
| <h3>Recent Workbook</h3> | |
| {_recent_workbooks(path, overall)} | |
| </section> | |
| <section class="panel"> | |
| <h3>TOTEM Snapshot <small>Based on latest run</small></h3> | |
| <div class="snapshot"> | |
| <div><small>Weakest Dimension</small><b>{escape(weakest)}</b><strong>{read_flow}</strong><small>/100</small></div> | |
| <div><small>Strongest Dimension</small><b>{escape(strongest)}</b><strong>{max(emotional, visual)}</strong><small>/100</small></div> | |
| <div><small>Next Work</small><b>{escape(next_item[:42])}</b><small>{escape(viability_summary)}</small></div> | |
| </div> | |
| </section> | |
| </div> | |
| <p style="color:#6d725f;font-size:12px;margin:18px 0 0;">Loaded {escape(path.name)} Β· {overview['sheet_count']} sheets read privately Β· matrix hidden from the product surface.</p> | |
| </section> | |
| </main> | |
| </div> | |
| """ | |
| # ββ 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. | |
| """ | |
| try: | |
| return dashboard_html(DEFAULT_WORKBOOK, "Dashboard ready. Click Run TOTEM Analysis to refresh metrics.") | |
| except Exception as exc: | |
| return ( | |
| "<div style='padding:24px;font-family:system-ui,sans-serif'>" | |
| "<h3 style='margin:0 0 8px'>TOTEM Dashboard</h3>" | |
| "<p style='margin:0 0 12px;color:#666'>" | |
| "Dashboard shell is ready. Click <b>Run TOTEM Analysis</b> to load workbook data." | |
| "</p>" | |
| f"<p style='margin:0;color:#888;font-size:12px'>Startup note: {escape(str(exc))}</p>" | |
| "</div>" | |
| ) | |
| # ββ 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("<div class='score-card'><h3>Live Score A Block</h3></div>") | |
| 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(""" | |
| <div style=" | |
| background: linear-gradient(135deg, #0e4a1d, #17642a); | |
| border-radius: 8px; | |
| padding: 20px 24px; | |
| margin-bottom: 16px; | |
| "> | |
| <h3 style="margin:0; color:#f8e838; font-family:Georgia,serif; font-size:20px;"> | |
| Codex Fingerprint Extractor | |
| </h3> | |
| <p style="margin:8px 0 0; color:#d9ead0; font-size:13px;"> | |
| 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. | |
| </p> | |
| </div> | |
| """) | |
| 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(""" | |
| <div style=" | |
| background: #fff8e1; | |
| border: 1px solid #ffe082; | |
| border-radius: 6px; | |
| padding: 12px 14px; | |
| margin-top: 12px; | |
| font-size: 13px; | |
| color: #5d4037; | |
| "> | |
| <b>File requirements:</b><br> | |
| β’ Selectable text preferred; scanned PDFs are OCR-processed automatically<br> | |
| β’ Minimum 1,000 words for HIGH confidence fingerprint<br> | |
| β’ Combine multiple works in one file to increase sample size<br> | |
| β’ Visual-primary books (Van Allsburg, Jeffers) will flag LOW confidence | |
| </div> | |
| """) | |
| 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(""" | |
| <div style=" | |
| background: #e8f5e9; | |
| border: 1px solid #a5d6a7; | |
| border-radius: 6px; | |
| padding: 14px 18px; | |
| margin-top: 8px; | |
| font-size: 13px; | |
| "> | |
| <b style="color:#1b5e20;">Tier 2 reminder:</b> | |
| <span style="color:#2e7d32;"> | |
| 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. | |
| </span> | |
| </div> | |
| """) | |
| # ββ 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__": | |
| demo.launch(ssr_mode=False, css=CSS + SS_CSS, head=HEAD) | |