Pointf5ive commited on
Commit
bef6364
·
1 Parent(s): ca7d969

Add TOTEM token audit and Groq provider support

Browse files
Files changed (4) hide show
  1. .gitignore +4 -0
  2. app.py +6 -1
  3. src/token_meter.py +113 -0
  4. src/totem_bridge.py +332 -35
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.py[cod]
4
+ data/token_audit/
app.py CHANGED
@@ -3175,7 +3175,7 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3175
  Full bridge run:
3176
  - workbook matrix is source of truth
3177
  - manuscript context is evidence
3178
- - hidden OpenAI skill returns strict JSON
3179
  - validated JSON updates dashboard
3180
  """
3181
  if not manuscript_context_json:
@@ -3213,7 +3213,9 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3213
  debug_log = {
3214
  "status": "ok",
3215
  "gate_summary": gate_summary,
 
3216
  "model": debug.get("model"),
 
3217
  "manuscript_words": debug.get("word_count"),
3218
  "manuscript_path": ctx.path,
3219
  "rubric_metrics": len(rubric.get("metrics", [])),
@@ -3221,6 +3223,9 @@ def run_totem_analysis_from_context(active_path: str, manuscript_context_json: s
3221
  "skill_path": debug.get("skill_path"),
3222
  "schema_pass": debug.get("schema_pass"),
3223
  "score_fields": list(SCORE_FIELDS),
 
 
 
3224
  }
3225
  return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
3226
  except Exception as exc:
 
3175
  Full bridge run:
3176
  - workbook matrix is source of truth
3177
  - manuscript context is evidence
3178
+ - hidden LLM skill returns strict JSON
3179
  - validated JSON updates dashboard
3180
  """
3181
  if not manuscript_context_json:
 
3213
  debug_log = {
3214
  "status": "ok",
3215
  "gate_summary": gate_summary,
3216
+ "provider": debug.get("provider"),
3217
  "model": debug.get("model"),
3218
+ "api_key_env": debug.get("api_key_env"),
3219
  "manuscript_words": debug.get("word_count"),
3220
  "manuscript_path": ctx.path,
3221
  "rubric_metrics": len(rubric.get("metrics", [])),
 
3223
  "skill_path": debug.get("skill_path"),
3224
  "schema_pass": debug.get("schema_pass"),
3225
  "score_fields": list(SCORE_FIELDS),
3226
+ "actual_tokens": debug.get("actual_tokens"),
3227
+ "token_audit_path": debug.get("token_audit_path"),
3228
+ "token_audit_transaction_id": debug.get("token_audit_transaction_id"),
3229
  }
3230
  return dashboard_html, log_df, summary, json.dumps(debug_log, indent=2)
3231
  except Exception as exc:
src/token_meter.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import os
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ APP_ROOT = Path(__file__).resolve().parents[1]
14
+ DEFAULT_AUDIT_DIR = APP_ROOT / "data" / "token_audit"
15
+
16
+
17
+ def new_transaction_id(prefix: str = "totem") -> str:
18
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
19
+ return f"{prefix}_{stamp}_{uuid.uuid4().hex[:10]}"
20
+
21
+
22
+ def stable_hash(value: Any) -> str:
23
+ if isinstance(value, str):
24
+ payload = value
25
+ else:
26
+ payload = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
27
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def compact_json(value: Any) -> str:
31
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
32
+
33
+
34
+ def estimate_text_tokens(text: str, model: str | None = None) -> dict[str, Any]:
35
+ """
36
+ Estimate tokens before an API call. Exact transaction totals should still come
37
+ from the provider response usage object after the call completes.
38
+ """
39
+ text = text or ""
40
+ try:
41
+ import tiktoken # type: ignore
42
+
43
+ try:
44
+ enc = tiktoken.encoding_for_model(model or "")
45
+ method = f"tiktoken:{enc.name}:model"
46
+ except Exception:
47
+ enc = tiktoken.get_encoding("cl100k_base")
48
+ method = "tiktoken:cl100k_base:fallback"
49
+ return {
50
+ "tokens": len(enc.encode(text)),
51
+ "chars": len(text),
52
+ "method": method,
53
+ }
54
+ except Exception:
55
+ # Conservative no-dependency approximation. Most English prompt material
56
+ # lands around 3.5-4 chars/token, but JSON punctuation pushes lower.
57
+ return {
58
+ "tokens": int(math.ceil(len(text) / 4)) if text else 0,
59
+ "chars": len(text),
60
+ "method": "heuristic:ceil(chars/4)",
61
+ }
62
+
63
+
64
+ def estimate_components(components: dict[str, str], model: str | None = None) -> dict[str, Any]:
65
+ by_component = {
66
+ name: estimate_text_tokens(text, model=model)
67
+ for name, text in components.items()
68
+ }
69
+ return {
70
+ "components": by_component,
71
+ "estimated_input_total": sum(item["tokens"] for item in by_component.values()),
72
+ "methods": sorted({item["method"] for item in by_component.values()}),
73
+ }
74
+
75
+
76
+ def extract_response_usage(response: Any) -> dict[str, int | None]:
77
+ usage = getattr(response, "usage", None)
78
+ if usage is None and isinstance(response, dict):
79
+ usage = response.get("usage")
80
+ if usage is None:
81
+ return {
82
+ "prompt_tokens": None,
83
+ "completion_tokens": None,
84
+ "total_tokens": None,
85
+ }
86
+
87
+ def get(name: str) -> int | None:
88
+ value = getattr(usage, name, None)
89
+ if value is None and isinstance(usage, dict):
90
+ value = usage.get(name)
91
+ try:
92
+ return int(value) if value is not None else None
93
+ except Exception:
94
+ return None
95
+
96
+ return {
97
+ "prompt_tokens": get("prompt_tokens"),
98
+ "completion_tokens": get("completion_tokens"),
99
+ "total_tokens": get("total_tokens"),
100
+ }
101
+
102
+
103
+ def audit_dir() -> Path:
104
+ return Path(os.getenv("TOTEM_TOKEN_AUDIT_DIR") or DEFAULT_AUDIT_DIR)
105
+
106
+
107
+ def append_token_audit(record: dict[str, Any]) -> str:
108
+ out_dir = audit_dir()
109
+ out_dir.mkdir(parents=True, exist_ok=True)
110
+ path = out_dir / "totem_token_usage.jsonl"
111
+ with path.open("a", encoding="utf-8") as fh:
112
+ fh.write(compact_json(record) + "\n")
113
+ return str(path)
src/totem_bridge.py CHANGED
@@ -2,6 +2,7 @@ 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
@@ -9,6 +10,15 @@ from typing import Any
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
 
@@ -54,6 +64,15 @@ class ManuscriptContext:
54
  page_trace: list[dict[str, Any]]
55
 
56
 
 
 
 
 
 
 
 
 
 
57
  def extract_workbook_matrix(path: Path) -> dict[str, Any]:
58
  if not path.exists():
59
  raise BridgeError(f"Workbook not found: {path}")
@@ -142,29 +161,8 @@ def run_totem_skill(
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
- if os.getenv("SPACE_ID") and not _env_truthy("TOTEM_ALLOW_FAKE_API_IN_SPACE"):
148
- raise BridgeError("TOTEM_FAKE_API is disabled on Hugging Face Spaces.")
149
- raw_payload = _fake_dashboard_payload(manuscript)
150
- validated = validate_dashboard_payload(raw_payload)
151
- return validated, {
152
- "model": "fake-audit",
153
- "word_count": manuscript.word_count,
154
- "raw_response_chars": len(json.dumps(raw_payload)),
155
- "skill_path": skill_path,
156
- "skill_loaded": True,
157
- "schema_pass": True,
158
- }
159
-
160
- api_key = os.getenv("OPENAI_API_KEY")
161
- if not api_key:
162
- raise BridgeError("OPENAI_API_KEY is not configured.")
163
-
164
- from openai import OpenAI
165
-
166
- model = os.getenv("TOTEM_OPENAI_MODEL", "gpt-4.1-mini")
167
- client = OpenAI(api_key=api_key)
168
 
169
  system_prompt = f"""
170
  You are the hidden TOTEM Analysis skill inside TOTEM Studio.
@@ -182,41 +180,340 @@ Use the workbook matrix as scoring authority and manuscript text as evidence.
182
  "rubric_matrix": rubric_matrix,
183
  "manuscript_cleaned_text": manuscript.cleaned_text,
184
  "scoring_dimensions": SCORE_LABELS,
185
- "required_output_contract": dashboard_output_contract(),
186
  "instruction": (
187
  "Return exactly one JSON object matching required_output_contract. "
188
  "Scores must be 0..100 dashboard values for the six dimensions. "
189
  "Do not write a report and do not include manuscript excerpts."
190
  ),
191
  }
 
192
 
193
- resp = client.chat.completions.create(
194
- model=model,
195
- response_format={"type": "json_object"},
196
- temperature=0.2,
197
- messages=[
198
- {"role": "system", "content": system_prompt},
199
- {"role": "user", "content": json.dumps(user_payload)},
200
- ],
201
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  raw = (resp.choices[0].message.content or "").strip()
203
  try:
204
  parsed = json.loads(raw)
205
  except Exception as exc:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  raise BridgeValidationError(f"Model returned invalid JSON: {exc}") from exc
207
 
208
- validated = validate_dashboard_payload(parsed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  debug = {
210
- "model": model,
 
 
211
  "word_count": manuscript.word_count,
212
  "raw_response_chars": len(raw),
213
  "skill_path": skill_path,
214
  "skill_loaded": True,
215
  "schema_pass": True,
 
 
 
216
  }
217
  return validated, debug
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
221
  if not isinstance(payload, dict):
222
  raise BridgeValidationError("Dashboard payload must be a JSON object.")
 
2
 
3
  import json
4
  import os
5
+ import time
6
  from dataclasses import dataclass
7
  from pathlib import Path
8
  from typing import Any
 
10
  import pandas as pd
11
 
12
  from src.codex_extractor import clean_text, extract_text_from_file
13
+ from src.token_meter import (
14
+ append_token_audit,
15
+ compact_json,
16
+ estimate_components,
17
+ estimate_text_tokens,
18
+ extract_response_usage,
19
+ new_transaction_id,
20
+ stable_hash,
21
+ )
22
  from src.totem_workbook import METRICS, protocol_table, protocol_weights
23
 
24
 
 
64
  page_trace: list[dict[str, Any]]
65
 
66
 
67
+ @dataclass
68
+ class LLMProviderConfig:
69
+ provider: str
70
+ model: str
71
+ api_key: str
72
+ api_key_env: str
73
+ base_url: str | None = None
74
+
75
+
76
  def extract_workbook_matrix(path: Path) -> dict[str, Any]:
77
  if not path.exists():
78
  raise BridgeError(f"Workbook not found: {path}")
 
161
  manuscript: ManuscriptContext,
162
  ) -> tuple[dict[str, Any], dict[str, Any]]:
163
  skill_text, skill_path = load_totem_skill()
164
+ transaction_id = new_transaction_id("totem")
165
+ contract = dashboard_output_contract()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  system_prompt = f"""
168
  You are the hidden TOTEM Analysis skill inside TOTEM Studio.
 
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
  }
190
+ user_payload_json = json.dumps(user_payload, ensure_ascii=False)
191
 
192
+ if _env_truthy("TOTEM_FAKE_API"):
193
+ if os.getenv("SPACE_ID") and not _env_truthy("TOTEM_ALLOW_FAKE_API_IN_SPACE"):
194
+ raise BridgeError("TOTEM_FAKE_API is disabled on Hugging Face Spaces.")
195
+ raw_payload = _fake_dashboard_payload(manuscript)
196
+ validated = validate_dashboard_payload(raw_payload)
197
+ raw_payload_json = json.dumps(raw_payload, ensure_ascii=False)
198
+ audit_path = _write_token_audit(
199
+ transaction_id=transaction_id,
200
+ status="ok",
201
+ provider="fake",
202
+ model="fake-audit",
203
+ api_key_env=None,
204
+ skill_path=skill_path,
205
+ skill_text=skill_text,
206
+ rubric_matrix=rubric_matrix,
207
+ manuscript=manuscript,
208
+ contract=contract,
209
+ system_prompt=system_prompt,
210
+ user_payload_json=user_payload_json,
211
+ actual_tokens=None,
212
+ raw_response=raw_payload_json,
213
+ latency_ms=0,
214
+ error=None,
215
+ )
216
+ return validated, {
217
+ "provider": "fake",
218
+ "model": "fake-audit",
219
+ "word_count": manuscript.word_count,
220
+ "raw_response_chars": len(raw_payload_json),
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
+ }
227
+
228
+ provider_config = _select_llm_provider()
229
+
230
+ from openai import OpenAI
231
+
232
+ client_kwargs: dict[str, Any] = {"api_key": provider_config.api_key}
233
+ if provider_config.base_url:
234
+ client_kwargs["base_url"] = provider_config.base_url
235
+ client = OpenAI(**client_kwargs)
236
+
237
+ started = time.perf_counter()
238
+ resp = None
239
+ raw = ""
240
+ try:
241
+ resp = client.chat.completions.create(
242
+ model=provider_config.model,
243
+ response_format={"type": "json_object"},
244
+ temperature=0.2,
245
+ messages=[
246
+ {"role": "system", "content": system_prompt},
247
+ {"role": "user", "content": user_payload_json},
248
+ ],
249
+ )
250
+ except Exception as exc:
251
+ latency_ms = int(round((time.perf_counter() - started) * 1000))
252
+ audit_path = _write_token_audit(
253
+ transaction_id=transaction_id,
254
+ status="api_error",
255
+ provider=provider_config.provider,
256
+ model=provider_config.model,
257
+ api_key_env=provider_config.api_key_env,
258
+ skill_path=skill_path,
259
+ skill_text=skill_text,
260
+ rubric_matrix=rubric_matrix,
261
+ manuscript=manuscript,
262
+ contract=contract,
263
+ system_prompt=system_prompt,
264
+ user_payload_json=user_payload_json,
265
+ actual_tokens=None,
266
+ raw_response="",
267
+ latency_ms=latency_ms,
268
+ error=f"{type(exc).__name__}: {exc}",
269
+ )
270
+ raise BridgeError(
271
+ f"{provider_config.provider} API call failed. Token audit: {audit_path}. {type(exc).__name__}: {exc}"
272
+ ) from exc
273
+
274
+ latency_ms = int(round((time.perf_counter() - started) * 1000))
275
+ actual_tokens = extract_response_usage(resp)
276
  raw = (resp.choices[0].message.content or "").strip()
277
  try:
278
  parsed = json.loads(raw)
279
  except Exception as exc:
280
+ audit_path = _write_token_audit(
281
+ transaction_id=transaction_id,
282
+ status="invalid_json",
283
+ provider=provider_config.provider,
284
+ model=provider_config.model,
285
+ api_key_env=provider_config.api_key_env,
286
+ skill_path=skill_path,
287
+ skill_text=skill_text,
288
+ rubric_matrix=rubric_matrix,
289
+ manuscript=manuscript,
290
+ contract=contract,
291
+ system_prompt=system_prompt,
292
+ user_payload_json=user_payload_json,
293
+ actual_tokens=actual_tokens,
294
+ raw_response=raw,
295
+ latency_ms=latency_ms,
296
+ error=f"{type(exc).__name__}: {exc}",
297
+ )
298
  raise BridgeValidationError(f"Model returned invalid JSON: {exc}") from exc
299
 
300
+ try:
301
+ validated = validate_dashboard_payload(parsed)
302
+ except BridgeValidationError as exc:
303
+ audit_path = _write_token_audit(
304
+ transaction_id=transaction_id,
305
+ status="schema_error",
306
+ provider=provider_config.provider,
307
+ model=provider_config.model,
308
+ api_key_env=provider_config.api_key_env,
309
+ skill_path=skill_path,
310
+ skill_text=skill_text,
311
+ rubric_matrix=rubric_matrix,
312
+ manuscript=manuscript,
313
+ contract=contract,
314
+ system_prompt=system_prompt,
315
+ user_payload_json=user_payload_json,
316
+ actual_tokens=actual_tokens,
317
+ raw_response=raw,
318
+ latency_ms=latency_ms,
319
+ error=f"{type(exc).__name__}: {exc}",
320
+ )
321
+ raise BridgeValidationError(f"{exc}. Token audit: {audit_path}") from exc
322
+
323
+ audit_path = _write_token_audit(
324
+ transaction_id=transaction_id,
325
+ status="ok",
326
+ provider=provider_config.provider,
327
+ model=provider_config.model,
328
+ api_key_env=provider_config.api_key_env,
329
+ skill_path=skill_path,
330
+ skill_text=skill_text,
331
+ rubric_matrix=rubric_matrix,
332
+ manuscript=manuscript,
333
+ contract=contract,
334
+ system_prompt=system_prompt,
335
+ user_payload_json=user_payload_json,
336
+ actual_tokens=actual_tokens,
337
+ raw_response=raw,
338
+ latency_ms=latency_ms,
339
+ error=None,
340
+ )
341
  debug = {
342
+ "provider": provider_config.provider,
343
+ "model": provider_config.model,
344
+ "api_key_env": provider_config.api_key_env,
345
  "word_count": manuscript.word_count,
346
  "raw_response_chars": len(raw),
347
  "skill_path": skill_path,
348
  "skill_loaded": True,
349
  "schema_pass": True,
350
+ "actual_tokens": actual_tokens,
351
+ "token_audit_path": audit_path,
352
+ "token_audit_transaction_id": transaction_id,
353
  }
354
  return validated, debug
355
 
356
 
357
+ def _select_llm_provider() -> LLMProviderConfig:
358
+ # Hugging Face exposes secrets exactly as named. Accept Jamal's early
359
+ # `grok_key` spelling, but normalize internally to the standard Groq name.
360
+ if os.environ.get("grok_key") and not os.environ.get("GROQ_API_KEY"):
361
+ os.environ["GROQ_API_KEY"] = os.environ["grok_key"]
362
+
363
+ requested = str(os.getenv("TOTEM_LLM_PROVIDER") or "").strip().lower()
364
+ if requested == "grok":
365
+ requested = "groq"
366
+
367
+ groq_key, groq_env = _first_env_value(
368
+ (
369
+ "GROQ_API_KEY",
370
+ "groq_key",
371
+ "GROQ_KEY",
372
+ "GROK_API_KEY",
373
+ "GROK_KEY",
374
+ )
375
+ )
376
+ openai_key, openai_env = _first_env_value(("OPENAI_API_KEY",))
377
+
378
+ if not requested:
379
+ requested = "groq" if groq_key else "openai"
380
+
381
+ if requested == "groq":
382
+ if not groq_key:
383
+ raise BridgeError("Groq is selected but no GROQ_API_KEY/groq_key secret is configured.")
384
+ return LLMProviderConfig(
385
+ provider="groq",
386
+ model=os.getenv("TOTEM_GROQ_MODEL") or os.getenv("GROQ_MODEL") or "llama-3.3-70b-versatile",
387
+ api_key=groq_key,
388
+ api_key_env=groq_env or "GROQ_API_KEY",
389
+ base_url=os.getenv("GROQ_BASE_URL") or "https://api.groq.com/openai/v1",
390
+ )
391
+
392
+ if requested == "openai":
393
+ if not openai_key and groq_key:
394
+ return LLMProviderConfig(
395
+ provider="groq",
396
+ model=os.getenv("TOTEM_GROQ_MODEL") or os.getenv("GROQ_MODEL") or "llama-3.3-70b-versatile",
397
+ api_key=groq_key,
398
+ api_key_env=groq_env or "GROQ_API_KEY",
399
+ base_url=os.getenv("GROQ_BASE_URL") or "https://api.groq.com/openai/v1",
400
+ )
401
+ if not openai_key:
402
+ raise BridgeError("OpenAI is selected but OPENAI_API_KEY is not configured.")
403
+ return LLMProviderConfig(
404
+ provider="openai",
405
+ model=os.getenv("TOTEM_OPENAI_MODEL", "gpt-4.1-mini"),
406
+ api_key=openai_key,
407
+ api_key_env=openai_env or "OPENAI_API_KEY",
408
+ base_url=os.getenv("OPENAI_BASE_URL") or None,
409
+ )
410
+
411
+ raise BridgeError("TOTEM_LLM_PROVIDER must be 'groq' or 'openai'.")
412
+
413
+
414
+ def _write_token_audit(
415
+ *,
416
+ transaction_id: str,
417
+ status: str,
418
+ provider: str,
419
+ model: str,
420
+ api_key_env: str | None,
421
+ skill_path: str,
422
+ skill_text: str,
423
+ rubric_matrix: dict[str, Any],
424
+ manuscript: ManuscriptContext,
425
+ contract: dict[str, Any],
426
+ system_prompt: str,
427
+ user_payload_json: str,
428
+ actual_tokens: dict[str, int | None] | None,
429
+ raw_response: str,
430
+ latency_ms: int,
431
+ error: str | None,
432
+ ) -> str:
433
+ rubric_json = compact_json(rubric_matrix)
434
+ contract_json = compact_json(contract)
435
+ user_wrapper_json = compact_json(
436
+ {
437
+ "task": "score_manuscript_against_workbook_matrix_for_dashboard",
438
+ "scoring_dimensions": SCORE_LABELS,
439
+ "instruction": "Return exactly one JSON object matching required_output_contract.",
440
+ }
441
+ )
442
+ system_wrapper = """
443
+ You are the hidden TOTEM Analysis skill inside TOTEM Studio.
444
+ Use the skill instructions as the scoring method, but do not create a DOCX report for this dashboard run.
445
+ Return strict JSON only. No prose. No markdown. No code fences.
446
+ Use the workbook matrix as scoring authority and manuscript text as evidence.
447
+ """.strip()
448
+
449
+ component_estimates = estimate_components(
450
+ {
451
+ "system_wrapper": system_wrapper,
452
+ "skill_md": skill_text,
453
+ "workbook_rubric_json": rubric_json,
454
+ "manuscript_cleaned_text": manuscript.cleaned_text,
455
+ "output_contract_json": contract_json,
456
+ "user_wrapper_json": user_wrapper_json,
457
+ },
458
+ model=model,
459
+ )
460
+ system_wire = estimate_text_tokens(system_prompt, model=model)
461
+ user_wire = estimate_text_tokens(user_payload_json, model=model)
462
+
463
+ record = {
464
+ "transaction_id": transaction_id,
465
+ "created_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
466
+ "status": status,
467
+ "provider": provider,
468
+ "model": model,
469
+ "api_key_env": api_key_env,
470
+ "latency_ms": latency_ms,
471
+ "skill": {
472
+ "path": skill_path,
473
+ "sha256": stable_hash(skill_text),
474
+ "chars": len(skill_text),
475
+ },
476
+ "workbook": {
477
+ "sha256": stable_hash(rubric_matrix),
478
+ "metric_count": len(rubric_matrix.get("metrics", [])) if isinstance(rubric_matrix, dict) else 0,
479
+ },
480
+ "manuscript": {
481
+ "path": manuscript.path,
482
+ "sha256": stable_hash(manuscript.cleaned_text),
483
+ "chars": len(manuscript.cleaned_text),
484
+ "word_count": manuscript.word_count,
485
+ },
486
+ "estimated_tokens": {
487
+ **component_estimates,
488
+ "wire_prompt": {
489
+ "system_prompt": system_wire,
490
+ "user_message": user_wire,
491
+ "estimated_prompt_total": system_wire["tokens"] + user_wire["tokens"],
492
+ },
493
+ },
494
+ "actual_tokens": actual_tokens
495
+ or {
496
+ "prompt_tokens": None,
497
+ "completion_tokens": None,
498
+ "total_tokens": None,
499
+ },
500
+ "response": {
501
+ "sha256": stable_hash(raw_response) if raw_response else None,
502
+ "chars": len(raw_response or ""),
503
+ },
504
+ "error": error,
505
+ }
506
+ return append_token_audit(record)
507
+
508
+
509
+ def _first_env_value(names: tuple[str, ...]) -> tuple[str | None, str | None]:
510
+ for name in names:
511
+ value = os.getenv(name)
512
+ if value:
513
+ return value, name
514
+ return None, None
515
+
516
+
517
  def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
518
  if not isinstance(payload, dict):
519
  raise BridgeValidationError("Dashboard payload must be a JSON object.")