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 = """ """ REVISION_ACTIONS = { "Clarity": "Simplify the line and sharpen the subject/action.", "Rhythm": "Rework beat pattern and remove drag.", "Read-aloud Flow": "Run a speak-test pass and cut mouth knots.", "Emotional Truth": "Anchor the feeling in the child-facing moment.", "Visual Strength": "Sharpen the drawable page beat.", "Commercial Publishability": "Tighten hook, age fit, and list-readiness.", } def _clean_path(uploaded_file) -> Path: return workbook_path(uploaded_file) def _validate_workbook_path(path: Path) -> Path: if not path.exists(): raise gr.Error(f"Workbook path does not exist: {path}") if path.suffix.lower() not in {".xlsx", ".xlsm"}: raise gr.Error("Upload or load an Excel workbook: .xlsx or .xlsm.") return path def _score_summary(log_df: pd.DataFrame | None) -> str: if log_df is None or log_df.empty: return "No scored rows yet." scored = log_df[log_df["Weighted Score"].astype(str) != ""].copy() if scored.empty: return "No scored rows yet." scored["Weighted Score"] = pd.to_numeric(scored["Weighted Score"], errors="coerce") average = round(float(scored["Weighted Score"].mean()), 1) revisions = int((scored["Revision Flag"] == "Yes").sum()) return f"{len(scored)} scored rows. Average weighted score {average}/10. Revision flags {revisions}." def _metric_value(log_df: pd.DataFrame, metric: str, fallback: float) -> int: if log_df is not None and not log_df.empty and metric in log_df: values = pd.to_numeric(log_df[metric], errors="coerce").dropna() if not values.empty: return int(round(float(values.mean()) * 10)) return int(round(fallback * 10)) def _small_spark(value: int, tone: str) -> str: heights = [19, 23, 16, 18, 17, 20, 22, 30, 25, 29, 34, 27] color = "#3f8f2f" if tone == "green" else "#dda10c" if tone == "gold" else "#d84f45" bars = "".join(f"" for h in heights) return f"
{bars}
" def _kpi_card(title: str, value: int, icon: str, tone: str, delta: str) -> str: return f"""
{icon}
{escape(title)}{value}/100
{_small_spark(value, tone)}

{escape(delta)}

""" def _priority_badge(gate: str) -> tuple[str, str]: if gate in {"HARD FAIL", "SOFT FAIL", "READ-ALOUD BLOCK"}: return "High", "high" if gate in {"COMMERCIAL CHECK", "REVISE"}: return "Medium", "medium" return "Low", "low" def _revision_rows(log_df: pd.DataFrame, tracker_df: pd.DataFrame) -> str: rows = [] if log_df is not None and not log_df.empty: working = log_df.copy() working["Weighted Score"] = pd.to_numeric(working["Weighted Score"], errors="coerce") working = working.sort_values(["Revision Flag", "Weighted Score"], ascending=[False, True]) for _, row in working.head(5).iterrows(): metric = str(row.get("Priority Fix") or "Read-aloud Flow") gate = str(row.get("Gate") or "REVISE") priority, cls = _priority_badge(gate) block = str(row.get("Stanza ID") or row.get("Sequence") or "Live block") action = REVISION_ACTIONS.get(metric, "Revise the weakest pressure point first.") rows.append( f"""
{escape(block)} {escape(metric)} {escape(gate.title())} {priority} {escape(action)}
""" ) if len(rows) < 5 and tracker_df is not None and not tracker_df.empty: for _, row in tracker_df.head(5 - len(rows)).iterrows(): block = str(row.get("Block", "Block")) metric = str(row.get("TOTEM priority", "Read-aloud Flow")) action = str(row.get("Next action", REVISION_ACTIONS.get(metric, "Continue next pass."))) rows.append( f"""
{escape(block)} {escape(metric)} Development Gate Medium {escape(action)}
""" ) return "\n".join(rows) or "

No revision rows found.

" def _risk_cards(log_df: pd.DataFrame) -> str: if log_df is None or log_df.empty: risks = [("Workbook Intake", "No scored rows detected yet.", "Medium Risk", "medium", "▁▂▃▂▁▂")] else: counts: dict[str, int] = {} for metric in METRICS: values = pd.to_numeric(log_df[metric], errors="coerce").dropna() weak = int((values < 7).sum()) if weak: counts[metric] = weak if not counts: counts = {"Commercial Publishability": 1} ordered = sorted(counts.items(), key=lambda item: item[1], reverse=True)[:3] risks = [] for metric, count in ordered: tone = "high" if count >= 2 else "medium" label = "High Risk" if tone == "high" else "Medium Risk" risks.append((metric, f"Detected under target in {count} scored block(s).", label, tone, "▂▅▃▇▁▆▂▅")) cards = [] icons = { "Read-aloud Flow": "≋", "Rhythm": "≋", "Visual Strength": "◉", "Emotional Truth": "♡", "Commercial Publishability": "↗", } for metric, detail, label, tone, bars in risks: cards.append( f"""
{icons.get(metric, "!")}
{escape(metric)}{escape(detail)}
{escape(label)} {escape(bars)}
""" ) return "\n".join(cards) def _recent_workbooks(path: Path, overall: int) -> str: name = path.stem.replace("_", " ") return f"""
TOTEM
{escape(name[:38])} Current workbook · Loaded now
{overall}%
""" def dashboard_html(path: Path, notice: str = "") -> str: path = _validate_workbook_path(path) overview = workbook_overview(path) log_df = score_log(path) viability_df, viability_summary = viability_table(path) tracker_df = manuscript_tracker_table(path) workstack_df = workstack_table(path) avg_viability = float(viability_df["Score"].mean()) if viability_df is not None and not viability_df.empty else 0 overall = int(round(avg_viability * 10)) if avg_viability else 0 read_flow = _metric_value(log_df, "Read-aloud Flow", 6.4) emotional = _metric_value(log_df, "Emotional Truth", 7.8) visual = _metric_value(log_df, "Visual Strength", 6.9) commercial = _metric_value(log_df, "Commercial Publishability", avg_viability or 7.1) weakest = "Read-aloud Flow" strongest = "Emotional Truth" if log_df is not None and not log_df.empty: metric_means = { metric: pd.to_numeric(log_df[metric], errors="coerce").dropna().mean() for metric in METRICS } metric_means = {metric: value for metric, value in metric_means.items() if pd.notna(value)} if metric_means: weakest = min(metric_means, key=metric_means.get) strongest = max(metric_means, key=metric_means.get) next_item = "" if workstack_df is not None and not workstack_df.empty and "Status" in workstack_df: active = workstack_df[workstack_df["Status"].isin(["Active", "Queued"])] if not active.empty: next_item = str(active.iloc[0].get("Next item", "Run next pass")) next_item = next_item or "Run the next TOTEM pass" notice_block = f"
{escape(notice)}
" if notice else "" return f"""
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. """ try: return dashboard_html(DEFAULT_WORKBOOK, "Dashboard ready. Click Run TOTEM Analysis to refresh metrics.") except Exception as exc: return ( "
" "

TOTEM Dashboard

" "

" "Dashboard shell is ready. Click Run TOTEM Analysis to load workbook data." "

" f"

Startup note: {escape(str(exc))}

" "
" ) # ── 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__": demo.launch(ssr_mode=False, css=CSS + SS_CSS, head=HEAD)