Spaces:
Sleeping
Sleeping
Pointf5ive commited on
Commit ·
83a57b2
1
Parent(s): 7af990b
Wire TOTEM skill scoring to dashboard metrics
Browse files- app.py +79 -62
- data/skills/TOTEM_Manuscript_Scoring_Skill.md +70 -0
- src/totem_bridge.py +184 -54
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(
|
| 1221 |
-
gap:
|
| 1222 |
}
|
| 1223 |
|
| 1224 |
.totem-metric-card {
|
| 1225 |
-
min-height:
|
| 1226 |
-
padding:
|
| 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:
|
| 1236 |
-
gap:
|
| 1237 |
align-items: center;
|
| 1238 |
}
|
| 1239 |
|
| 1240 |
.totem-metric-icon {
|
| 1241 |
-
width:
|
| 1242 |
-
height:
|
| 1243 |
border-radius: 999px;
|
| 1244 |
display: grid;
|
| 1245 |
place-items: center;
|
| 1246 |
color: #fff;
|
| 1247 |
-
font-size:
|
| 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:
|
| 1255 |
color: rgba(246, 241, 232, 0.95);
|
| 1256 |
}
|
| 1257 |
|
| 1258 |
.totem-metric-score {
|
| 1259 |
margin-top: 12px;
|
| 1260 |
-
font-size:
|
| 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:
|
| 1268 |
color: rgba(174, 183, 204, 0.95);
|
| 1269 |
}
|
| 1270 |
|
| 1271 |
.totem-metric-bars {
|
| 1272 |
display: flex;
|
| 1273 |
align-items: end;
|
| 1274 |
-
gap:
|
| 1275 |
-
height:
|
| 1276 |
-
margin-top:
|
| 1277 |
}
|
| 1278 |
|
| 1279 |
.totem-metric-bars span {
|
| 1280 |
-
width:
|
| 1281 |
border-radius: 3px 3px 0 0;
|
| 1282 |
background: var(--metric-color);
|
| 1283 |
opacity: .95;
|
| 1284 |
}
|
| 1285 |
|
| 1286 |
.totem-metric-hint {
|
| 1287 |
-
margin-top:
|
| 1288 |
-
font-size:
|
| 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 |
-
"
|
| 1551 |
-
"
|
| 1552 |
-
"
|
| 1553 |
-
"
|
| 1554 |
-
"
|
|
|
|
| 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 |
-
("
|
| 1886 |
-
("
|
|
|
|
| 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 |
-
("
|
|
|
|
| 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 |
-
|
| 3076 |
-
|
| 3077 |
-
|
| 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 |
-
|
| 3165 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3203 |
-
|
| 3204 |
-
|
|
|
|
|
|
|
| 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
|
| 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":
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
user_payload = {
|
| 98 |
-
"task": "
|
| 99 |
"rubric_matrix": rubric_matrix,
|
| 100 |
"manuscript_cleaned_text": manuscript.cleaned_text,
|
| 101 |
-
"
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
-
|
| 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 |
-
"
|
| 172 |
-
"
|
| 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":
|
| 200 |
-
"Rhythm":
|
| 201 |
"Read-aloud Flow": "read_aloud_flow",
|
| 202 |
"Emotional Truth": "emotional_truth",
|
| 203 |
"Visual Strength": "visual_strength",
|
| 204 |
-
"Commercial Publishability": "
|
| 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": "
|
| 235 |
"priority": "Medium",
|
| 236 |
"recommended_action": "Review and revise.",
|
| 237 |
}
|
| 238 |
return {
|
| 239 |
-
"block": str(item.get("block") or "
|
| 240 |
"weakest_dimension": str(item.get("weakest_dimension") or "Read-aloud Flow").strip()[:80],
|
| 241 |
-
"gate": str(item.get("gate") or "
|
| 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
|
| 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 == "":
|