Pointf5ive commited on
Commit
e833d42
·
verified ·
1 Parent(s): 18da3ac

Deploy Codex Extractor Gradio app

Browse files
Files changed (1) hide show
  1. 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)