Codex_Extractor / app.py
Pointf5ive's picture
Fix Codex tab visibility and catalogue autofill
df9f2bb verified
Raw History Blame
45.7 kB
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 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; } .gradio-container [role="tab"] { opacity: 1 !important; visibility: visible !important; } #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; } } """ 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">β—ˆ 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) by_author = catalogue[ catalogue["author_name"].map(_normalise_lookup_key) == _normalise_lookup_key(author_name) ] if author_id: by_id = catalogue[ catalogue["authour_id"].fillna("").astype(str).str.strip() == author_id ] if not by_id.empty: by_author = by_id works = [w for w in by_author["title"].astype(str).str.strip().tolist() if w] works_sampled = ", ".join(pd.Series(works).drop_duplicates().tolist()) if not works_sampled: 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) # ── GRADIO INTERFACE ────────────────────────────────────────────────────────── with gr.Blocks(title="TOTEM Studio", css=CSS) as demo: active_path = gr.State(str(DEFAULT_WORKBOOK)) log_state = gr.State(pd.DataFrame(columns=LOG_COLUMNS)) with gr.Tabs(): # ── TAB 1: DASHBOARD ───────────────────────────────────────────────── with gr.TabItem("Dashboard"): dashboard = gr.HTML() with gr.Row(elem_id="studio-actions"): with gr.Column(elem_classes=["wrap"]): workbook_upload = gr.UploadButton( "Upload Workbook", file_types=[".xlsx", ".xlsm"], type="filepath", variant="primary", scale=1, ) run_button = gr.Button("Run TOTEM Analysis", variant="secondary", scale=1) with gr.Row(elem_id="path-panel"): with gr.Column(elem_classes=["wrap"]): path_input = gr.Textbox(label="Local workbook path", value=ORIGINAL_WORKBOOK_PATH) path_button = gr.Button("Load Local Path", variant="primary") with gr.Accordion("Private scoring controls", open=False, elem_id="score-panel"): gr.HTML("<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> """) # ── EVENT WIRING ───────────────────────────────────────────────────────── # Dashboard tab demo.load(load_workbook, outputs=[active_path, dashboard, log_state, score_status]) workbook_upload.upload(load_uploaded, inputs=[workbook_upload], outputs=[active_path, dashboard, log_state, score_status]) run_button.click(run_analysis, inputs=[active_path], outputs=[dashboard, log_state, score_status]) path_button.click(load_local_path, inputs=[path_input], outputs=[active_path, dashboard, log_state, score_status]) recalc_button.click(recalc_log, inputs=[log_state, active_path], outputs=[dashboard, log_state, score_status]) export_button.click(export_log, inputs=[log_state, active_path], outputs=[exported_file]) single_button.click( single_score, inputs=[active_path, sequence, stanza_id, draft_pass, clarity, rhythm, flow, emotional_truth, visual_strength, commercial, notes], outputs=[single_df, score_status], ) # Codex Extractor tab codex_extract_btn.click( run_codex_extraction, inputs=[codex_file, codex_author_name, codex_author_id, codex_works], outputs=[codex_report, codex_json, codex_status], queue=False, 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], queue=False, 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()
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
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;
}
.gradio-container [role="tab"] {
opacity: 1 !important;
visibility: visible !important;
}
#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 {