Spaces:
Sleeping
Sleeping
Deploy Codex Extractor Gradio app
Browse files- src/totem_workbook.py +406 -0
src/totem_workbook.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from tempfile import NamedTemporaryFile
|
| 5 |
+
from typing import Any, Union
|
| 6 |
+
import math
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from openpyxl import load_workbook
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
APP_ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
DEFAULT_WORKBOOK = APP_ROOT / "data" / "order69_macmillan_totem_rebuilt.xlsx"
|
| 14 |
+
|
| 15 |
+
METRICS = [
|
| 16 |
+
"Clarity",
|
| 17 |
+
"Rhythm",
|
| 18 |
+
"Read-aloud Flow",
|
| 19 |
+
"Emotional Truth",
|
| 20 |
+
"Visual Strength",
|
| 21 |
+
"Commercial Publishability",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
LOG_COLUMNS = [
|
| 25 |
+
"Sequence",
|
| 26 |
+
"Stanza ID",
|
| 27 |
+
"Draft / Pass",
|
| 28 |
+
*METRICS,
|
| 29 |
+
"Weighted Score",
|
| 30 |
+
"Average",
|
| 31 |
+
"Gate",
|
| 32 |
+
"Revision Flag",
|
| 33 |
+
"Priority Fix",
|
| 34 |
+
"Notes",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
KEY_READ_SHEETS = [
|
| 38 |
+
"IDENTITY",
|
| 39 |
+
"CANON",
|
| 40 |
+
"VALUES",
|
| 41 |
+
"STORY",
|
| 42 |
+
"PITCH",
|
| 43 |
+
"BRAND",
|
| 44 |
+
"TONE",
|
| 45 |
+
"SATIRE",
|
| 46 |
+
"HANDOFF",
|
| 47 |
+
"RECENT_CONTEXT",
|
| 48 |
+
"CHAR_HENRY",
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
UPLOAD_TYPES = Union[str, Path, Any]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def workbook_path(uploaded_file: UPLOAD_TYPES | None = None) -> Path:
|
| 55 |
+
if uploaded_file is None:
|
| 56 |
+
return DEFAULT_WORKBOOK
|
| 57 |
+
if isinstance(uploaded_file, (str, Path)):
|
| 58 |
+
return Path(uploaded_file)
|
| 59 |
+
if hasattr(uploaded_file, "name"):
|
| 60 |
+
return Path(uploaded_file.name)
|
| 61 |
+
return DEFAULT_WORKBOOK
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _text(value: Any) -> str:
|
| 65 |
+
if value is None:
|
| 66 |
+
return ""
|
| 67 |
+
if isinstance(value, float) and math.isnan(value):
|
| 68 |
+
return ""
|
| 69 |
+
return str(value).strip()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _number(value: Any) -> float | None:
|
| 73 |
+
if value is None or value == "":
|
| 74 |
+
return None
|
| 75 |
+
if isinstance(value, float) and math.isnan(value):
|
| 76 |
+
return None
|
| 77 |
+
if isinstance(value, str) and value.startswith("="):
|
| 78 |
+
return None
|
| 79 |
+
try:
|
| 80 |
+
return float(value)
|
| 81 |
+
except (TypeError, ValueError):
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _load(path: Path, data_only: bool = False):
|
| 86 |
+
return load_workbook(path, data_only=data_only, read_only=False, keep_vba=path.suffix.lower() == ".xlsm")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def table_from_sheet(path: Path, sheet_name: str, header_row: int, start_row: int | None = None) -> pd.DataFrame:
|
| 90 |
+
wb = _load(path)
|
| 91 |
+
ws = wb[sheet_name]
|
| 92 |
+
start = start_row or header_row + 1
|
| 93 |
+
headers = [_text(ws.cell(header_row, col).value) for col in range(1, ws.max_column + 1)]
|
| 94 |
+
rows: list[list[str]] = []
|
| 95 |
+
|
| 96 |
+
for row_index in range(start, ws.max_row + 1):
|
| 97 |
+
row = [_text(ws.cell(row_index, col).value) for col in range(1, ws.max_column + 1)]
|
| 98 |
+
if any(row):
|
| 99 |
+
rows.append(row)
|
| 100 |
+
|
| 101 |
+
width = max(len(headers), max((len(row) for row in rows), default=0))
|
| 102 |
+
headers = (headers + [f"Column {idx}" for idx in range(len(headers) + 1, width + 1)])[:width]
|
| 103 |
+
normalized = [(row + [""] * width)[:width] for row in rows]
|
| 104 |
+
df = pd.DataFrame(normalized, columns=headers)
|
| 105 |
+
return df.loc[:, [col for col in df.columns if col]]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def workbook_overview(path: Path) -> dict[str, Any]:
|
| 109 |
+
wb = _load(path)
|
| 110 |
+
sheets = []
|
| 111 |
+
for ws in wb.worksheets:
|
| 112 |
+
nonempty = sum(1 for cell in ws._cells.values() if cell.value not in (None, ""))
|
| 113 |
+
sheets.append(
|
| 114 |
+
{
|
| 115 |
+
"Sheet": ws.title,
|
| 116 |
+
"Rows": ws.max_row,
|
| 117 |
+
"Columns": ws.max_column,
|
| 118 |
+
"Filled cells": nonempty,
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
chain = table_from_sheet(path, "Chain", 2)
|
| 123 |
+
top_roles = chain.head(8).to_dict("records") if not chain.empty else []
|
| 124 |
+
return {
|
| 125 |
+
"sheet_count": len(wb.sheetnames),
|
| 126 |
+
"filled_cells": sum(row["Filled cells"] for row in sheets),
|
| 127 |
+
"sheets": pd.DataFrame(sheets),
|
| 128 |
+
"top_roles": top_roles,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def chain_table(path: Path) -> pd.DataFrame:
|
| 133 |
+
return table_from_sheet(path, "Chain", 2)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def protocol_table(path: Path) -> pd.DataFrame:
|
| 137 |
+
df = table_from_sheet(path, "TOTEM_PROTOCOL", 5)
|
| 138 |
+
if "Metric" in df.columns:
|
| 139 |
+
df = df[df["Metric"].isin(METRICS)].copy()
|
| 140 |
+
if "Weight" in df.columns:
|
| 141 |
+
df["Weight"] = pd.to_numeric(df["Weight"], errors="coerce")
|
| 142 |
+
return df
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def protocol_weights(path: Path) -> dict[str, float]:
|
| 146 |
+
df = protocol_table(path)
|
| 147 |
+
weights = {row["Metric"]: float(row["Weight"]) for _, row in df.iterrows() if row.get("Metric") in METRICS}
|
| 148 |
+
if not weights:
|
| 149 |
+
weights = {
|
| 150 |
+
"Clarity": 0.20,
|
| 151 |
+
"Rhythm": 0.15,
|
| 152 |
+
"Read-aloud Flow": 0.20,
|
| 153 |
+
"Emotional Truth": 0.15,
|
| 154 |
+
"Visual Strength": 0.15,
|
| 155 |
+
"Commercial Publishability": 0.15,
|
| 156 |
+
}
|
| 157 |
+
return weights
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def gate_for_scores(scores: dict[str, float], weights: dict[str, float]) -> dict[str, Any]:
|
| 161 |
+
clean_scores = {metric: _number(scores.get(metric)) for metric in METRICS}
|
| 162 |
+
present = {metric: score for metric, score in clean_scores.items() if score is not None}
|
| 163 |
+
|
| 164 |
+
if not present:
|
| 165 |
+
return {
|
| 166 |
+
"Weighted Score": "",
|
| 167 |
+
"Average": "",
|
| 168 |
+
"Gate": "",
|
| 169 |
+
"Revision Flag": "",
|
| 170 |
+
"Priority Fix": "",
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
weighted = round(sum(float(present.get(metric, 0)) * weights.get(metric, 0) for metric in METRICS), 1)
|
| 174 |
+
average = round(sum(present.values()) / len(present), 1)
|
| 175 |
+
lowest_metric = min(present, key=lambda metric: present[metric])
|
| 176 |
+
lowest_score = present[lowest_metric]
|
| 177 |
+
low_count = sum(1 for score in present.values() if score <= 6)
|
| 178 |
+
|
| 179 |
+
rhythm = present.get("Rhythm")
|
| 180 |
+
flow = present.get("Read-aloud Flow")
|
| 181 |
+
commercial = present.get("Commercial Publishability")
|
| 182 |
+
|
| 183 |
+
if lowest_score <= 4:
|
| 184 |
+
gate = "HARD FAIL"
|
| 185 |
+
elif low_count >= 2:
|
| 186 |
+
gate = "SOFT FAIL"
|
| 187 |
+
elif (rhythm is not None and rhythm < 7) or (flow is not None and flow < 7):
|
| 188 |
+
gate = "READ-ALOUD BLOCK"
|
| 189 |
+
elif commercial is not None and commercial < 7:
|
| 190 |
+
gate = "COMMERCIAL CHECK"
|
| 191 |
+
elif weighted >= 8 and lowest_score >= 7:
|
| 192 |
+
gate = "GREENLIGHT"
|
| 193 |
+
else:
|
| 194 |
+
gate = "REVISE"
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"Weighted Score": weighted,
|
| 198 |
+
"Average": average,
|
| 199 |
+
"Gate": gate,
|
| 200 |
+
"Revision Flag": "No" if gate == "GREENLIGHT" else "Yes",
|
| 201 |
+
"Priority Fix": lowest_metric,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def score_log(path: Path) -> pd.DataFrame:
|
| 206 |
+
wb = _load(path, data_only=False)
|
| 207 |
+
ws = wb["TOTEM_LOG"]
|
| 208 |
+
weights = protocol_weights(path)
|
| 209 |
+
rows: list[dict[str, Any]] = []
|
| 210 |
+
|
| 211 |
+
for row_index in range(7, min(ws.max_row, 86) + 1):
|
| 212 |
+
raw = {
|
| 213 |
+
"Sequence": _text(ws.cell(row_index, 1).value),
|
| 214 |
+
"Stanza ID": _text(ws.cell(row_index, 2).value),
|
| 215 |
+
"Draft / Pass": _text(ws.cell(row_index, 3).value),
|
| 216 |
+
"Clarity": _number(ws.cell(row_index, 4).value),
|
| 217 |
+
"Rhythm": _number(ws.cell(row_index, 5).value),
|
| 218 |
+
"Read-aloud Flow": _number(ws.cell(row_index, 6).value),
|
| 219 |
+
"Emotional Truth": _number(ws.cell(row_index, 7).value),
|
| 220 |
+
"Visual Strength": _number(ws.cell(row_index, 8).value),
|
| 221 |
+
"Commercial Publishability": _number(ws.cell(row_index, 9).value),
|
| 222 |
+
"Priority Fix": _text(ws.cell(row_index, 14).value),
|
| 223 |
+
"Notes": _text(ws.cell(row_index, 15).value),
|
| 224 |
+
}
|
| 225 |
+
priority_cell = raw["Priority Fix"]
|
| 226 |
+
priority_is_formula = priority_cell.startswith("=")
|
| 227 |
+
has_user_content = any(raw.get(col) not in ("", None) for col in ["Sequence", "Stanza ID", "Draft / Pass", *METRICS, "Notes"])
|
| 228 |
+
has_user_content = has_user_content or bool(priority_cell and not priority_is_formula)
|
| 229 |
+
if not has_user_content:
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
calculated = gate_for_scores({metric: raw[metric] for metric in METRICS}, weights)
|
| 233 |
+
if raw["Priority Fix"] and raw["Priority Fix"] not in METRICS and not priority_is_formula:
|
| 234 |
+
raw["Notes"] = raw["Notes"] or raw["Priority Fix"]
|
| 235 |
+
raw["Priority Fix"] = calculated["Priority Fix"]
|
| 236 |
+
elif not raw["Priority Fix"] or priority_is_formula:
|
| 237 |
+
raw["Priority Fix"] = calculated["Priority Fix"]
|
| 238 |
+
|
| 239 |
+
raw.update(
|
| 240 |
+
{
|
| 241 |
+
"Weighted Score": calculated["Weighted Score"],
|
| 242 |
+
"Average": calculated["Average"],
|
| 243 |
+
"Gate": calculated["Gate"],
|
| 244 |
+
"Revision Flag": calculated["Revision Flag"],
|
| 245 |
+
}
|
| 246 |
+
)
|
| 247 |
+
rows.append(raw)
|
| 248 |
+
|
| 249 |
+
return pd.DataFrame(rows, columns=LOG_COLUMNS)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def recalculate_log(log_df: pd.DataFrame | None, path: Path) -> pd.DataFrame:
|
| 253 |
+
if log_df is None or log_df.empty:
|
| 254 |
+
return pd.DataFrame(columns=LOG_COLUMNS)
|
| 255 |
+
|
| 256 |
+
weights = protocol_weights(path)
|
| 257 |
+
rows: list[dict[str, Any]] = []
|
| 258 |
+
for _, row in log_df.iterrows():
|
| 259 |
+
item = {column: row.get(column, "") for column in LOG_COLUMNS}
|
| 260 |
+
scores = {metric: _number(item.get(metric)) for metric in METRICS}
|
| 261 |
+
has_content = any(_text(item.get(col)) for col in ["Sequence", "Stanza ID", "Draft / Pass", "Priority Fix", "Notes"]) or any(
|
| 262 |
+
value is not None for value in scores.values()
|
| 263 |
+
)
|
| 264 |
+
if not has_content:
|
| 265 |
+
continue
|
| 266 |
+
calculated = gate_for_scores(scores, weights)
|
| 267 |
+
item.update(calculated)
|
| 268 |
+
rows.append(item)
|
| 269 |
+
|
| 270 |
+
return pd.DataFrame(rows, columns=LOG_COLUMNS)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def score_single_row(
|
| 274 |
+
path: Path,
|
| 275 |
+
sequence: str,
|
| 276 |
+
stanza_id: str,
|
| 277 |
+
draft_pass: str,
|
| 278 |
+
clarity: float,
|
| 279 |
+
rhythm: float,
|
| 280 |
+
flow: float,
|
| 281 |
+
emotional_truth: float,
|
| 282 |
+
visual_strength: float,
|
| 283 |
+
commercial: float,
|
| 284 |
+
notes: str,
|
| 285 |
+
) -> pd.DataFrame:
|
| 286 |
+
scores = {
|
| 287 |
+
"Clarity": clarity,
|
| 288 |
+
"Rhythm": rhythm,
|
| 289 |
+
"Read-aloud Flow": flow,
|
| 290 |
+
"Emotional Truth": emotional_truth,
|
| 291 |
+
"Visual Strength": visual_strength,
|
| 292 |
+
"Commercial Publishability": commercial,
|
| 293 |
+
}
|
| 294 |
+
calculated = gate_for_scores(scores, protocol_weights(path))
|
| 295 |
+
row = {
|
| 296 |
+
"Sequence": sequence,
|
| 297 |
+
"Stanza ID": stanza_id,
|
| 298 |
+
"Draft / Pass": draft_pass,
|
| 299 |
+
**scores,
|
| 300 |
+
**calculated,
|
| 301 |
+
"Notes": notes,
|
| 302 |
+
}
|
| 303 |
+
return pd.DataFrame([row], columns=LOG_COLUMNS)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def viability_table(path: Path) -> tuple[pd.DataFrame, str]:
|
| 307 |
+
wb = _load(path, data_only=False)
|
| 308 |
+
ws = wb["VIABILITY"]
|
| 309 |
+
rows = []
|
| 310 |
+
for row_index in range(5, ws.max_row + 1):
|
| 311 |
+
metric = _text(ws.cell(row_index, 1).value)
|
| 312 |
+
score = _number(ws.cell(row_index, 2).value)
|
| 313 |
+
read = _text(ws.cell(row_index, 3).value)
|
| 314 |
+
if metric and score is not None:
|
| 315 |
+
rows.append({"Metric": metric, "Score": score, "Read": read})
|
| 316 |
+
df = pd.DataFrame(rows)
|
| 317 |
+
if df.empty:
|
| 318 |
+
return df, "No viability rows found."
|
| 319 |
+
|
| 320 |
+
avg = round(float(df["Score"].mean()), 1)
|
| 321 |
+
strong = int((df["Score"] >= 8).sum())
|
| 322 |
+
needs_work = int((df["Score"] < 7).sum())
|
| 323 |
+
weakest = df.loc[df["Score"].idxmin()]
|
| 324 |
+
summary = (
|
| 325 |
+
f"Average viability: {avg}/10. Strong metrics: {strong}. "
|
| 326 |
+
f"Needs work under 7: {needs_work}. Weakest commercial pressure point: "
|
| 327 |
+
f"{weakest['Metric']} ({weakest['Score']}/10)."
|
| 328 |
+
)
|
| 329 |
+
return df, summary
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def workstack_table(path: Path) -> pd.DataFrame:
|
| 333 |
+
return table_from_sheet(path, "WORKSTACK", 2)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def manuscript_tracker_table(path: Path) -> pd.DataFrame:
|
| 337 |
+
return table_from_sheet(path, "MANUSCRIPT_TRACKER", 4)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def command_registry_table(path: Path) -> pd.DataFrame:
|
| 341 |
+
return table_from_sheet(path, "COMMAND_REGISTRY", 4)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def key_reads_markdown(path: Path) -> str:
|
| 345 |
+
wb = _load(path)
|
| 346 |
+
chunks = []
|
| 347 |
+
for sheet_name in KEY_READ_SHEETS:
|
| 348 |
+
if sheet_name not in wb.sheetnames:
|
| 349 |
+
continue
|
| 350 |
+
ws = wb[sheet_name]
|
| 351 |
+
title = _text(ws["A1"].value) or sheet_name
|
| 352 |
+
purpose = _text(ws["B2"].value)
|
| 353 |
+
current = _text(ws["B3"].value)
|
| 354 |
+
note = _text(ws["B4"].value)
|
| 355 |
+
body = current or purpose or note
|
| 356 |
+
if len(body) > 900:
|
| 357 |
+
body = body[:900].rstrip() + "..."
|
| 358 |
+
chunks.append(f"### {title}\n{body}")
|
| 359 |
+
return "\n\n".join(chunks)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def sheet_preview(path: Path, sheet_name: str, rows: int = 40) -> pd.DataFrame:
|
| 363 |
+
wb = _load(path, data_only=False)
|
| 364 |
+
if sheet_name not in wb.sheetnames:
|
| 365 |
+
return pd.DataFrame()
|
| 366 |
+
ws = wb[sheet_name]
|
| 367 |
+
data = []
|
| 368 |
+
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):
|
| 369 |
+
cleaned = [_text(value) for value in row]
|
| 370 |
+
if any(cleaned):
|
| 371 |
+
data.append(cleaned)
|
| 372 |
+
width = max((len(row) for row in data), default=0)
|
| 373 |
+
return pd.DataFrame([(row + [""] * width)[:width] for row in data])
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def sheet_names(path: Path) -> list[str]:
|
| 377 |
+
wb = _load(path)
|
| 378 |
+
return list(wb.sheetnames)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def export_updated_workbook(log_df: pd.DataFrame | None, source_path: Path) -> str:
|
| 382 |
+
if log_df is None:
|
| 383 |
+
log_df = pd.DataFrame(columns=LOG_COLUMNS)
|
| 384 |
+
log_df = recalculate_log(log_df, source_path)
|
| 385 |
+
|
| 386 |
+
with NamedTemporaryFile(prefix="totem_updated_", suffix=".xlsx", delete=False) as handle:
|
| 387 |
+
output_path = Path(handle.name)
|
| 388 |
+
|
| 389 |
+
wb = _load(source_path, data_only=False)
|
| 390 |
+
ws = wb["TOTEM_LOG"]
|
| 391 |
+
|
| 392 |
+
for row_index in range(7, 87):
|
| 393 |
+
for col_index in list(range(1, 10)) + [14, 15]:
|
| 394 |
+
ws.cell(row_index, col_index).value = None
|
| 395 |
+
|
| 396 |
+
for offset, (_, row) in enumerate(log_df.head(80).iterrows(), start=7):
|
| 397 |
+
ws.cell(offset, 1).value = _text(row.get("Sequence"))
|
| 398 |
+
ws.cell(offset, 2).value = _text(row.get("Stanza ID"))
|
| 399 |
+
ws.cell(offset, 3).value = _text(row.get("Draft / Pass"))
|
| 400 |
+
for metric_offset, metric in enumerate(METRICS, start=4):
|
| 401 |
+
ws.cell(offset, metric_offset).value = _number(row.get(metric))
|
| 402 |
+
ws.cell(offset, 14).value = _text(row.get("Priority Fix"))
|
| 403 |
+
ws.cell(offset, 15).value = _text(row.get("Notes"))
|
| 404 |
+
|
| 405 |
+
wb.save(output_path)
|
| 406 |
+
return str(output_path)
|