Pointf5ive commited on
Commit
83a57b2
·
1 Parent(s): 7af990b

Wire TOTEM skill scoring to dashboard metrics

Browse files
app.py CHANGED
@@ -5,6 +5,7 @@ import html
5
  import json
6
  import os
7
  import re
 
8
  from shutil import copy2
9
  from html import escape
10
  from pathlib import Path
@@ -36,6 +37,7 @@ from src.totem_bridge import (
36
  extract_workbook_matrix,
37
  recompute_gate,
38
  run_totem_skill,
 
39
  )
40
  from smoke_signal_tab import smoke_signal_tab, SS_CSS
41
 
@@ -1217,13 +1219,13 @@ TOTEM_CSS = """
1217
 
1218
  .totem-shell .totem-row {
1219
  display: grid;
1220
- grid-template-columns: repeat(5, minmax(0, 1fr));
1221
- gap: var(--space-3);
1222
  }
1223
 
1224
  .totem-metric-card {
1225
- min-height: 178px;
1226
- padding: 18px;
1227
  border-radius: 14px;
1228
  background: linear-gradient(180deg, rgba(16, 26, 58, 0.96), rgba(8, 18, 45, 0.98));
1229
  border: 1px solid rgba(174, 183, 204, 0.3);
@@ -1232,60 +1234,60 @@ TOTEM_CSS = """
1232
 
1233
  .totem-metric-head {
1234
  display: grid;
1235
- grid-template-columns: 56px 1fr;
1236
- gap: 12px;
1237
  align-items: center;
1238
  }
1239
 
1240
  .totem-metric-icon {
1241
- width: 56px;
1242
- height: 56px;
1243
  border-radius: 999px;
1244
  display: grid;
1245
  place-items: center;
1246
  color: #fff;
1247
- font-size: 24px;
1248
  background: rgba(7, 18, 45, 0.45);
1249
  border: 1px solid var(--metric-color);
1250
  box-shadow: 0 0 22px color-mix(in srgb, var(--metric-color), transparent 55%);
1251
  }
1252
 
1253
  .totem-metric-label {
1254
- font-size: 14px;
1255
  color: rgba(246, 241, 232, 0.95);
1256
  }
1257
 
1258
  .totem-metric-score {
1259
  margin-top: 12px;
1260
- font-size: 42px;
1261
  line-height: 1;
1262
  color: var(--metric-color);
1263
  font-variant-numeric: tabular-nums;
1264
  }
1265
 
1266
  .totem-metric-score small {
1267
- font-size: 17px;
1268
  color: rgba(174, 183, 204, 0.95);
1269
  }
1270
 
1271
  .totem-metric-bars {
1272
  display: flex;
1273
  align-items: end;
1274
- gap: 6px;
1275
- height: 32px;
1276
- margin-top: 14px;
1277
  }
1278
 
1279
  .totem-metric-bars span {
1280
- width: 4px;
1281
  border-radius: 3px 3px 0 0;
1282
  background: var(--metric-color);
1283
  opacity: .95;
1284
  }
1285
 
1286
  .totem-metric-hint {
1287
- margin-top: 12px;
1288
- font-size: 12px;
1289
  color: rgba(174, 183, 204, 0.98);
1290
  }
1291
 
@@ -1497,6 +1499,7 @@ DASHBOARD_STATE_KEYS = (
1497
  "project_name",
1498
  "workbook_loaded",
1499
  "analysis_status",
 
1500
  "last_analysis_at",
1501
  "totem_signal",
1502
  "metrics",
@@ -1547,11 +1550,12 @@ def compute_totem_signal(
1547
  return 35
1548
 
1549
  weights = {
1550
- "overall_publishability": 0.30,
1551
- "read_aloud_flow": 0.15,
1552
- "emotional_truth": 0.20,
1553
- "visual_strength": 0.20,
1554
- "commercial_viability": 0.15,
 
1555
  }
1556
 
1557
  total = 0.0
@@ -1566,27 +1570,16 @@ def get_initial_dashboard_state() -> dict:
1566
  Stage 2 state contract.
1567
  Placeholder values are deliberate before first analysis run.
1568
  """
1569
- metrics = {
1570
- "overall_publishability": 0,
1571
- "read_aloud_flow": 0,
1572
- "emotional_truth": 0,
1573
- "visual_strength": 0,
1574
- "commercial_viability": 0,
1575
- }
1576
  return {
1577
  "project_name": "Editorial Workspace",
1578
  "workbook_loaded": False,
1579
  "analysis_status": "idle", # idle | ready | running | complete | error
 
1580
  "last_analysis_at": None,
1581
  "totem_signal": compute_totem_signal(metrics, workbook_loaded=False, analysis_timestamp=None),
1582
  "metrics": metrics,
1583
- "metric_history": {
1584
- "overall_publishability": [],
1585
- "read_aloud_flow": [],
1586
- "emotional_truth": [],
1587
- "visual_strength": [],
1588
- "commercial_viability": [],
1589
- },
1590
  "revision_queue": [],
1591
  "risk_clusters": [],
1592
  }
@@ -1610,6 +1603,7 @@ def normalize_dashboard_state(raw_existing_outputs) -> dict:
1610
  if status not in {"idle", "ready", "running", "complete", "error"}:
1611
  status = state["analysis_status"]
1612
  state["analysis_status"] = status
 
1613
 
1614
  ts_value = raw_existing_outputs.get("last_analysis_at")
1615
  state["last_analysis_at"] = str(ts_value).strip() if ts_value else None
@@ -1853,7 +1847,7 @@ def render_status_row(state: dict) -> str:
1853
  "error": "TOTEM analysis error. Review logs and retry.",
1854
  }
1855
  css_state = status if status in {"ready", "running", "complete", "error"} else "ready"
1856
- message = esc(text_by_status.get(status, text_by_status["ready"]))
1857
  return f"""
1858
  <section class="totem-status-row state-{css_state}">
1859
  <div>{message}</div>
@@ -1882,8 +1876,9 @@ def render_metric_cards(state: dict) -> str:
1882
  metrics = state.get("metrics", {}) if isinstance(state, dict) else {}
1883
  history = state.get("metric_history", {}) if isinstance(state, dict) else {}
1884
  card_defs = [
1885
- ("overall_publishability", "Overall Publishability", "✦", "var(--totem-gold)", "Source: viability lens"),
1886
- ("read_aloud_flow", "Read-Aloud Flow", "", "var(--totem-pink)", "Weakest live pressure"),
 
1887
  ("emotional_truth", "Emotional Truth", "❤", "var(--totem-emerald)", "Strongest story signal"),
1888
  ("visual_strength", "Visual Strength", "◉", "var(--totem-cyan)", "Drawable page value"),
1889
  ("commercial_viability", "Commercial Viability", "↗", "var(--totem-violet)", "Publisher-facing lens"),
@@ -2940,6 +2935,7 @@ def _build_dashboard_state_from_workbook(path: Path, status: str, notice: str =
2940
  state["analysis_status"] = status
2941
  state["workbook_loaded"] = path.exists()
2942
  state["project_name"] = "Editorial Workspace"
 
2943
  state["last_analysis_at"] = datetime.datetime.now().isoformat(timespec="seconds") if status == "complete" else None
2944
 
2945
  if path.exists():
@@ -2947,7 +2943,8 @@ def _build_dashboard_state_from_workbook(path: Path, status: str, notice: str =
2947
  log_df = score_log(path)
2948
  if log_df is not None and not log_df.empty:
2949
  for key, col in [
2950
- ("overall_publishability", "Commercial Publishability"),
 
2951
  ("read_aloud_flow", "Read-aloud Flow"),
2952
  ("emotional_truth", "Emotional Truth"),
2953
  ("visual_strength", "Visual Strength"),
@@ -3063,6 +3060,21 @@ def run_analysis(active_path: str):
3063
  return render_dashboard(state), log_df, _score_summary(log_df)
3064
 
3065
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3066
  def _apply_llm_dashboard_state(
3067
  *,
3068
  active_path: str,
@@ -3071,25 +3083,21 @@ def _apply_llm_dashboard_state(
3071
  ) -> tuple[str, pd.DataFrame, str, str]:
3072
  path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
3073
  log_df = score_log(path)
3074
- state = _build_dashboard_state_from_workbook(path, "complete", notice)
3075
- state["metrics"]["overall_publishability"] = int(llm_state["overall_publishability"])
3076
- state["metrics"]["read_aloud_flow"] = int(llm_state["read_aloud_flow"])
3077
- state["metrics"]["emotional_truth"] = int(llm_state["emotional_truth"])
3078
- state["metrics"]["visual_strength"] = int(llm_state["visual_strength"])
3079
- state["metrics"]["commercial_viability"] = int(llm_state["commercial_visibility"])
3080
  state["revision_queue"] = list(llm_state.get("revision_priority_queue") or [])
3081
  state["risk_clusters"] = list(llm_state.get("risk_clusters") or [])
3082
  state["analysis_status"] = "complete"
 
 
 
 
 
 
3083
  if os.getenv("TOTEM_RECOMPUTE_GATES", "1").strip() not in {"0", "false", "False"}:
3084
- recomputed = recompute_gate(
3085
- {
3086
- "read_aloud_flow": state["metrics"]["read_aloud_flow"],
3087
- "emotional_truth": state["metrics"]["emotional_truth"],
3088
- "visual_strength": state["metrics"]["visual_strength"],
3089
- "commercial_visibility": state["metrics"]["commercial_viability"],
3090
- },
3091
- extract_workbook_matrix(path),
3092
- )
3093
  gate_summary = f"Gate={llm_state.get('gate','REVISE')} | Recomputed={recomputed}"
3094
  else:
3095
  gate_summary = f"Gate={llm_state.get('gate','REVISE')}"
@@ -3161,8 +3169,11 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3161
  - validated JSON updates dashboard
3162
  """
3163
  if not manuscript_context_json:
3164
- dashboard_html, log_df, summary = run_analysis(active_path)
3165
- return dashboard_html, log_df, summary, "No manuscript context found. Ran workbook-only refresh."
 
 
 
3166
 
3167
  try:
3168
  ctx_payload = json.loads(manuscript_context_json)
@@ -3196,12 +3207,18 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3196
  "manuscript_words": debug.get("word_count"),
3197
  "manuscript_path": ctx.path,
3198
  "rubric_metrics": len(rubric.get("metrics", [])),
 
 
 
 
3199
  }
3200
  return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
3201
  except Exception as exc:
3202
- dashboard_html, log_df, summary = run_analysis(active_path)
3203
- debug_log = {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
3204
- return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
 
 
3205
 
3206
 
3207
  def recalc_log(log_df, active_path: str):
@@ -3258,7 +3275,7 @@ with gr.Blocks(title="TOTEM Studio") as demo:
3258
  "border:1px solid rgba(242,193,78,.35);border-radius:10px;"
3259
  "background:rgba(10,22,45,.85);color:#f6f1e8;font-size:13px;'>"
3260
  "<b>Mode:</b> Draft Mode (ORDER_91) keeps manuscript extraction separate from scoring. "
3261
- "Only <b>Run TOTEM Analysis</b> updates the 5 scoring metrics. "
3262
  "<span style='opacity:.95;color:#8df0b5;font-weight:700'>BRIDGE ACTIVE</span> · "
3263
  "<span style='opacity:.8'>Build: 668ed10</span>"
3264
  "</div>"
 
5
  import json
6
  import os
7
  import re
8
+ import datetime
9
  from shutil import copy2
10
  from html import escape
11
  from pathlib import Path
 
37
  extract_workbook_matrix,
38
  recompute_gate,
39
  run_totem_skill,
40
+ SCORE_FIELDS,
41
  )
42
  from smoke_signal_tab import smoke_signal_tab, SS_CSS
43
 
 
1219
 
1220
  .totem-shell .totem-row {
1221
  display: grid;
1222
+ grid-template-columns: repeat(6, minmax(0, 1fr));
1223
+ gap: 10px;
1224
  }
1225
 
1226
  .totem-metric-card {
1227
+ min-height: 168px;
1228
+ padding: 14px;
1229
  border-radius: 14px;
1230
  background: linear-gradient(180deg, rgba(16, 26, 58, 0.96), rgba(8, 18, 45, 0.98));
1231
  border: 1px solid rgba(174, 183, 204, 0.3);
 
1234
 
1235
  .totem-metric-head {
1236
  display: grid;
1237
+ grid-template-columns: 48px 1fr;
1238
+ gap: 10px;
1239
  align-items: center;
1240
  }
1241
 
1242
  .totem-metric-icon {
1243
+ width: 48px;
1244
+ height: 48px;
1245
  border-radius: 999px;
1246
  display: grid;
1247
  place-items: center;
1248
  color: #fff;
1249
+ font-size: 21px;
1250
  background: rgba(7, 18, 45, 0.45);
1251
  border: 1px solid var(--metric-color);
1252
  box-shadow: 0 0 22px color-mix(in srgb, var(--metric-color), transparent 55%);
1253
  }
1254
 
1255
  .totem-metric-label {
1256
+ font-size: 13px;
1257
  color: rgba(246, 241, 232, 0.95);
1258
  }
1259
 
1260
  .totem-metric-score {
1261
  margin-top: 12px;
1262
+ font-size: 38px;
1263
  line-height: 1;
1264
  color: var(--metric-color);
1265
  font-variant-numeric: tabular-nums;
1266
  }
1267
 
1268
  .totem-metric-score small {
1269
+ font-size: 15px;
1270
  color: rgba(174, 183, 204, 0.95);
1271
  }
1272
 
1273
  .totem-metric-bars {
1274
  display: flex;
1275
  align-items: end;
1276
+ gap: 4px;
1277
+ height: 30px;
1278
+ margin-top: 12px;
1279
  }
1280
 
1281
  .totem-metric-bars span {
1282
+ width: 3px;
1283
  border-radius: 3px 3px 0 0;
1284
  background: var(--metric-color);
1285
  opacity: .95;
1286
  }
1287
 
1288
  .totem-metric-hint {
1289
+ margin-top: 10px;
1290
+ font-size: 11px;
1291
  color: rgba(174, 183, 204, 0.98);
1292
  }
1293
 
 
1499
  "project_name",
1500
  "workbook_loaded",
1501
  "analysis_status",
1502
+ "dashboard_message",
1503
  "last_analysis_at",
1504
  "totem_signal",
1505
  "metrics",
 
1550
  return 35
1551
 
1552
  weights = {
1553
+ "clarity": 0.16,
1554
+ "rhythm": 0.14,
1555
+ "read_aloud_flow": 0.18,
1556
+ "emotional_truth": 0.18,
1557
+ "visual_strength": 0.16,
1558
+ "commercial_viability": 0.18,
1559
  }
1560
 
1561
  total = 0.0
 
1570
  Stage 2 state contract.
1571
  Placeholder values are deliberate before first analysis run.
1572
  """
1573
+ metrics = {key: 0 for key in SCORE_FIELDS}
 
 
 
 
 
 
1574
  return {
1575
  "project_name": "Editorial Workspace",
1576
  "workbook_loaded": False,
1577
  "analysis_status": "idle", # idle | ready | running | complete | error
1578
+ "dashboard_message": "",
1579
  "last_analysis_at": None,
1580
  "totem_signal": compute_totem_signal(metrics, workbook_loaded=False, analysis_timestamp=None),
1581
  "metrics": metrics,
1582
+ "metric_history": {key: [] for key in SCORE_FIELDS},
 
 
 
 
 
 
1583
  "revision_queue": [],
1584
  "risk_clusters": [],
1585
  }
 
1603
  if status not in {"idle", "ready", "running", "complete", "error"}:
1604
  status = state["analysis_status"]
1605
  state["analysis_status"] = status
1606
+ state["dashboard_message"] = str(raw_existing_outputs.get("dashboard_message") or state["dashboard_message"]).strip()
1607
 
1608
  ts_value = raw_existing_outputs.get("last_analysis_at")
1609
  state["last_analysis_at"] = str(ts_value).strip() if ts_value else None
 
1847
  "error": "TOTEM analysis error. Review logs and retry.",
1848
  }
1849
  css_state = status if status in {"ready", "running", "complete", "error"} else "ready"
1850
+ message = esc(state.get("dashboard_message") or text_by_status.get(status, text_by_status["ready"]))
1851
  return f"""
1852
  <section class="totem-status-row state-{css_state}">
1853
  <div>{message}</div>
 
1876
  metrics = state.get("metrics", {}) if isinstance(state, dict) else {}
1877
  history = state.get("metric_history", {}) if isinstance(state, dict) else {}
1878
  card_defs = [
1879
+ ("clarity", "Clarity", "✦", "var(--totem-gold)", "Clean story signal"),
1880
+ ("rhythm", "Rhythm", "", "var(--totem-warning)", "Beat and cadence"),
1881
+ ("read_aloud_flow", "Read-Aloud Flow", "≋", "var(--totem-pink)", "Mouth-feel pressure"),
1882
  ("emotional_truth", "Emotional Truth", "❤", "var(--totem-emerald)", "Strongest story signal"),
1883
  ("visual_strength", "Visual Strength", "◉", "var(--totem-cyan)", "Drawable page value"),
1884
  ("commercial_viability", "Commercial Viability", "↗", "var(--totem-violet)", "Publisher-facing lens"),
 
2935
  state["analysis_status"] = status
2936
  state["workbook_loaded"] = path.exists()
2937
  state["project_name"] = "Editorial Workspace"
2938
+ state["dashboard_message"] = notice or ""
2939
  state["last_analysis_at"] = datetime.datetime.now().isoformat(timespec="seconds") if status == "complete" else None
2940
 
2941
  if path.exists():
 
2943
  log_df = score_log(path)
2944
  if log_df is not None and not log_df.empty:
2945
  for key, col in [
2946
+ ("clarity", "Clarity"),
2947
+ ("rhythm", "Rhythm"),
2948
  ("read_aloud_flow", "Read-aloud Flow"),
2949
  ("emotional_truth", "Emotional Truth"),
2950
  ("visual_strength", "Visual Strength"),
 
3060
  return render_dashboard(state), log_df, _score_summary(log_df)
3061
 
3062
 
3063
+ def _dashboard_error_response(active_path: str, message: str, error_detail: str):
3064
+ try:
3065
+ path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
3066
+ log_df = score_log(path)
3067
+ state = _build_dashboard_state_from_workbook(path, "error", message)
3068
+ summary = _score_summary(log_df)
3069
+ except Exception:
3070
+ state = get_initial_dashboard_state()
3071
+ state["analysis_status"] = "error"
3072
+ state["dashboard_message"] = message
3073
+ log_df = pd.DataFrame(columns=LOG_COLUMNS)
3074
+ summary = "Dashboard analysis blocked."
3075
+ return render_dashboard(state), log_df, summary, json.dumps({"status": "error", "error": error_detail}, indent=2)
3076
+
3077
+
3078
  def _apply_llm_dashboard_state(
3079
  *,
3080
  active_path: str,
 
3083
  ) -> tuple[str, pd.DataFrame, str, str]:
3084
  path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
3085
  log_df = score_log(path)
3086
+ state = _build_dashboard_state_from_workbook(path, "complete", llm_state.get("dashboard_message") or notice)
3087
+ scores = llm_state.get("scores") if isinstance(llm_state.get("scores"), dict) else llm_state
3088
+ for key in SCORE_FIELDS:
3089
+ state["metrics"][key] = int(scores[key])
 
 
3090
  state["revision_queue"] = list(llm_state.get("revision_priority_queue") or [])
3091
  state["risk_clusters"] = list(llm_state.get("risk_clusters") or [])
3092
  state["analysis_status"] = "complete"
3093
+ state["last_analysis_at"] = datetime.datetime.now().isoformat(timespec="seconds")
3094
+ state["totem_signal"] = compute_totem_signal(
3095
+ state["metrics"],
3096
+ workbook_loaded=state["workbook_loaded"],
3097
+ analysis_timestamp=state["last_analysis_at"],
3098
+ )
3099
  if os.getenv("TOTEM_RECOMPUTE_GATES", "1").strip() not in {"0", "false", "False"}:
3100
+ recomputed = recompute_gate(state["metrics"], extract_workbook_matrix(path))
 
 
 
 
 
 
 
 
3101
  gate_summary = f"Gate={llm_state.get('gate','REVISE')} | Recomputed={recomputed}"
3102
  else:
3103
  gate_summary = f"Gate={llm_state.get('gate','REVISE')}"
 
3169
  - validated JSON updates dashboard
3170
  """
3171
  if not manuscript_context_json:
3172
+ return _dashboard_error_response(
3173
+ active_path,
3174
+ "Upload a manuscript first, then run TOTEM Analysis.",
3175
+ "No manuscript context found.",
3176
+ )
3177
 
3178
  try:
3179
  ctx_payload = json.loads(manuscript_context_json)
 
3207
  "manuscript_words": debug.get("word_count"),
3208
  "manuscript_path": ctx.path,
3209
  "rubric_metrics": len(rubric.get("metrics", [])),
3210
+ "skill_loaded": debug.get("skill_loaded"),
3211
+ "skill_path": debug.get("skill_path"),
3212
+ "schema_pass": debug.get("schema_pass"),
3213
+ "score_fields": list(SCORE_FIELDS),
3214
  }
3215
  return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
3216
  except Exception as exc:
3217
+ return _dashboard_error_response(
3218
+ active_path,
3219
+ "TOTEM analysis error. Review Bridge Diagnostics and retry.",
3220
+ f"{type(exc).__name__}: {exc}",
3221
+ )
3222
 
3223
 
3224
  def recalc_log(log_df, active_path: str):
 
3275
  "border:1px solid rgba(242,193,78,.35);border-radius:10px;"
3276
  "background:rgba(10,22,45,.85);color:#f6f1e8;font-size:13px;'>"
3277
  "<b>Mode:</b> Draft Mode (ORDER_91) keeps manuscript extraction separate from scoring. "
3278
+ "Only <b>Run TOTEM Analysis</b> updates the 6 scoring metrics. "
3279
  "<span style='opacity:.95;color:#8df0b5;font-weight:700'>BRIDGE ACTIVE</span> · "
3280
  "<span style='opacity:.8'>Build: 668ed10</span>"
3281
  "</div>"
data/skills/TOTEM_Manuscript_Scoring_Skill.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TOTEM Manuscript Scoring Skill
2
+
3
+ ## Purpose
4
+ Turn any uploaded children's picture-book manuscript into a polished, branded TOTEM Studio assessment document without the user needing to retype the full context each time.
5
+
6
+ ## User trigger phrases
7
+ Run this workflow when the user says any of the following:
8
+ - “TOTEM score this”
9
+ - “Score my manuscript”
10
+ - “Run a TOTEM snapshot”
11
+ - “Turn this into a professional report”
12
+ - “Assess this with the Henry / TOTEM metrics”
13
+
14
+ ## Required inputs
15
+ 1. The manuscript file or manuscript text.
16
+ 2. Optional: title, author name, target age band, intended page count, and publishing route.
17
+ 3. Optional: brand image/assets. If absent, use the saved TOTEM Studio brand system below.
18
+
19
+ ## Brand system
20
+ - Brand: TOTEM Studio — Story Intelligence OS.
21
+ - Brand essence: magical, intelligent, imaginative, premium.
22
+ - Voice: wonder-driven, insightful, trustworthy, inventive.
23
+ - Colours:
24
+ - Midnight Navy #07122D
25
+ - Story Gold #F2C14E
26
+ - Enchanted Violet #8A4DFF
27
+ - Pixie Pink #FF5FD2
28
+ - Emerald Tale #22B573
29
+ - Celestial Cyan #39C9FF
30
+ - Moonlight Ivory #F6F1E8
31
+ - Visual rule: dark premium background, gold section rules, restrained sparkle/stardust accents, clean typography.
32
+ - Logo rule: use full logo on cover, icon mark for headers/dividers, horizontal lockup for back page or footer where useful.
33
+
34
+ ## TOTEM scoring dimensions
35
+ Score each dimension from 1–10, with clear acceptance risk and rewrite priority:
36
+ 1. Clarity
37
+ 2. Rhythm
38
+ 3. Read-aloud flow
39
+ 4. Emotional truth
40
+ 5. Visual strength
41
+ 6. Commercial viability
42
+
43
+ ## Assessment method
44
+ 1. Read the manuscript fully before scoring.
45
+ 2. Identify the core emotional promise and the likely market/category.
46
+ 3. Score each TOTEM dimension using evidence from the manuscript.
47
+ 4. Identify the strongest commercial asset.
48
+ 5. Identify the largest acceptance blocker.
49
+ 6. Produce targeted rewrite directives, not generic feedback.
50
+ 7. Include a block-level pressure map where useful.
51
+ 8. End with a practical rewrite roadmap.
52
+
53
+ ## Output rule
54
+ Do not dump the full report into chat. Create a professionally formatted DOCX using the TOTEM branded template. The chat response should only include a short confirmation and the download link.
55
+
56
+ ## Required report structure
57
+ 1. Cover / brand title page
58
+ 2. Executive verdict
59
+ 3. TOTEM metric score table
60
+ 4. Dimension-by-dimension analysis
61
+ 5. Acceptance blocker summary
62
+ 6. Block-level pressure map
63
+ 7. Rewrite roadmap
64
+ 8. Optional appendix for manuscript notes
65
+
66
+ ## Quality standard
67
+ Clarity, rhythm, read-aloud flow, emotional truth, visual strength, and commercial publishability are non-negotiable. Prioritise publishability over cleverness. Be direct, commercial, and useful.
68
+
69
+ ## Safety / rights rule
70
+ Do not include long copyrighted excerpts from outside sources. Do not mix controlled samples, public-domain samples, or user manuscript text unless the user has clearly supplied the manuscript for assessment.
src/totem_bridge.py CHANGED
@@ -12,6 +12,31 @@ from src.codex_extractor import clean_text, extract_text_from_file
12
  from src.totem_workbook import METRICS, protocol_table, protocol_weights
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class BridgeError(RuntimeError):
16
  pass
17
 
@@ -58,7 +83,7 @@ def extract_workbook_matrix(path: Path) -> dict[str, Any]:
58
  return {
59
  "weights": {k: float(v) for k, v in weights.items()},
60
  "metrics": metric_rows,
61
- "gate_labels": ["HARD FAIL", "SOFT FAIL", "READ-ALOUD BLOCK", "COMMERCIAL CHECK", "GREENLIGHT", "REVISE"],
62
  }
63
 
64
 
@@ -75,11 +100,61 @@ def extract_manuscript_context(file_path: str | Path) -> ManuscriptContext:
75
  )
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def run_totem_skill(
79
  *,
80
  rubric_matrix: dict[str, Any],
81
  manuscript: ManuscriptContext,
82
  ) -> tuple[dict[str, Any], dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  api_key = os.getenv("OPENAI_API_KEY")
84
  if not api_key:
85
  raise BridgeError("OPENAI_API_KEY is not configured.")
@@ -89,41 +164,28 @@ def run_totem_skill(
89
  model = os.getenv("TOTEM_OPENAI_MODEL", "gpt-4.1-mini")
90
  client = OpenAI(api_key=api_key)
91
 
92
- system_prompt = (
93
- "You are the hidden TOTEM Analysis skill. "
94
- "Return strict JSON only with no markdown. "
95
- "Use workbook matrix as scoring authority and manuscript text as evidence."
96
- )
 
 
 
 
 
 
97
  user_payload = {
98
- "task": "score_manuscript_against_workbook_matrix",
99
  "rubric_matrix": rubric_matrix,
100
  "manuscript_cleaned_text": manuscript.cleaned_text,
101
- "required_output_contract": {
102
- "overall_publishability": "int 0..100",
103
- "read_aloud_flow": "int 0..100",
104
- "emotional_truth": "int 0..100",
105
- "visual_strength": "int 0..100",
106
- "commercial_visibility": "int 0..100",
107
- "gate": "string",
108
- "weakest_metric": "string",
109
- "dashboard_message": "string",
110
- "revision_priority_queue": [
111
- {
112
- "block": "string",
113
- "weakest_dimension": "string",
114
- "gate": "string",
115
- "priority": "string",
116
- "recommended_action": "string",
117
- }
118
- ],
119
- "risk_clusters": [
120
- {
121
- "name": "string",
122
- "risk": "High Risk | Medium Risk | Low Risk",
123
- "summary": "string",
124
- }
125
- ],
126
- },
127
  }
128
 
129
  resp = client.chat.completions.create(
@@ -146,17 +208,30 @@ def run_totem_skill(
146
  "model": model,
147
  "word_count": manuscript.word_count,
148
  "raw_response_chars": len(raw),
 
 
 
149
  }
150
  return validated, debug
151
 
152
 
153
  def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  required = [
155
- "overall_publishability",
156
- "read_aloud_flow",
157
- "emotional_truth",
158
- "visual_strength",
159
- "commercial_visibility",
160
  "gate",
161
  "weakest_metric",
162
  "dashboard_message",
@@ -167,16 +242,22 @@ def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
167
  if missing:
168
  raise BridgeValidationError(f"Missing required output fields: {', '.join(missing)}")
169
 
 
 
 
 
 
 
 
 
170
  out = {
171
- "overall_publishability": _clamp_score(payload["overall_publishability"]),
172
- "read_aloud_flow": _clamp_score(payload["read_aloud_flow"]),
173
- "emotional_truth": _clamp_score(payload["emotional_truth"]),
174
- "visual_strength": _clamp_score(payload["visual_strength"]),
175
- "commercial_visibility": _clamp_score(payload["commercial_visibility"]),
176
- "gate": str(payload.get("gate") or "REVISE").strip()[:48],
177
  "weakest_metric": str(payload.get("weakest_metric") or "Read-aloud Flow").strip()[:80],
178
  "dashboard_message": str(payload.get("dashboard_message") or "").strip()[:280],
 
179
  }
 
180
 
181
  rpq = payload.get("revision_priority_queue")
182
  if not isinstance(rpq, list):
@@ -196,17 +277,15 @@ def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> st
196
  return "REVISE"
197
 
198
  metric_key_map = {
199
- "Clarity": None,
200
- "Rhythm": None,
201
  "Read-aloud Flow": "read_aloud_flow",
202
  "Emotional Truth": "emotional_truth",
203
  "Visual Strength": "visual_strength",
204
- "Commercial Publishability": "commercial_visibility",
205
  }
206
  scores = []
207
  for label, key in metric_key_map.items():
208
- if key is None:
209
- continue
210
  score = float(metrics.get(key, 0))
211
  w = float(weights.get(label, 0.0))
212
  scores.append((label, score, w))
@@ -216,11 +295,18 @@ def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> st
216
  weighted = sum(s * w for _, s, w in scores)
217
  low = min(s for _, s, _ in scores)
218
  low_count = sum(1 for _, s, _ in scores if s <= 60)
 
 
 
219
 
220
  if low <= 40:
221
  return "HARD FAIL"
222
  if low_count >= 2:
223
  return "SOFT FAIL"
 
 
 
 
224
  if weighted >= 80 and low >= 70:
225
  return "GREENLIGHT"
226
  return "REVISE"
@@ -229,16 +315,16 @@ def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> st
229
  def _normalize_queue_item(item: Any) -> dict[str, str]:
230
  if not isinstance(item, dict):
231
  return {
232
- "block": "",
233
  "weakest_dimension": "Read-aloud Flow",
234
- "gate": "Soft Fail",
235
  "priority": "Medium",
236
  "recommended_action": "Review and revise.",
237
  }
238
  return {
239
- "block": str(item.get("block") or "").strip()[:48],
240
  "weakest_dimension": str(item.get("weakest_dimension") or "Read-aloud Flow").strip()[:80],
241
- "gate": str(item.get("gate") or "Soft Fail").strip()[:48],
242
  "priority": str(item.get("priority") or "Medium").strip()[:24],
243
  "recommended_action": str(item.get("recommended_action") or "Review and revise.").strip()[:280],
244
  }
@@ -253,7 +339,7 @@ def _normalize_cluster_item(item: Any) -> dict[str, Any]:
253
  "sparkline": [8, 10, 9, 11, 12, 10, 9, 11],
254
  }
255
  risk = str(item.get("risk") or "Medium Risk").strip()
256
- if risk not in {"High Risk", "Medium Risk", "Low Risk"}:
257
  risk = "Medium Risk"
258
  return {
259
  "name": str(item.get("name") or "Risk").strip()[:60],
@@ -263,6 +349,50 @@ def _normalize_cluster_item(item: Any) -> dict[str, Any]:
263
  }
264
 
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  def _safe_float(value: Any) -> float | None:
267
  try:
268
  if value is None or value == "":
 
12
  from src.totem_workbook import METRICS, protocol_table, protocol_weights
13
 
14
 
15
+ APP_ROOT = Path(__file__).resolve().parents[1]
16
+ DEFAULT_SKILL_PATH = APP_ROOT / "data" / "skills" / "TOTEM_Manuscript_Scoring_Skill.md"
17
+
18
+ SCORE_FIELDS = (
19
+ "clarity",
20
+ "rhythm",
21
+ "read_aloud_flow",
22
+ "emotional_truth",
23
+ "visual_strength",
24
+ "commercial_viability",
25
+ )
26
+
27
+ SCORE_LABELS = {
28
+ "clarity": "Clarity",
29
+ "rhythm": "Rhythm",
30
+ "read_aloud_flow": "Read-aloud Flow",
31
+ "emotional_truth": "Emotional Truth",
32
+ "visual_strength": "Visual Strength",
33
+ "commercial_viability": "Commercial Viability",
34
+ }
35
+
36
+ VALID_GATES = {"HARD FAIL", "SOFT FAIL", "READ-ALOUD BLOCK", "COMMERCIAL CHECK", "GREENLIGHT", "REVISE"}
37
+ VALID_RISKS = {"High Risk", "Medium Risk", "Low Risk"}
38
+
39
+
40
  class BridgeError(RuntimeError):
41
  pass
42
 
 
83
  return {
84
  "weights": {k: float(v) for k, v in weights.items()},
85
  "metrics": metric_rows,
86
+ "gate_labels": sorted(VALID_GATES),
87
  }
88
 
89
 
 
100
  )
101
 
102
 
103
+ def load_totem_skill(skill_path: str | Path | None = None) -> tuple[str, str]:
104
+ path = Path(skill_path or os.getenv("TOTEM_SKILL_PATH") or DEFAULT_SKILL_PATH)
105
+ if not path.exists():
106
+ raise BridgeError(f"TOTEM scoring skill not found: {path}")
107
+ text = path.read_text(encoding="utf-8").strip()
108
+ if not text:
109
+ raise BridgeError(f"TOTEM scoring skill is empty: {path}")
110
+ return text, str(path)
111
+
112
+
113
+ def dashboard_output_contract() -> dict[str, Any]:
114
+ return {
115
+ "scores": {field: "integer 0..100" for field in SCORE_FIELDS},
116
+ "gate": "HARD FAIL | SOFT FAIL | READ-ALOUD BLOCK | COMMERCIAL CHECK | GREENLIGHT | REVISE",
117
+ "weakest_metric": "one of: Clarity, Rhythm, Read-aloud Flow, Emotional Truth, Visual Strength, Commercial Viability",
118
+ "dashboard_message": "short dashboard status message, max 220 characters",
119
+ "revision_priority_queue": [
120
+ {
121
+ "block": "short block/page/section identifier",
122
+ "weakest_dimension": "scoring dimension name",
123
+ "gate": "gate label",
124
+ "priority": "High | Medium | Low",
125
+ "recommended_action": "specific rewrite action, max 240 characters",
126
+ }
127
+ ],
128
+ "risk_clusters": [
129
+ {
130
+ "name": "risk cluster name",
131
+ "risk": "High Risk | Medium Risk | Low Risk",
132
+ "summary": "short evidence-based risk summary",
133
+ }
134
+ ],
135
+ "evidence_summary": "brief evidence summary, max 500 characters",
136
+ }
137
+
138
+
139
  def run_totem_skill(
140
  *,
141
  rubric_matrix: dict[str, Any],
142
  manuscript: ManuscriptContext,
143
  ) -> tuple[dict[str, Any], dict[str, Any]]:
144
+ skill_text, skill_path = load_totem_skill()
145
+
146
+ if _env_truthy("TOTEM_FAKE_API"):
147
+ raw_payload = _fake_dashboard_payload(manuscript)
148
+ validated = validate_dashboard_payload(raw_payload)
149
+ return validated, {
150
+ "model": "fake-audit",
151
+ "word_count": manuscript.word_count,
152
+ "raw_response_chars": len(json.dumps(raw_payload)),
153
+ "skill_path": skill_path,
154
+ "skill_loaded": True,
155
+ "schema_pass": True,
156
+ }
157
+
158
  api_key = os.getenv("OPENAI_API_KEY")
159
  if not api_key:
160
  raise BridgeError("OPENAI_API_KEY is not configured.")
 
164
  model = os.getenv("TOTEM_OPENAI_MODEL", "gpt-4.1-mini")
165
  client = OpenAI(api_key=api_key)
166
 
167
+ system_prompt = f"""
168
+ You are the hidden TOTEM Analysis skill inside TOTEM Studio.
169
+ Use the skill instructions below as the scoring method, but do not create a DOCX report for this dashboard run.
170
+ Return strict JSON only. No prose. No markdown. No code fences.
171
+ Use the workbook matrix as scoring authority and manuscript text as evidence.
172
+
173
+ --- TOTEM SKILL INSTRUCTIONS ---
174
+ {skill_text}
175
+ --- END TOTEM SKILL INSTRUCTIONS ---
176
+ """.strip()
177
+
178
  user_payload = {
179
+ "task": "score_manuscript_against_workbook_matrix_for_dashboard",
180
  "rubric_matrix": rubric_matrix,
181
  "manuscript_cleaned_text": manuscript.cleaned_text,
182
+ "scoring_dimensions": SCORE_LABELS,
183
+ "required_output_contract": dashboard_output_contract(),
184
+ "instruction": (
185
+ "Return exactly one JSON object matching required_output_contract. "
186
+ "Scores must be 0..100 dashboard values for the six dimensions. "
187
+ "Do not write a report and do not include manuscript excerpts."
188
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
 
191
  resp = client.chat.completions.create(
 
208
  "model": model,
209
  "word_count": manuscript.word_count,
210
  "raw_response_chars": len(raw),
211
+ "skill_path": skill_path,
212
+ "skill_loaded": True,
213
+ "schema_pass": True,
214
  }
215
  return validated, debug
216
 
217
 
218
  def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
219
+ if not isinstance(payload, dict):
220
+ raise BridgeValidationError("Dashboard payload must be a JSON object.")
221
+
222
+ scores = payload.get("scores")
223
+ if not isinstance(scores, dict):
224
+ # Backward-compatible normalization for older flat model responses.
225
+ scores = {
226
+ "clarity": payload.get("clarity"),
227
+ "rhythm": payload.get("rhythm"),
228
+ "read_aloud_flow": payload.get("read_aloud_flow"),
229
+ "emotional_truth": payload.get("emotional_truth"),
230
+ "visual_strength": payload.get("visual_strength"),
231
+ "commercial_viability": payload.get("commercial_viability", payload.get("commercial_visibility")),
232
+ }
233
+
234
  required = [
 
 
 
 
 
235
  "gate",
236
  "weakest_metric",
237
  "dashboard_message",
 
242
  if missing:
243
  raise BridgeValidationError(f"Missing required output fields: {', '.join(missing)}")
244
 
245
+ missing_scores = [field for field in SCORE_FIELDS if field not in scores or scores.get(field) in (None, "")]
246
+ if missing_scores:
247
+ raise BridgeValidationError(f"Missing required score fields: {', '.join(missing_scores)}")
248
+
249
+ gate = str(payload.get("gate") or "REVISE").strip().upper()
250
+ if gate not in VALID_GATES:
251
+ gate = "REVISE"
252
+
253
  out = {
254
+ "scores": {field: _clamp_score(scores[field]) for field in SCORE_FIELDS},
255
+ "gate": gate,
 
 
 
 
256
  "weakest_metric": str(payload.get("weakest_metric") or "Read-aloud Flow").strip()[:80],
257
  "dashboard_message": str(payload.get("dashboard_message") or "").strip()[:280],
258
+ "evidence_summary": str(payload.get("evidence_summary") or "").strip()[:500],
259
  }
260
+ out.update(out["scores"])
261
 
262
  rpq = payload.get("revision_priority_queue")
263
  if not isinstance(rpq, list):
 
277
  return "REVISE"
278
 
279
  metric_key_map = {
280
+ "Clarity": "clarity",
281
+ "Rhythm": "rhythm",
282
  "Read-aloud Flow": "read_aloud_flow",
283
  "Emotional Truth": "emotional_truth",
284
  "Visual Strength": "visual_strength",
285
+ "Commercial Publishability": "commercial_viability",
286
  }
287
  scores = []
288
  for label, key in metric_key_map.items():
 
 
289
  score = float(metrics.get(key, 0))
290
  w = float(weights.get(label, 0.0))
291
  scores.append((label, score, w))
 
295
  weighted = sum(s * w for _, s, w in scores)
296
  low = min(s for _, s, _ in scores)
297
  low_count = sum(1 for _, s, _ in scores if s <= 60)
298
+ rhythm = float(metrics.get("rhythm", 0))
299
+ flow = float(metrics.get("read_aloud_flow", 0))
300
+ commercial = float(metrics.get("commercial_viability", 0))
301
 
302
  if low <= 40:
303
  return "HARD FAIL"
304
  if low_count >= 2:
305
  return "SOFT FAIL"
306
+ if rhythm < 70 or flow < 70:
307
+ return "READ-ALOUD BLOCK"
308
+ if commercial < 70:
309
+ return "COMMERCIAL CHECK"
310
  if weighted >= 80 and low >= 70:
311
  return "GREENLIGHT"
312
  return "REVISE"
 
315
  def _normalize_queue_item(item: Any) -> dict[str, str]:
316
  if not isinstance(item, dict):
317
  return {
318
+ "block": "-",
319
  "weakest_dimension": "Read-aloud Flow",
320
+ "gate": "REVISE",
321
  "priority": "Medium",
322
  "recommended_action": "Review and revise.",
323
  }
324
  return {
325
+ "block": str(item.get("block") or "-").strip()[:48],
326
  "weakest_dimension": str(item.get("weakest_dimension") or "Read-aloud Flow").strip()[:80],
327
+ "gate": str(item.get("gate") or "REVISE").strip()[:48],
328
  "priority": str(item.get("priority") or "Medium").strip()[:24],
329
  "recommended_action": str(item.get("recommended_action") or "Review and revise.").strip()[:280],
330
  }
 
339
  "sparkline": [8, 10, 9, 11, 12, 10, 9, 11],
340
  }
341
  risk = str(item.get("risk") or "Medium Risk").strip()
342
+ if risk not in VALID_RISKS:
343
  risk = "Medium Risk"
344
  return {
345
  "name": str(item.get("name") or "Risk").strip()[:60],
 
349
  }
350
 
351
 
352
+ def _fake_dashboard_payload(manuscript: ManuscriptContext) -> dict[str, Any]:
353
+ word_count = max(1, manuscript.word_count)
354
+ base = max(45, min(88, 62 + (word_count // 160)))
355
+ long_sentence_penalty = 8 if word_count > 850 else 3
356
+ return {
357
+ "scores": {
358
+ "clarity": max(0, min(100, base - long_sentence_penalty)),
359
+ "rhythm": max(0, min(100, base - 11)),
360
+ "read_aloud_flow": max(0, min(100, base - 7)),
361
+ "emotional_truth": max(0, min(100, base + 5)),
362
+ "visual_strength": max(0, min(100, base + 8)),
363
+ "commercial_viability": max(0, min(100, base + 1)),
364
+ },
365
+ "gate": "REVISE",
366
+ "weakest_metric": "Rhythm",
367
+ "dashboard_message": "TOTEM analysis complete using fake audit response.",
368
+ "revision_priority_queue": [
369
+ {
370
+ "block": "Opening third",
371
+ "weakest_dimension": "Rhythm",
372
+ "gate": "REVISE",
373
+ "priority": "High",
374
+ "recommended_action": "Run a read-aloud pass and cut any line that stalls the beat.",
375
+ },
376
+ {
377
+ "block": "Middle turn",
378
+ "weakest_dimension": "Clarity",
379
+ "gate": "REVISE",
380
+ "priority": "Medium",
381
+ "recommended_action": "Clarify the character action before adding extra comic business.",
382
+ },
383
+ ],
384
+ "risk_clusters": [
385
+ {"name": "Rhythm", "risk": "High Risk", "summary": "Read-aloud pressure likely clusters around longer lines."},
386
+ {"name": "Clarity", "risk": "Medium Risk", "summary": "Some manuscript beats may need cleaner cause and effect."},
387
+ ],
388
+ "evidence_summary": f"Fake audit used {word_count} manuscript words.",
389
+ }
390
+
391
+
392
+ def _env_truthy(name: str) -> bool:
393
+ return str(os.getenv(name, "")).strip().lower() in {"1", "true", "yes", "on"}
394
+
395
+
396
  def _safe_float(value: Any) -> float | None:
397
  try:
398
  if value is None or value == "":