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"""
Search projects, workbooks, blocks...
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)
{_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)