Pointf5ive commited on
Commit
a453059
·
1 Parent(s): 74365bc

Add TOTEM workbook-manuscript bridge with strict JSON validation and dashboard wiring

Browse files
Files changed (3) hide show
  1. app.py +122 -4
  2. requirements.txt +1 -0
  3. 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 run extraction and refresh workbook scoring context."
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(run_analysis, inputs=[active_path],
3110
- outputs=[dashboard, log_state, score_status])
 
 
 
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))