Pointf5ive commited on
Commit
efebee3
·
1 Parent(s): 08ae10d

Ground TOTEM scoring in fingerprint evidence

Browse files
Files changed (2) hide show
  1. app.py +26 -91
  2. src/totem_bridge.py +100 -9
app.py CHANGED
@@ -244,67 +244,6 @@ footer { display: none !important; }
244
  display: none !important;
245
  }
246
 
247
- /* ── Private scoring controls — dark themed (Stage 10) ─────────────────── */
248
- #score-panel {
249
- max-width: 1180px;
250
- margin: 8px auto 24px auto;
251
- padding: 0 18px;
252
- }
253
-
254
- #score-panel > .label-wrap,
255
- #score-panel > details > summary,
256
- #score-panel summary {
257
- background: rgba(13, 19, 48, 0.95) !important;
258
- color: rgba(246, 241, 232, 0.9) !important;
259
- border: 1px solid rgba(242, 193, 78, 0.25) !important;
260
- border-radius: 10px !important;
261
- font-family: var(--font-ui) !important;
262
- font-size: 13px !important;
263
- padding: 10px 16px !important;
264
- }
265
-
266
- #score-panel .score-card {
267
- border: 1px solid rgba(242, 193, 78, 0.18);
268
- background: rgba(7, 18, 45, 0.97);
269
- border-radius: 10px;
270
- padding: 18px;
271
- }
272
-
273
- #score-panel h3 {
274
- margin: 0 0 12px 0;
275
- font-size: 16px;
276
- color: var(--totem-gold);
277
- font-family: var(--font-display);
278
- }
279
-
280
- #score-panel button {
281
- border-radius: 8px !important;
282
- font-weight: 700 !important;
283
- background: rgba(138, 77, 255, 0.25) !important;
284
- border: 1px solid rgba(138, 77, 255, 0.5) !important;
285
- color: #f6f1e8 !important;
286
- }
287
-
288
- #score-panel .wrap {
289
- gap: 12px;
290
- }
291
-
292
- #score-panel label,
293
- #score-panel .block {
294
- background: rgba(7, 18, 45, 0.8) !important;
295
- border-color: rgba(246, 241, 232, 0.12) !important;
296
- color: rgba(246, 241, 232, 0.9) !important;
297
- border-radius: 8px !important;
298
- }
299
-
300
- #score-panel input,
301
- #score-panel textarea {
302
- background: rgba(4, 8, 26, 0.9) !important;
303
- color: #f6f1e8 !important;
304
- border-color: rgba(242, 193, 78, 0.2) !important;
305
- border-radius: 6px !important;
306
- }
307
-
308
  /* ── Global Gradio component dark overrides (Stage 9) ──────────────────── */
309
  .gradio-container input,
310
  .gradio-container textarea,
@@ -3147,8 +3086,14 @@ def _bridge_verification_notes(debug: dict, llm_state: dict) -> dict:
3147
  warnings.append("Fake audit provider returned these numbers; this should never be enabled on Hugging Face Spaces.")
3148
  if not usage_reported:
3149
  warnings.append("Provider did not report token usage, so API completion cannot be cost-audited from usage fields.")
 
 
 
 
3150
  if not str(llm_state.get("evidence_summary") or "").strip():
3151
  warnings.append("Model returned no evidence_summary; score grounding is weak.")
 
 
3152
 
3153
  scores = llm_state.get("scores") if isinstance(llm_state.get("scores"), dict) else {}
3154
  rounded_scores = [score for score in scores.values() if isinstance(score, int) and score % 5 == 0]
@@ -3234,10 +3179,20 @@ def analyze_manuscript_for_dashboard(file_obj, active_path: str):
3234
  works_sampled=works_sampled,
3235
  )
3236
  manuscript_ctx = extract_manuscript_context(file_path)
 
 
 
 
 
 
 
 
3237
  manuscript_state = {
3238
  "path": manuscript_ctx.path,
3239
  "cleaned_text": manuscript_ctx.cleaned_text,
3240
  "word_count": manuscript_ctx.word_count,
 
 
3241
  }
3242
  dashboard_status = (
3243
  f"{status} | Draft Mode active: extraction complete. "
@@ -3267,8 +3222,14 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3267
  if not cleaned_text:
3268
  raise BridgeError("Manuscript cleaned text is empty.")
3269
  ctx_path = str(ctx_payload.get("path") or "").strip()
 
 
 
 
3270
  if ctx_path and Path(ctx_path).exists():
3271
  ctx = extract_manuscript_context(ctx_path)
 
 
3272
  else:
3273
  ctx = ManuscriptContext(
3274
  path=ctx_path or "(session)",
@@ -3276,6 +3237,8 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3276
  cleaned_text=cleaned_text,
3277
  word_count=len(cleaned_text.split()),
3278
  page_trace=[],
 
 
3279
  )
3280
 
3281
  path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
@@ -3295,6 +3258,8 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3295
  "api_key_env": debug.get("api_key_env"),
3296
  "manuscript_words": debug.get("word_count"),
3297
  "manuscript_path": ctx.path,
 
 
3298
  "rubric_metrics": len(rubric.get("metrics", [])),
3299
  "skill_loaded": debug.get("skill_loaded"),
3300
  "skill_path": debug.get("skill_path"),
@@ -3483,26 +3448,6 @@ with gr.Blocks(title="TOTEM Studio") as demo:
3483
  path_input = gr.Textbox(label="Local workbook path", value=str(_preferred_workbook_path()))
3484
  path_button = gr.Button("Load Local Path", variant="primary")
3485
 
3486
- with gr.Accordion("Private scoring controls", open=False, elem_id="score-panel"):
3487
- gr.HTML("<div class='score-card'><h3>Live Score A Block</h3></div>")
3488
- with gr.Row():
3489
- sequence = gr.Textbox(label="Sequence", value="Live pass")
3490
- stanza_id = gr.Textbox(label="Stanza ID", value="New block")
3491
- draft_pass = gr.Textbox(label="Draft / Pass", value="First score")
3492
- with gr.Row():
3493
- clarity = gr.Slider(1, 10, value=7, step=0.5, label="Clarity")
3494
- rhythm = gr.Slider(1, 10, value=7, step=0.5, label="Rhythm")
3495
- flow = gr.Slider(1, 10, value=7, step=0.5, label="Read-aloud Flow")
3496
- with gr.Row():
3497
- emotional_truth = gr.Slider(1, 10, value=7, step=0.5, label="Emotional Truth")
3498
- visual_strength = gr.Slider(1, 10, value=7, step=0.5, label="Visual Strength")
3499
- commercial = gr.Slider(1, 10, value=7, step=0.5, label="Commercial Publishability")
3500
- notes = gr.Textbox(label="Notes", lines=2)
3501
- with gr.Row():
3502
- single_button = gr.Button("Score Block", variant="primary")
3503
- recalc_button = gr.Button("Recalculate Gates")
3504
- export_button = gr.Button("Download Updated Workbook")
3505
- single_df = gr.Dataframe(label="Latest scorecard", interactive=False, visible=False)
3506
  score_status = gr.Markdown(elem_id="hidden-status")
3507
  exported_file = gr.File(label="Export appears here", elem_id="hidden-export")
3508
 
@@ -3660,16 +3605,6 @@ with gr.Blocks(title="TOTEM Studio") as demo:
3660
  outputs=[bridge_diag_log],
3661
  js="""(v) => { if (v) { navigator.clipboard.writeText(v); } return v; }""",
3662
  )
3663
- recalc_button.click(recalc_log, inputs=[log_state, active_path],
3664
- outputs=[dashboard, log_state, score_status])
3665
- export_button.click(export_log, inputs=[log_state, active_path], outputs=[exported_file])
3666
- single_button.click(
3667
- single_score,
3668
- inputs=[active_path, sequence, stanza_id, draft_pass, clarity, rhythm,
3669
- flow, emotional_truth, visual_strength, commercial, notes],
3670
- outputs=[single_df, score_status],
3671
- )
3672
-
3673
  # Codex Extractor tab
3674
  codex_extract_btn.click(
3675
  run_codex_extraction,
 
244
  display: none !important;
245
  }
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  /* ── Global Gradio component dark overrides (Stage 9) ──────────────────── */
248
  .gradio-container input,
249
  .gradio-container textarea,
 
3086
  warnings.append("Fake audit provider returned these numbers; this should never be enabled on Hugging Face Spaces.")
3087
  if not usage_reported:
3088
  warnings.append("Provider did not report token usage, so API completion cannot be cost-audited from usage fields.")
3089
+ if not debug.get("fingerprint_supplied"):
3090
+ warnings.append("Manuscript fingerprint was not supplied to the API, so scores are not VM-grounded.")
3091
+ elif int(debug.get("fingerprint_metric_count") or 0) < 10:
3092
+ warnings.append("Manuscript fingerprint contains fewer VM metrics than expected; score grounding may be partial.")
3093
  if not str(llm_state.get("evidence_summary") or "").strip():
3094
  warnings.append("Model returned no evidence_summary; score grounding is weak.")
3095
+ if not isinstance(llm_state.get("score_evidence"), dict):
3096
+ warnings.append("Model returned no per-score evidence object; scores cannot be traced to workbook/fingerprint inputs.")
3097
 
3098
  scores = llm_state.get("scores") if isinstance(llm_state.get("scores"), dict) else {}
3099
  rounded_scores = [score for score in scores.values() if isinstance(score, int) and score % 5 == 0]
 
3179
  works_sampled=works_sampled,
3180
  )
3181
  manuscript_ctx = extract_manuscript_context(file_path)
3182
+ try:
3183
+ fingerprint_payload = json.loads(json_out) if json_out else {}
3184
+ if not isinstance(fingerprint_payload, dict):
3185
+ fingerprint_payload = {}
3186
+ except Exception:
3187
+ fingerprint_payload = {}
3188
+ manuscript_ctx.fingerprint = fingerprint_payload
3189
+ manuscript_ctx.fingerprint_report = report
3190
  manuscript_state = {
3191
  "path": manuscript_ctx.path,
3192
  "cleaned_text": manuscript_ctx.cleaned_text,
3193
  "word_count": manuscript_ctx.word_count,
3194
+ "fingerprint": fingerprint_payload,
3195
+ "fingerprint_report": report,
3196
  }
3197
  dashboard_status = (
3198
  f"{status} | Draft Mode active: extraction complete. "
 
3222
  if not cleaned_text:
3223
  raise BridgeError("Manuscript cleaned text is empty.")
3224
  ctx_path = str(ctx_payload.get("path") or "").strip()
3225
+ fingerprint_payload = ctx_payload.get("fingerprint")
3226
+ if not isinstance(fingerprint_payload, dict):
3227
+ fingerprint_payload = {}
3228
+ fingerprint_report = str(ctx_payload.get("fingerprint_report") or "")
3229
  if ctx_path and Path(ctx_path).exists():
3230
  ctx = extract_manuscript_context(ctx_path)
3231
+ ctx.fingerprint = fingerprint_payload
3232
+ ctx.fingerprint_report = fingerprint_report
3233
  else:
3234
  ctx = ManuscriptContext(
3235
  path=ctx_path or "(session)",
 
3237
  cleaned_text=cleaned_text,
3238
  word_count=len(cleaned_text.split()),
3239
  page_trace=[],
3240
+ fingerprint=fingerprint_payload,
3241
+ fingerprint_report=fingerprint_report,
3242
  )
3243
 
3244
  path = _validate_workbook_path(Path(active_path) if active_path else _preferred_workbook_path())
 
3258
  "api_key_env": debug.get("api_key_env"),
3259
  "manuscript_words": debug.get("word_count"),
3260
  "manuscript_path": ctx.path,
3261
+ "fingerprint_supplied": debug.get("fingerprint_supplied"),
3262
+ "fingerprint_metric_count": debug.get("fingerprint_metric_count"),
3263
  "rubric_metrics": len(rubric.get("metrics", [])),
3264
  "skill_loaded": debug.get("skill_loaded"),
3265
  "skill_path": debug.get("skill_path"),
 
3448
  path_input = gr.Textbox(label="Local workbook path", value=str(_preferred_workbook_path()))
3449
  path_button = gr.Button("Load Local Path", variant="primary")
3450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3451
  score_status = gr.Markdown(elem_id="hidden-status")
3452
  exported_file = gr.File(label="Export appears here", elem_id="hidden-export")
3453
 
 
3605
  outputs=[bridge_diag_log],
3606
  js="""(v) => { if (v) { navigator.clipboard.writeText(v); } return v; }""",
3607
  )
 
 
 
 
 
 
 
 
 
 
3608
  # Codex Extractor tab
3609
  codex_extract_btn.click(
3610
  run_codex_extraction,
src/totem_bridge.py CHANGED
@@ -62,6 +62,8 @@ class ManuscriptContext:
62
  cleaned_text: str
63
  word_count: int
64
  page_trace: list[dict[str, Any]]
 
 
65
 
66
 
67
  @dataclass
@@ -132,6 +134,16 @@ def load_totem_skill(skill_path: str | Path | None = None) -> tuple[str, str]:
132
  def dashboard_output_contract() -> dict[str, Any]:
133
  return {
134
  "scores": {field: "integer 0..100" for field in SCORE_FIELDS},
 
 
 
 
 
 
 
 
 
 
135
  "gate": "HARD FAIL | SOFT FAIL | READ-ALOUD BLOCK | COMMERCIAL CHECK | GREENLIGHT | REVISE",
136
  "weakest_metric": "one of: Clarity, Rhythm, Read-aloud Flow, Emotional Truth, Visual Strength, Commercial Viability",
137
  "dashboard_message": "short dashboard status message, max 220 characters",
@@ -168,7 +180,7 @@ def run_totem_skill(
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}
@@ -178,12 +190,17 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
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": 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
  }
@@ -221,6 +238,8 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
221
  "skill_path": skill_path,
222
  "skill_loaded": True,
223
  "schema_pass": True,
 
 
224
  "token_audit_path": audit_path,
225
  "token_audit_transaction_id": transaction_id,
226
  "raw_model_payload": raw_payload,
@@ -350,6 +369,8 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
350
  "skill_loaded": True,
351
  "schema_pass": True,
352
  "actual_tokens": actual_tokens,
 
 
353
  "token_audit_path": audit_path,
354
  "token_audit_transaction_id": transaction_id,
355
  "raw_model_payload": parsed,
@@ -435,6 +456,7 @@ def _write_token_audit(
435
  error: str | None,
436
  ) -> str:
437
  rubric_json = compact_json(rubric_matrix)
 
438
  contract_json = compact_json(contract)
439
  user_wrapper_json = compact_json(
440
  {
@@ -447,7 +469,7 @@ def _write_token_audit(
447
  You are the hidden TOTEM Analysis skill inside TOTEM Studio.
448
  Use the skill instructions as the scoring method, but do not create a DOCX report for this dashboard run.
449
  Return strict JSON only. No prose. No markdown. No code fences.
450
- Use the workbook matrix as scoring authority and manuscript text as evidence.
451
  """.strip()
452
 
453
  component_estimates = estimate_components(
@@ -455,6 +477,8 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
455
  "system_wrapper": system_wrapper,
456
  "skill_md": skill_text,
457
  "workbook_rubric_json": rubric_json,
 
 
458
  "manuscript_cleaned_text": manuscript.cleaned_text,
459
  "output_contract_json": contract_json,
460
  "user_wrapper_json": user_wrapper_json,
@@ -487,6 +511,12 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
487
  "chars": len(manuscript.cleaned_text),
488
  "word_count": manuscript.word_count,
489
  },
 
 
 
 
 
 
490
  "estimated_tokens": {
491
  **component_estimates,
492
  "wire_prompt": {
@@ -562,6 +592,11 @@ def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
562
  }
563
  out.update(out["scores"])
564
 
 
 
 
 
 
565
  rpq = payload.get("revision_priority_queue")
566
  if not isinstance(rpq, list):
567
  raise BridgeValidationError("revision_priority_queue must be a list.")
@@ -574,6 +609,45 @@ def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
574
  return out
575
 
576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> str:
578
  weights = rubric_matrix.get("weights", {}) if isinstance(rubric_matrix, dict) else {}
579
  if not isinstance(weights, dict) or not weights:
@@ -656,14 +730,25 @@ def _fake_dashboard_payload(manuscript: ManuscriptContext) -> dict[str, Any]:
656
  word_count = max(1, manuscript.word_count)
657
  base = max(45, min(88, 62 + (word_count // 160)))
658
  long_sentence_penalty = 8 if word_count > 850 else 3
 
 
 
 
 
 
 
 
659
  return {
660
- "scores": {
661
- "clarity": max(0, min(100, base - long_sentence_penalty)),
662
- "rhythm": max(0, min(100, base - 11)),
663
- "read_aloud_flow": max(0, min(100, base - 7)),
664
- "emotional_truth": max(0, min(100, base + 5)),
665
- "visual_strength": max(0, min(100, base + 8)),
666
- "commercial_viability": max(0, min(100, base + 1)),
 
 
 
667
  },
668
  "gate": "REVISE",
669
  "weakest_metric": "Rhythm",
@@ -711,3 +796,9 @@ def _clamp_score(value: Any) -> int:
711
  except Exception as exc:
712
  raise BridgeValidationError(f"Invalid numeric score value: {value!r}") from exc
713
  return max(0, min(100, x))
 
 
 
 
 
 
 
62
  cleaned_text: str
63
  word_count: int
64
  page_trace: list[dict[str, Any]]
65
+ fingerprint: dict[str, Any] | None = None
66
+ fingerprint_report: str = ""
67
 
68
 
69
  @dataclass
 
134
  def dashboard_output_contract() -> dict[str, Any]:
135
  return {
136
  "scores": {field: "integer 0..100" for field in SCORE_FIELDS},
137
+ "score_evidence": {
138
+ field: {
139
+ "score": "integer 0..100 matching scores.<field>",
140
+ "fingerprint_metrics_used": ["VM metric ids used, e.g. VM-001, VM-010, VM-025"],
141
+ "workbook_rule": "target/threshold/rubric rule used from workbook matrix",
142
+ "manuscript_evidence": "short paraphrased manuscript evidence; no long excerpts",
143
+ "reason": "why this score follows from the workbook rule and fingerprint metrics",
144
+ }
145
+ for field in SCORE_FIELDS
146
+ },
147
  "gate": "HARD FAIL | SOFT FAIL | READ-ALOUD BLOCK | COMMERCIAL CHECK | GREENLIGHT | REVISE",
148
  "weakest_metric": "one of: Clarity, Rhythm, Read-aloud Flow, Emotional Truth, Visual Strength, Commercial Viability",
149
  "dashboard_message": "short dashboard status message, max 220 characters",
 
180
  You are the hidden TOTEM Analysis skill inside TOTEM Studio.
181
  Use the skill instructions below as the scoring method, but do not create a DOCX report for this dashboard run.
182
  Return strict JSON only. No prose. No markdown. No code fences.
183
+ Use the workbook matrix as scoring authority, manuscript fingerprint VM metrics as measured evidence, and manuscript text only as supporting evidence.
184
 
185
  --- TOTEM SKILL INSTRUCTIONS ---
186
  {skill_text}
 
190
  user_payload = {
191
  "task": "score_manuscript_against_workbook_matrix_for_dashboard",
192
  "rubric_matrix": rubric_matrix,
193
+ "manuscript_fingerprint": manuscript.fingerprint or {},
194
+ "manuscript_fingerprint_report": manuscript.fingerprint_report[:6000] if manuscript.fingerprint_report else "",
195
  "manuscript_cleaned_text": manuscript.cleaned_text,
196
  "scoring_dimensions": SCORE_LABELS,
197
  "required_output_contract": contract,
198
  "instruction": (
199
  "Return exactly one JSON object matching required_output_contract. "
200
  "Scores must be 0..100 dashboard values for the six dimensions. "
201
+ "Every score_evidence entry must cite at least one VM metric id from manuscript_fingerprint "
202
+ "and one workbook rule/target from rubric_matrix. If the fingerprint is missing or insufficient, "
203
+ "return gate REVISE and explain the missing evidence in dashboard_message. "
204
  "Do not write a report and do not include manuscript excerpts."
205
  ),
206
  }
 
238
  "skill_path": skill_path,
239
  "skill_loaded": True,
240
  "schema_pass": True,
241
+ "fingerprint_supplied": bool(manuscript.fingerprint),
242
+ "fingerprint_metric_count": _fingerprint_metric_count(manuscript.fingerprint),
243
  "token_audit_path": audit_path,
244
  "token_audit_transaction_id": transaction_id,
245
  "raw_model_payload": raw_payload,
 
369
  "skill_loaded": True,
370
  "schema_pass": True,
371
  "actual_tokens": actual_tokens,
372
+ "fingerprint_supplied": bool(manuscript.fingerprint),
373
+ "fingerprint_metric_count": _fingerprint_metric_count(manuscript.fingerprint),
374
  "token_audit_path": audit_path,
375
  "token_audit_transaction_id": transaction_id,
376
  "raw_model_payload": parsed,
 
456
  error: str | None,
457
  ) -> str:
458
  rubric_json = compact_json(rubric_matrix)
459
+ fingerprint_json = compact_json(manuscript.fingerprint or {})
460
  contract_json = compact_json(contract)
461
  user_wrapper_json = compact_json(
462
  {
 
469
  You are the hidden TOTEM Analysis skill inside TOTEM Studio.
470
  Use the skill instructions as the scoring method, but do not create a DOCX report for this dashboard run.
471
  Return strict JSON only. No prose. No markdown. No code fences.
472
+ Use the workbook matrix as scoring authority, manuscript fingerprint VM metrics as measured evidence, and manuscript text only as supporting evidence.
473
  """.strip()
474
 
475
  component_estimates = estimate_components(
 
477
  "system_wrapper": system_wrapper,
478
  "skill_md": skill_text,
479
  "workbook_rubric_json": rubric_json,
480
+ "manuscript_fingerprint_json": fingerprint_json,
481
+ "manuscript_fingerprint_report": manuscript.fingerprint_report or "",
482
  "manuscript_cleaned_text": manuscript.cleaned_text,
483
  "output_contract_json": contract_json,
484
  "user_wrapper_json": user_wrapper_json,
 
511
  "chars": len(manuscript.cleaned_text),
512
  "word_count": manuscript.word_count,
513
  },
514
+ "fingerprint": {
515
+ "supplied": bool(manuscript.fingerprint),
516
+ "sha256": stable_hash(manuscript.fingerprint or {}),
517
+ "metric_count": _fingerprint_metric_count(manuscript.fingerprint),
518
+ "report_chars": len(manuscript.fingerprint_report or ""),
519
+ },
520
  "estimated_tokens": {
521
  **component_estimates,
522
  "wire_prompt": {
 
592
  }
593
  out.update(out["scores"])
594
 
595
+ score_evidence = payload.get("score_evidence")
596
+ if not isinstance(score_evidence, dict):
597
+ raise BridgeValidationError("score_evidence must be a JSON object keyed by scoring dimension.")
598
+ out["score_evidence"] = _normalize_score_evidence(score_evidence, out["scores"])
599
+
600
  rpq = payload.get("revision_priority_queue")
601
  if not isinstance(rpq, list):
602
  raise BridgeValidationError("revision_priority_queue must be a list.")
 
609
  return out
610
 
611
 
612
+ def _normalize_score_evidence(evidence: dict[str, Any], scores: dict[str, int]) -> dict[str, dict[str, Any]]:
613
+ normalized: dict[str, dict[str, Any]] = {}
614
+ missing: list[str] = []
615
+ weak: list[str] = []
616
+ for field in SCORE_FIELDS:
617
+ item = evidence.get(field)
618
+ if not isinstance(item, dict):
619
+ missing.append(field)
620
+ continue
621
+
622
+ metrics = item.get("fingerprint_metrics_used") or item.get("vm_metrics_used") or []
623
+ if isinstance(metrics, str):
624
+ metrics = [metrics]
625
+ metric_ids = [str(x).strip() for x in metrics if str(x).strip()]
626
+ if not metric_ids or not any(m.upper().startswith("VM-") for m in metric_ids):
627
+ weak.append(field)
628
+
629
+ workbook_rule = str(item.get("workbook_rule") or "").strip()[:260]
630
+ manuscript_evidence = str(item.get("manuscript_evidence") or "").strip()[:260]
631
+ reason = str(item.get("reason") or "").strip()[:320]
632
+ if not workbook_rule or not reason:
633
+ weak.append(field)
634
+
635
+ normalized[field] = {
636
+ "score": scores[field],
637
+ "fingerprint_metrics_used": metric_ids[:8],
638
+ "workbook_rule": workbook_rule,
639
+ "manuscript_evidence": manuscript_evidence,
640
+ "reason": reason,
641
+ }
642
+
643
+ if missing:
644
+ raise BridgeValidationError(f"Missing score_evidence fields: {', '.join(missing)}")
645
+ if weak:
646
+ deduped = ", ".join(dict.fromkeys(weak))
647
+ raise BridgeValidationError(f"score_evidence lacks VM metric/workbook grounding for: {deduped}")
648
+ return normalized
649
+
650
+
651
  def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> str:
652
  weights = rubric_matrix.get("weights", {}) if isinstance(rubric_matrix, dict) else {}
653
  if not isinstance(weights, dict) or not weights:
 
730
  word_count = max(1, manuscript.word_count)
731
  base = max(45, min(88, 62 + (word_count // 160)))
732
  long_sentence_penalty = 8 if word_count > 850 else 3
733
+ scores = {
734
+ "clarity": max(0, min(100, base - long_sentence_penalty)),
735
+ "rhythm": max(0, min(100, base - 11)),
736
+ "read_aloud_flow": max(0, min(100, base - 7)),
737
+ "emotional_truth": max(0, min(100, base + 5)),
738
+ "visual_strength": max(0, min(100, base + 8)),
739
+ "commercial_viability": max(0, min(100, base + 1)),
740
+ }
741
  return {
742
+ "scores": scores,
743
+ "score_evidence": {
744
+ field: {
745
+ "score": score,
746
+ "fingerprint_metrics_used": ["VM-024"],
747
+ "workbook_rule": "Fake provider test rule; real runs must cite workbook targets.",
748
+ "manuscript_evidence": "Fake provider uses word-count-only smoke evidence.",
749
+ "reason": f"Fake audit score for {field} derived from {word_count} words.",
750
+ }
751
+ for field, score in scores.items()
752
  },
753
  "gate": "REVISE",
754
  "weakest_metric": "Rhythm",
 
796
  except Exception as exc:
797
  raise BridgeValidationError(f"Invalid numeric score value: {value!r}") from exc
798
  return max(0, min(100, x))
799
+
800
+
801
+ def _fingerprint_metric_count(fingerprint: dict[str, Any] | None) -> int:
802
+ if not isinstance(fingerprint, dict):
803
+ return 0
804
+ return sum(1 for key in fingerprint if str(key).upper().startswith("VM-"))