from __future__ import annotations from pathlib import Path from tempfile import NamedTemporaryFile from typing import Any, Union import math import pandas as pd from openpyxl import load_workbook APP_ROOT = Path(__file__).resolve().parents[1] DEFAULT_WORKBOOK = APP_ROOT / "data" / "order69_macmillan_totem_rebuilt.xlsx" METRICS = [ "Clarity", "Rhythm", "Read-aloud Flow", "Emotional Truth", "Visual Strength", "Commercial Publishability", ] LOG_COLUMNS = [ "Sequence", "Stanza ID", "Draft / Pass", *METRICS, "Weighted Score", "Average", "Gate", "Revision Flag", "Priority Fix", "Notes", ] KEY_READ_SHEETS = [ "IDENTITY", "CANON", "VALUES", "STORY", "PITCH", "BRAND", "TONE", "SATIRE", "HANDOFF", "RECENT_CONTEXT", "CHAR_HENRY", ] UPLOAD_TYPES = Union[str, Path, Any] def workbook_path(uploaded_file: UPLOAD_TYPES | None = None) -> Path: if uploaded_file is None: return DEFAULT_WORKBOOK if isinstance(uploaded_file, (str, Path)): return Path(uploaded_file) if hasattr(uploaded_file, "name"): return Path(uploaded_file.name) return DEFAULT_WORKBOOK def _text(value: Any) -> str: if value is None: return "" if isinstance(value, float) and math.isnan(value): return "" return str(value).strip() def _number(value: Any) -> float | None: if value is None or value == "": return None if isinstance(value, float) and math.isnan(value): return None if isinstance(value, str) and value.startswith("="): return None try: return float(value) except (TypeError, ValueError): return None def _load(path: Path, data_only: bool = False): return load_workbook(path, data_only=data_only, read_only=False, keep_vba=path.suffix.lower() == ".xlsm") def table_from_sheet(path: Path, sheet_name: str, header_row: int, start_row: int | None = None) -> pd.DataFrame: wb = _load(path) ws = wb[sheet_name] start = start_row or header_row + 1 headers = [_text(ws.cell(header_row, col).value) for col in range(1, ws.max_column + 1)] rows: list[list[str]] = [] for row_index in range(start, ws.max_row + 1): row = [_text(ws.cell(row_index, col).value) for col in range(1, ws.max_column + 1)] if any(row): rows.append(row) width = max(len(headers), max((len(row) for row in rows), default=0)) headers = (headers + [f"Column {idx}" for idx in range(len(headers) + 1, width + 1)])[:width] normalized = [(row + [""] * width)[:width] for row in rows] df = pd.DataFrame(normalized, columns=headers) return df.loc[:, [col for col in df.columns if col]] def workbook_overview(path: Path) -> dict[str, Any]: wb = _load(path) sheets = [] for ws in wb.worksheets: nonempty = sum(1 for cell in ws._cells.values() if cell.value not in (None, "")) sheets.append( { "Sheet": ws.title, "Rows": ws.max_row, "Columns": ws.max_column, "Filled cells": nonempty, } ) chain = table_from_sheet(path, "Chain", 2) top_roles = chain.head(8).to_dict("records") if not chain.empty else [] return { "sheet_count": len(wb.sheetnames), "filled_cells": sum(row["Filled cells"] for row in sheets), "sheets": pd.DataFrame(sheets), "top_roles": top_roles, } def chain_table(path: Path) -> pd.DataFrame: return table_from_sheet(path, "Chain", 2) def protocol_table(path: Path) -> pd.DataFrame: df = table_from_sheet(path, "TOTEM_PROTOCOL", 5) if "Metric" in df.columns: df = df[df["Metric"].isin(METRICS)].copy() if "Weight" in df.columns: df["Weight"] = pd.to_numeric(df["Weight"], errors="coerce") return df def protocol_weights(path: Path) -> dict[str, float]: df = protocol_table(path) weights = {row["Metric"]: float(row["Weight"]) for _, row in df.iterrows() if row.get("Metric") in METRICS} if not weights: weights = { "Clarity": 0.20, "Rhythm": 0.15, "Read-aloud Flow": 0.20, "Emotional Truth": 0.15, "Visual Strength": 0.15, "Commercial Publishability": 0.15, } return weights def gate_for_scores(scores: dict[str, float], weights: dict[str, float]) -> dict[str, Any]: clean_scores = {metric: _number(scores.get(metric)) for metric in METRICS} present = {metric: score for metric, score in clean_scores.items() if score is not None} if not present: return { "Weighted Score": "", "Average": "", "Gate": "", "Revision Flag": "", "Priority Fix": "", } weighted = round(sum(float(present.get(metric, 0)) * weights.get(metric, 0) for metric in METRICS), 1) average = round(sum(present.values()) / len(present), 1) lowest_metric = min(present, key=lambda metric: present[metric]) lowest_score = present[lowest_metric] low_count = sum(1 for score in present.values() if score <= 6) rhythm = present.get("Rhythm") flow = present.get("Read-aloud Flow") commercial = present.get("Commercial Publishability") if lowest_score <= 4: gate = "HARD FAIL" elif low_count >= 2: gate = "SOFT FAIL" elif (rhythm is not None and rhythm < 7) or (flow is not None and flow < 7): gate = "READ-ALOUD BLOCK" elif commercial is not None and commercial < 7: gate = "COMMERCIAL CHECK" elif weighted >= 8 and lowest_score >= 7: gate = "GREENLIGHT" else: gate = "REVISE" return { "Weighted Score": weighted, "Average": average, "Gate": gate, "Revision Flag": "No" if gate == "GREENLIGHT" else "Yes", "Priority Fix": lowest_metric, } def score_log(path: Path) -> pd.DataFrame: wb = _load(path, data_only=False) ws = wb["TOTEM_LOG"] weights = protocol_weights(path) rows: list[dict[str, Any]] = [] for row_index in range(7, min(ws.max_row, 86) + 1): raw = { "Sequence": _text(ws.cell(row_index, 1).value), "Stanza ID": _text(ws.cell(row_index, 2).value), "Draft / Pass": _text(ws.cell(row_index, 3).value), "Clarity": _number(ws.cell(row_index, 4).value), "Rhythm": _number(ws.cell(row_index, 5).value), "Read-aloud Flow": _number(ws.cell(row_index, 6).value), "Emotional Truth": _number(ws.cell(row_index, 7).value), "Visual Strength": _number(ws.cell(row_index, 8).value), "Commercial Publishability": _number(ws.cell(row_index, 9).value), "Priority Fix": _text(ws.cell(row_index, 14).value), "Notes": _text(ws.cell(row_index, 15).value), } priority_cell = raw["Priority Fix"] priority_is_formula = priority_cell.startswith("=") has_user_content = any(raw.get(col) not in ("", None) for col in ["Sequence", "Stanza ID", "Draft / Pass", *METRICS, "Notes"]) has_user_content = has_user_content or bool(priority_cell and not priority_is_formula) if not has_user_content: continue calculated = gate_for_scores({metric: raw[metric] for metric in METRICS}, weights) if raw["Priority Fix"] and raw["Priority Fix"] not in METRICS and not priority_is_formula: raw["Notes"] = raw["Notes"] or raw["Priority Fix"] raw["Priority Fix"] = calculated["Priority Fix"] elif not raw["Priority Fix"] or priority_is_formula: raw["Priority Fix"] = calculated["Priority Fix"] raw.update( { "Weighted Score": calculated["Weighted Score"], "Average": calculated["Average"], "Gate": calculated["Gate"], "Revision Flag": calculated["Revision Flag"], } ) rows.append(raw) return pd.DataFrame(rows, columns=LOG_COLUMNS) def recalculate_log(log_df: pd.DataFrame | None, path: Path) -> pd.DataFrame: if log_df is None or log_df.empty: return pd.DataFrame(columns=LOG_COLUMNS) weights = protocol_weights(path) rows: list[dict[str, Any]] = [] for _, row in log_df.iterrows(): item = {column: row.get(column, "") for column in LOG_COLUMNS} scores = {metric: _number(item.get(metric)) for metric in METRICS} has_content = any(_text(item.get(col)) for col in ["Sequence", "Stanza ID", "Draft / Pass", "Priority Fix", "Notes"]) or any( value is not None for value in scores.values() ) if not has_content: continue calculated = gate_for_scores(scores, weights) item.update(calculated) rows.append(item) return pd.DataFrame(rows, columns=LOG_COLUMNS) def score_single_row( path: Path, sequence: str, stanza_id: str, draft_pass: str, clarity: float, rhythm: float, flow: float, emotional_truth: float, visual_strength: float, commercial: float, notes: str, ) -> pd.DataFrame: scores = { "Clarity": clarity, "Rhythm": rhythm, "Read-aloud Flow": flow, "Emotional Truth": emotional_truth, "Visual Strength": visual_strength, "Commercial Publishability": commercial, } calculated = gate_for_scores(scores, protocol_weights(path)) row = { "Sequence": sequence, "Stanza ID": stanza_id, "Draft / Pass": draft_pass, **scores, **calculated, "Notes": notes, } return pd.DataFrame([row], columns=LOG_COLUMNS) def viability_table(path: Path) -> tuple[pd.DataFrame, str]: wb = _load(path, data_only=False) ws = wb["VIABILITY"] rows = [] for row_index in range(5, ws.max_row + 1): metric = _text(ws.cell(row_index, 1).value) score = _number(ws.cell(row_index, 2).value) read = _text(ws.cell(row_index, 3).value) if metric and score is not None: rows.append({"Metric": metric, "Score": score, "Read": read}) df = pd.DataFrame(rows) if df.empty: return df, "No viability rows found." avg = round(float(df["Score"].mean()), 1) strong = int((df["Score"] >= 8).sum()) needs_work = int((df["Score"] < 7).sum()) weakest = df.loc[df["Score"].idxmin()] summary = ( f"Average viability: {avg}/10. Strong metrics: {strong}. " f"Needs work under 7: {needs_work}. Weakest commercial pressure point: " f"{weakest['Metric']} ({weakest['Score']}/10)." ) return df, summary def workstack_table(path: Path) -> pd.DataFrame: return table_from_sheet(path, "WORKSTACK", 2) def manuscript_tracker_table(path: Path) -> pd.DataFrame: return table_from_sheet(path, "MANUSCRIPT_TRACKER", 4) def command_registry_table(path: Path) -> pd.DataFrame: return table_from_sheet(path, "COMMAND_REGISTRY", 4) def key_reads_markdown(path: Path) -> str: wb = _load(path) chunks = [] for sheet_name in KEY_READ_SHEETS: if sheet_name not in wb.sheetnames: continue ws = wb[sheet_name] title = _text(ws["A1"].value) or sheet_name purpose = _text(ws["B2"].value) current = _text(ws["B3"].value) note = _text(ws["B4"].value) body = current or purpose or note if len(body) > 900: body = body[:900].rstrip() + "..." chunks.append(f"### {title}\n{body}") return "\n\n".join(chunks) def sheet_preview(path: Path, sheet_name: str, rows: int = 40) -> pd.DataFrame: wb = _load(path, data_only=False) if sheet_name not in wb.sheetnames: return pd.DataFrame() ws = wb[sheet_name] data = [] for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, rows), max_col=min(ws.max_column, 12), values_only=True): cleaned = [_text(value) for value in row] if any(cleaned): data.append(cleaned) width = max((len(row) for row in data), default=0) return pd.DataFrame([(row + [""] * width)[:width] for row in data]) def sheet_names(path: Path) -> list[str]: wb = _load(path) return list(wb.sheetnames) def export_updated_workbook(log_df: pd.DataFrame | None, source_path: Path) -> str: if log_df is None: log_df = pd.DataFrame(columns=LOG_COLUMNS) log_df = recalculate_log(log_df, source_path) with NamedTemporaryFile(prefix="totem_updated_", suffix=".xlsx", delete=False) as handle: output_path = Path(handle.name) wb = _load(source_path, data_only=False) ws = wb["TOTEM_LOG"] for row_index in range(7, 87): for col_index in list(range(1, 10)) + [14, 15]: ws.cell(row_index, col_index).value = None for offset, (_, row) in enumerate(log_df.head(80).iterrows(), start=7): ws.cell(offset, 1).value = _text(row.get("Sequence")) ws.cell(offset, 2).value = _text(row.get("Stanza ID")) ws.cell(offset, 3).value = _text(row.get("Draft / Pass")) for metric_offset, metric in enumerate(METRICS, start=4): ws.cell(offset, metric_offset).value = _number(row.get(metric)) ws.cell(offset, 14).value = _text(row.get("Priority Fix")) ws.cell(offset, 15).value = _text(row.get("Notes")) wb.save(output_path) return str(output_path)