Spaces:
Sleeping
Sleeping
Pointf5ive commited on
Commit ·
a453059
1
Parent(s): 74365bc
Add TOTEM workbook-manuscript bridge with strict JSON validation and dashboard wiring
Browse files- app.py +122 -4
- requirements.txt +1 -0
- src/totem_bridge.py +280 -0
app.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import hashlib
|
| 4 |
import html
|
| 5 |
import json
|
|
|
|
| 6 |
import re
|
| 7 |
from shutil import copy2
|
| 8 |
from html import escape
|
|
@@ -28,6 +29,14 @@ from src.totem_workbook import (
|
|
| 28 |
)
|
| 29 |
|
| 30 |
from src.codex_extractor import process_upload, format_fingerprint_report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from smoke_signal_tab import smoke_signal_tab, SS_CSS
|
| 32 |
|
| 33 |
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
|
|
@@ -2783,6 +2792,39 @@ def run_analysis(active_path: str):
|
|
| 2783 |
return render_dashboard(state), log_df, _score_summary(log_df)
|
| 2784 |
|
| 2785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2786 |
def analyze_manuscript_and_refresh(
|
| 2787 |
file_obj,
|
| 2788 |
author_name: str,
|
|
@@ -2826,11 +2868,69 @@ def analyze_manuscript_for_dashboard(file_obj, active_path: str):
|
|
| 2826 |
author_id=author_id,
|
| 2827 |
works_sampled=works_sampled,
|
| 2828 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2829 |
dashboard_status = (
|
| 2830 |
f"{status} | Draft Mode active: extraction complete. "
|
| 2831 |
"No auto-scoring was run. Click 'Run TOTEM Analysis' to score."
|
| 2832 |
)
|
| 2833 |
-
return report, json_out, dashboard_status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2834 |
|
| 2835 |
|
| 2836 |
def recalc_log(log_df, active_path: str):
|
|
@@ -2875,6 +2975,7 @@ def initial_dashboard_html() -> str:
|
|
| 2875 |
with gr.Blocks(title="TOTEM Studio") as demo:
|
| 2876 |
active_path = gr.State(str(_preferred_workbook_path()))
|
| 2877 |
log_state = gr.State(pd.DataFrame(columns=LOG_COLUMNS))
|
|
|
|
| 2878 |
|
| 2879 |
with gr.Tabs():
|
| 2880 |
|
|
@@ -2919,7 +3020,7 @@ with gr.Blocks(title="TOTEM Studio") as demo:
|
|
| 2919 |
with gr.Accordion("Manuscript Intake (score pipeline)", open=False, elem_id="codex-panel"):
|
| 2920 |
with gr.Column(elem_classes=["codex-body"]):
|
| 2921 |
gr.Markdown(
|
| 2922 |
-
"Upload manuscript here to
|
| 2923 |
)
|
| 2924 |
dashboard_manuscript_file = gr.File(
|
| 2925 |
label="Manuscript (.txt, .docx, or .pdf)",
|
|
@@ -2946,6 +3047,13 @@ with gr.Blocks(title="TOTEM Studio") as demo:
|
|
| 2946 |
interactive=False,
|
| 2947 |
lines=10,
|
| 2948 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2949 |
|
| 2950 |
with gr.Row(elem_id="path-panel"):
|
| 2951 |
with gr.Column(elem_classes=["wrap"]):
|
|
@@ -3106,8 +3214,11 @@ with gr.Blocks(title="TOTEM Studio") as demo:
|
|
| 3106 |
inputs=[dashboard_workbook_file],
|
| 3107 |
outputs=[workbook_home_display, path_input, active_path, dashboard, log_state, score_status],
|
| 3108 |
)
|
| 3109 |
-
run_button.click(
|
| 3110 |
-
|
|
|
|
|
|
|
|
|
|
| 3111 |
path_button.click(load_local_path, inputs=[path_input],
|
| 3112 |
outputs=[active_path, dashboard, log_state, score_status])
|
| 3113 |
dashboard_manuscript_analyze.click(
|
|
@@ -3117,8 +3228,15 @@ with gr.Blocks(title="TOTEM Studio") as demo:
|
|
| 3117 |
dashboard_manuscript_report,
|
| 3118 |
dashboard_manuscript_json,
|
| 3119 |
dashboard_manuscript_status,
|
|
|
|
| 3120 |
],
|
| 3121 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3122 |
recalc_button.click(recalc_log, inputs=[log_state, active_path],
|
| 3123 |
outputs=[dashboard, log_state, score_status])
|
| 3124 |
export_button.click(export_log, inputs=[log_state, active_path], outputs=[exported_file])
|
|
|
|
| 3 |
import hashlib
|
| 4 |
import html
|
| 5 |
import json
|
| 6 |
+
import os
|
| 7 |
import re
|
| 8 |
from shutil import copy2
|
| 9 |
from html import escape
|
|
|
|
| 29 |
)
|
| 30 |
|
| 31 |
from src.codex_extractor import process_upload, format_fingerprint_report
|
| 32 |
+
from src.totem_bridge import (
|
| 33 |
+
BridgeError,
|
| 34 |
+
ManuscriptContext,
|
| 35 |
+
extract_manuscript_context,
|
| 36 |
+
extract_workbook_matrix,
|
| 37 |
+
recompute_gate,
|
| 38 |
+
run_totem_skill,
|
| 39 |
+
)
|
| 40 |
from smoke_signal_tab import smoke_signal_tab, SS_CSS
|
| 41 |
|
| 42 |
ORIGINAL_WORKBOOK_PATH = "data/order69_macmillan_totem_rebuilt.xlsx"
|
|
|
|
| 2792 |
return render_dashboard(state), log_df, _score_summary(log_df)
|
| 2793 |
|
| 2794 |
|
| 2795 |
+
def _apply_llm_dashboard_state(
|
| 2796 |
+
*,
|
| 2797 |
+
active_path: str,
|
| 2798 |
+
llm_state: dict,
|
| 2799 |
+
notice: str = "",
|
| 2800 |
+
) -> tuple[str, pd.DataFrame, str, str]:
|
| 2801 |
+
path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
|
| 2802 |
+
log_df = score_log(path)
|
| 2803 |
+
state = _build_dashboard_state_from_workbook(path, "complete", notice)
|
| 2804 |
+
state["metrics"]["overall_publishability"] = int(llm_state["overall_publishability"])
|
| 2805 |
+
state["metrics"]["read_aloud_flow"] = int(llm_state["read_aloud_flow"])
|
| 2806 |
+
state["metrics"]["emotional_truth"] = int(llm_state["emotional_truth"])
|
| 2807 |
+
state["metrics"]["visual_strength"] = int(llm_state["visual_strength"])
|
| 2808 |
+
state["metrics"]["commercial_viability"] = int(llm_state["commercial_visibility"])
|
| 2809 |
+
state["revision_queue"] = list(llm_state.get("revision_priority_queue") or [])
|
| 2810 |
+
state["risk_clusters"] = list(llm_state.get("risk_clusters") or [])
|
| 2811 |
+
state["analysis_status"] = "complete"
|
| 2812 |
+
if os.getenv("TOTEM_RECOMPUTE_GATES", "1").strip() not in {"0", "false", "False"}:
|
| 2813 |
+
recomputed = recompute_gate(
|
| 2814 |
+
{
|
| 2815 |
+
"read_aloud_flow": state["metrics"]["read_aloud_flow"],
|
| 2816 |
+
"emotional_truth": state["metrics"]["emotional_truth"],
|
| 2817 |
+
"visual_strength": state["metrics"]["visual_strength"],
|
| 2818 |
+
"commercial_visibility": state["metrics"]["commercial_viability"],
|
| 2819 |
+
},
|
| 2820 |
+
extract_workbook_matrix(path),
|
| 2821 |
+
)
|
| 2822 |
+
gate_summary = f"Gate={llm_state.get('gate','REVISE')} | Recomputed={recomputed}"
|
| 2823 |
+
else:
|
| 2824 |
+
gate_summary = f"Gate={llm_state.get('gate','REVISE')}"
|
| 2825 |
+
return render_dashboard(state), log_df, _score_summary(log_df), gate_summary
|
| 2826 |
+
|
| 2827 |
+
|
| 2828 |
def analyze_manuscript_and_refresh(
|
| 2829 |
file_obj,
|
| 2830 |
author_name: str,
|
|
|
|
| 2868 |
author_id=author_id,
|
| 2869 |
works_sampled=works_sampled,
|
| 2870 |
)
|
| 2871 |
+
manuscript_ctx = extract_manuscript_context(file_path)
|
| 2872 |
+
manuscript_state = {
|
| 2873 |
+
"path": manuscript_ctx.path,
|
| 2874 |
+
"cleaned_text": manuscript_ctx.cleaned_text,
|
| 2875 |
+
"word_count": manuscript_ctx.word_count,
|
| 2876 |
+
}
|
| 2877 |
dashboard_status = (
|
| 2878 |
f"{status} | Draft Mode active: extraction complete. "
|
| 2879 |
"No auto-scoring was run. Click 'Run TOTEM Analysis' to score."
|
| 2880 |
)
|
| 2881 |
+
return report, json_out, dashboard_status, json.dumps(manuscript_state, ensure_ascii=False)
|
| 2882 |
+
|
| 2883 |
+
|
| 2884 |
+
def run_totem_analysis_from_context(active_path: str, manuscript_context_json: str):
|
| 2885 |
+
"""
|
| 2886 |
+
Full bridge run:
|
| 2887 |
+
- workbook matrix is source of truth
|
| 2888 |
+
- manuscript context is evidence
|
| 2889 |
+
- hidden OpenAI skill returns strict JSON
|
| 2890 |
+
- validated JSON updates dashboard
|
| 2891 |
+
"""
|
| 2892 |
+
if not manuscript_context_json:
|
| 2893 |
+
dashboard_html, log_df, summary = run_analysis(active_path)
|
| 2894 |
+
return dashboard_html, log_df, summary, "No manuscript context found. Ran workbook-only refresh."
|
| 2895 |
+
|
| 2896 |
+
try:
|
| 2897 |
+
ctx_payload = json.loads(manuscript_context_json)
|
| 2898 |
+
cleaned_text = str(ctx_payload.get("cleaned_text") or "").strip()
|
| 2899 |
+
if not cleaned_text:
|
| 2900 |
+
raise BridgeError("Manuscript cleaned text is empty.")
|
| 2901 |
+
ctx_path = str(ctx_payload.get("path") or "").strip()
|
| 2902 |
+
if ctx_path and Path(ctx_path).exists():
|
| 2903 |
+
ctx = extract_manuscript_context(ctx_path)
|
| 2904 |
+
else:
|
| 2905 |
+
ctx = ManuscriptContext(
|
| 2906 |
+
path=ctx_path or "(session)",
|
| 2907 |
+
raw_text=cleaned_text,
|
| 2908 |
+
cleaned_text=cleaned_text,
|
| 2909 |
+
word_count=len(cleaned_text.split()),
|
| 2910 |
+
page_trace=[],
|
| 2911 |
+
)
|
| 2912 |
+
|
| 2913 |
+
path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
|
| 2914 |
+
rubric = extract_workbook_matrix(path)
|
| 2915 |
+
llm_state, debug = run_totem_skill(rubric_matrix=rubric, manuscript=ctx)
|
| 2916 |
+
dashboard_html, log_df, summary, gate_summary = _apply_llm_dashboard_state(
|
| 2917 |
+
active_path=active_path,
|
| 2918 |
+
llm_state=llm_state,
|
| 2919 |
+
notice="TOTEM analysis complete (workbook + manuscript).",
|
| 2920 |
+
)
|
| 2921 |
+
debug_log = {
|
| 2922 |
+
"status": "ok",
|
| 2923 |
+
"gate_summary": gate_summary,
|
| 2924 |
+
"model": debug.get("model"),
|
| 2925 |
+
"manuscript_words": debug.get("word_count"),
|
| 2926 |
+
"manuscript_path": ctx.path,
|
| 2927 |
+
"rubric_metrics": len(rubric.get("metrics", [])),
|
| 2928 |
+
}
|
| 2929 |
+
return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
|
| 2930 |
+
except Exception as exc:
|
| 2931 |
+
dashboard_html, log_df, summary = run_analysis(active_path)
|
| 2932 |
+
debug_log = {"status": "error", "error": f"{type(exc).__name__}: {exc}"}
|
| 2933 |
+
return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
|
| 2934 |
|
| 2935 |
|
| 2936 |
def recalc_log(log_df, active_path: str):
|
|
|
|
| 2975 |
with gr.Blocks(title="TOTEM Studio") as demo:
|
| 2976 |
active_path = gr.State(str(_preferred_workbook_path()))
|
| 2977 |
log_state = gr.State(pd.DataFrame(columns=LOG_COLUMNS))
|
| 2978 |
+
manuscript_context_state = gr.State("")
|
| 2979 |
|
| 2980 |
with gr.Tabs():
|
| 2981 |
|
|
|
|
| 3020 |
with gr.Accordion("Manuscript Intake (score pipeline)", open=False, elem_id="codex-panel"):
|
| 3021 |
with gr.Column(elem_classes=["codex-body"]):
|
| 3022 |
gr.Markdown(
|
| 3023 |
+
"Upload manuscript here to build analysis context. Scoring updates only when you click Run TOTEM Analysis."
|
| 3024 |
)
|
| 3025 |
dashboard_manuscript_file = gr.File(
|
| 3026 |
label="Manuscript (.txt, .docx, or .pdf)",
|
|
|
|
| 3047 |
interactive=False,
|
| 3048 |
lines=10,
|
| 3049 |
)
|
| 3050 |
+
bridge_diag_log = gr.Textbox(
|
| 3051 |
+
label="Bridge diagnostics (for bug reports)",
|
| 3052 |
+
interactive=False,
|
| 3053 |
+
lines=8,
|
| 3054 |
+
value='{"status":"idle","message":"No analysis run yet."}',
|
| 3055 |
+
)
|
| 3056 |
+
copy_bridge_log_btn = gr.Button("Copy Bridge Log", variant="secondary")
|
| 3057 |
|
| 3058 |
with gr.Row(elem_id="path-panel"):
|
| 3059 |
with gr.Column(elem_classes=["wrap"]):
|
|
|
|
| 3214 |
inputs=[dashboard_workbook_file],
|
| 3215 |
outputs=[workbook_home_display, path_input, active_path, dashboard, log_state, score_status],
|
| 3216 |
)
|
| 3217 |
+
run_button.click(
|
| 3218 |
+
run_totem_analysis_from_context,
|
| 3219 |
+
inputs=[active_path, manuscript_context_state],
|
| 3220 |
+
outputs=[dashboard, log_state, score_status, bridge_diag_log],
|
| 3221 |
+
)
|
| 3222 |
path_button.click(load_local_path, inputs=[path_input],
|
| 3223 |
outputs=[active_path, dashboard, log_state, score_status])
|
| 3224 |
dashboard_manuscript_analyze.click(
|
|
|
|
| 3228 |
dashboard_manuscript_report,
|
| 3229 |
dashboard_manuscript_json,
|
| 3230 |
dashboard_manuscript_status,
|
| 3231 |
+
manuscript_context_state,
|
| 3232 |
],
|
| 3233 |
)
|
| 3234 |
+
copy_bridge_log_btn.click(
|
| 3235 |
+
lambda v: v,
|
| 3236 |
+
inputs=[bridge_diag_log],
|
| 3237 |
+
outputs=[bridge_diag_log],
|
| 3238 |
+
js="""(v) => { if (v) { navigator.clipboard.writeText(v); } return v; }""",
|
| 3239 |
+
)
|
| 3240 |
recalc_button.click(recalc_log, inputs=[log_state, active_path],
|
| 3241 |
outputs=[dashboard, log_state, score_status])
|
| 3242 |
export_button.click(export_log, inputs=[log_state, active_path], outputs=[exported_file])
|
requirements.txt
CHANGED
|
@@ -2,6 +2,7 @@ gradio>=4.44,<7
|
|
| 2 |
huggingface_hub<1.0
|
| 3 |
nltk>=3.8
|
| 4 |
openpyxl>=3.1
|
|
|
|
| 5 |
pandas>=2.2
|
| 6 |
pdfplumber>=0.10
|
| 7 |
Pillow>=10.0.0
|
|
|
|
| 2 |
huggingface_hub<1.0
|
| 3 |
nltk>=3.8
|
| 4 |
openpyxl>=3.1
|
| 5 |
+
openai>=1.30.0
|
| 6 |
pandas>=2.2
|
| 7 |
pdfplumber>=0.10
|
| 8 |
Pillow>=10.0.0
|
src/totem_bridge.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
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 |
+
|
| 18 |
+
|
| 19 |
+
class BridgeValidationError(BridgeError):
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ManuscriptContext:
|
| 25 |
+
path: str
|
| 26 |
+
raw_text: str
|
| 27 |
+
cleaned_text: str
|
| 28 |
+
word_count: int
|
| 29 |
+
page_trace: list[dict[str, Any]]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def extract_workbook_matrix(path: Path) -> dict[str, Any]:
|
| 33 |
+
if not path.exists():
|
| 34 |
+
raise BridgeError(f"Workbook not found: {path}")
|
| 35 |
+
weights = protocol_weights(path)
|
| 36 |
+
protocol_df = protocol_table(path)
|
| 37 |
+
|
| 38 |
+
metric_rows: list[dict[str, Any]] = []
|
| 39 |
+
if protocol_df is not None and not protocol_df.empty:
|
| 40 |
+
for _, row in protocol_df.iterrows():
|
| 41 |
+
metric = str(row.get("Metric", "")).strip()
|
| 42 |
+
if metric not in METRICS:
|
| 43 |
+
continue
|
| 44 |
+
metric_rows.append(
|
| 45 |
+
{
|
| 46 |
+
"metric": metric,
|
| 47 |
+
"weight": float(weights.get(metric, 0.0)),
|
| 48 |
+
"target": _safe_float(row.get("Target")),
|
| 49 |
+
"min": _safe_float(row.get("Min")),
|
| 50 |
+
"max": _safe_float(row.get("Max")),
|
| 51 |
+
"gate_hint": str(row.get("Gate") or "").strip(),
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
if not metric_rows:
|
| 56 |
+
metric_rows = [{"metric": m, "weight": float(weights.get(m, 0.0))} for m in METRICS]
|
| 57 |
+
|
| 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 |
+
|
| 65 |
+
def extract_manuscript_context(file_path: str | Path) -> ManuscriptContext:
|
| 66 |
+
path = Path(file_path)
|
| 67 |
+
story_text, raw_text, page_trace = extract_text_from_file(path)
|
| 68 |
+
cleaned = clean_text(story_text or raw_text or "")
|
| 69 |
+
return ManuscriptContext(
|
| 70 |
+
path=str(path),
|
| 71 |
+
raw_text=raw_text or "",
|
| 72 |
+
cleaned_text=cleaned,
|
| 73 |
+
word_count=len(cleaned.split()),
|
| 74 |
+
page_trace=page_trace or [],
|
| 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.")
|
| 86 |
+
|
| 87 |
+
from openai import OpenAI
|
| 88 |
+
|
| 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(
|
| 130 |
+
model=model,
|
| 131 |
+
response_format={"type": "json_object"},
|
| 132 |
+
temperature=0.2,
|
| 133 |
+
messages=[
|
| 134 |
+
{"role": "system", "content": system_prompt},
|
| 135 |
+
{"role": "user", "content": json.dumps(user_payload)},
|
| 136 |
+
],
|
| 137 |
+
)
|
| 138 |
+
raw = (resp.choices[0].message.content or "").strip()
|
| 139 |
+
try:
|
| 140 |
+
parsed = json.loads(raw)
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
raise BridgeValidationError(f"Model returned invalid JSON: {exc}") from exc
|
| 143 |
+
|
| 144 |
+
validated = validate_dashboard_payload(parsed)
|
| 145 |
+
debug = {
|
| 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",
|
| 163 |
+
"revision_priority_queue",
|
| 164 |
+
"risk_clusters",
|
| 165 |
+
]
|
| 166 |
+
missing = [k for k in required if k not in payload]
|
| 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):
|
| 183 |
+
raise BridgeValidationError("revision_priority_queue must be a list.")
|
| 184 |
+
out["revision_priority_queue"] = [_normalize_queue_item(x) for x in rpq[:12]]
|
| 185 |
+
|
| 186 |
+
clusters = payload.get("risk_clusters")
|
| 187 |
+
if not isinstance(clusters, list):
|
| 188 |
+
raise BridgeValidationError("risk_clusters must be a list.")
|
| 189 |
+
out["risk_clusters"] = [_normalize_cluster_item(x) for x in clusters[:8]]
|
| 190 |
+
return out
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> str:
|
| 194 |
+
weights = rubric_matrix.get("weights", {}) if isinstance(rubric_matrix, dict) else {}
|
| 195 |
+
if not isinstance(weights, dict) or not weights:
|
| 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))
|
| 213 |
+
if not scores:
|
| 214 |
+
return "REVISE"
|
| 215 |
+
|
| 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"
|
| 227 |
+
|
| 228 |
+
|
| 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 |
+
}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _normalize_cluster_item(item: Any) -> dict[str, Any]:
|
| 248 |
+
if not isinstance(item, dict):
|
| 249 |
+
return {
|
| 250 |
+
"name": "Rhythm",
|
| 251 |
+
"risk": "Medium Risk",
|
| 252 |
+
"description": "Risk signal detected.",
|
| 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],
|
| 260 |
+
"risk": risk,
|
| 261 |
+
"description": str(item.get("summary") or item.get("description") or "Risk signal detected.").strip()[:220],
|
| 262 |
+
"sparkline": [8, 10, 9, 11, 12, 10, 9, 11],
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _safe_float(value: Any) -> float | None:
|
| 267 |
+
try:
|
| 268 |
+
if value is None or value == "":
|
| 269 |
+
return None
|
| 270 |
+
return float(value)
|
| 271 |
+
except Exception:
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _clamp_score(value: Any) -> int:
|
| 276 |
+
try:
|
| 277 |
+
x = int(round(float(value)))
|
| 278 |
+
except Exception as exc:
|
| 279 |
+
raise BridgeValidationError(f"Invalid numeric score value: {value!r}") from exc
|
| 280 |
+
return max(0, min(100, x))
|