Devin AI Deric J. McHenry commited on
Commit
da53bdc
·
1 Parent(s): 01cf8b5

Fix field mapping bug: detect partial JSON, extract by name, add Supabase migration

Browse files

Critical bug: when users paste terminal output like '"totals": { ... }' (not
valid JSON), parse_four() grabbed numbers positionally — mapping cacheCreation
to input, cacheRead to output, etc. Completely scrambled.

Fix: three-strategy pipeline in ingest_meta():
1. _try_fix_json(): wraps partial fragments in {} to make valid JSON
2. _extract_by_name(): regex extraction by field name (inputTokens,
outputTokens, etc.) when named fields are present in any format
3. parse_four(): fallback for bare numbers only

Also adds _FIELD_ALIASES dict mapping canonical names to all known variants
(camelCase + snake_case). _has_named_fields() detects when Strategy 2 applies.

Codex fragments with named fields correctly route through the two-pathway
parser (Alpha 3:2:1 / Beta 1:9) via the named-field extraction path.

SUPABASE_MIGRATION.md: SQL for adding submitted_at + hf_user columns to
sigrank_operators, creating sigrank_sessions table, and RLS policies.

Co-Authored-By: Deric J. McHenry <deric.mchenry@gmail.com>

Files changed (2) hide show
  1. SUPABASE_MIGRATION.md +97 -0
  2. ingest.py +161 -6
SUPABASE_MIGRATION.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase Migration — SigRank Importer Overhaul
2
+
3
+ Run these in the Supabase SQL Editor (Dashboard → SQL Editor → New Query).
4
+
5
+ ---
6
+
7
+ ## 1. Add columns to `sigrank_operators`
8
+
9
+ ```sql
10
+ -- Timestamp for when the entry was last submitted/updated
11
+ ALTER TABLE sigrank_operators
12
+ ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMPTZ DEFAULT now();
13
+
14
+ -- HuggingFace username — only authenticated users can persist
15
+ ALTER TABLE sigrank_operators
16
+ ADD COLUMN IF NOT EXISTS hf_user TEXT;
17
+
18
+ -- Index for fast lookups by HF user
19
+ CREATE INDEX IF NOT EXISTS idx_sigrank_operators_hf_user
20
+ ON sigrank_operators (hf_user);
21
+ ```
22
+
23
+ ---
24
+
25
+ ## 2. Create `sigrank_sessions` table (session history / Greatest Hits)
26
+
27
+ ```sql
28
+ CREATE TABLE IF NOT EXISTS sigrank_sessions (
29
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
30
+ name TEXT NOT NULL,
31
+ input BIGINT NOT NULL DEFAULT 0,
32
+ output BIGINT NOT NULL DEFAULT 0,
33
+ cache_create BIGINT NOT NULL DEFAULT 0,
34
+ cache_read BIGINT NOT NULL DEFAULT 0,
35
+ cost_usd DOUBLE PRECISION,
36
+ source TEXT DEFAULT 'manual',
37
+ estimated BOOLEAN DEFAULT FALSE,
38
+ caveat TEXT,
39
+ hf_user TEXT,
40
+ submitted_at TIMESTAMPTZ DEFAULT now()
41
+ );
42
+
43
+ -- Index for loading a user's session history
44
+ CREATE INDEX IF NOT EXISTS idx_sigrank_sessions_name
45
+ ON sigrank_sessions (name, submitted_at DESC);
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 3. RLS policies (keep anon read-only, service key for writes)
51
+
52
+ ```sql
53
+ -- Enable RLS on the new table
54
+ ALTER TABLE sigrank_sessions ENABLE ROW LEVEL SECURITY;
55
+
56
+ -- Anon can read session history
57
+ CREATE POLICY "anon_read_sessions" ON sigrank_sessions
58
+ FOR SELECT USING (true);
59
+
60
+ -- Service role can insert (writes come from the app backend)
61
+ CREATE POLICY "service_insert_sessions" ON sigrank_sessions
62
+ FOR INSERT WITH CHECK (true);
63
+
64
+ -- Same pattern for the new columns on sigrank_operators
65
+ -- (existing policies should already cover SELECT/INSERT;
66
+ -- verify the existing INSERT policy allows the new columns)
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 4. Verify
72
+
73
+ After running the above, check:
74
+
75
+ ```sql
76
+ -- Should show submitted_at and hf_user columns
77
+ SELECT column_name, data_type
78
+ FROM information_schema.columns
79
+ WHERE table_name = 'sigrank_operators'
80
+ ORDER BY ordinal_position;
81
+
82
+ -- Should exist with all columns
83
+ SELECT column_name, data_type
84
+ FROM information_schema.columns
85
+ WHERE table_name = 'sigrank_sessions'
86
+ ORDER BY ordinal_position;
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Notes
92
+
93
+ - `sigrank_operators` still upserts on `name` (one board entry per operator)
94
+ - `sigrank_sessions` is append-only — every submission creates a new row
95
+ - The app reads sessions via `load_session_history(name, limit=5)` for the Greatest Hits display
96
+ - `hf_user` is populated only when the user is authenticated via HuggingFace OAuth on the Space
97
+ - Without the `SUPABASE_SERVICE_KEY` env var, all writes are no-ops (safe for public demo)
ingest.py CHANGED
@@ -10,6 +10,102 @@ for architectural modeling and are distinct from raw provider API payload logs.
10
  import json
11
  import re
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def parse_ccusage(text):
14
  """Accept raw `ccusage --json` output (any known shape). Returns (i,o,cw,cr,cost)."""
15
  d = json.loads(text)
@@ -92,10 +188,10 @@ def parse_codex_submission(payload, operator_profile=None):
92
 
93
  Two pathways depending on operator telemetry:
94
 
95
- Pathway Alpha (Standard): No Claude footprint 3:2:1 baseline.
96
- estimated_user_input = outputTokens × 2.0
97
 
98
- Pathway Beta (Claude Engine): Operator has verified Claude profile
99
  dynamic 1:9 transmission velocity extraction.
100
  estimated_user_input = outputTokens / 9.0
101
 
@@ -138,6 +234,12 @@ def parse_codex_submission(payload, operator_profile=None):
138
  def ingest_meta(text, operator_profile=None):
139
  """Returns (i,o,cw,cr,meta) with estimated/caveat/cost.
140
 
 
 
 
 
 
 
141
  operator_profile: optional dict with at least {"model_type": "claude"}
142
  when the submitting user has a verified Claude session profile. This
143
  switches the Codex parser from the 3:2:1 baseline to the 1:9 closed-loop
@@ -145,11 +247,64 @@ def ingest_meta(text, operator_profile=None):
145
  """
146
  text=text.strip()
147
  if not text: raise ValueError("empty")
148
- if text[0] in "{[":
149
- d=json.loads(text)
 
 
 
 
 
150
  if is_codex_shape(d):
151
  return parse_codex_submission(d, operator_profile=operator_profile)
152
- i,o,cw,cr,cost = parse_ccusage(text)
153
  return i,o,cw,cr,{"source":"ccusage","estimated":False,"caveat":None,"cost":cost}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  i,o,cw,cr = parse_four(text)
155
  return i,o,cw,cr,{"source":"manual","estimated":False,"caveat":None,"cost":None}
 
10
  import json
11
  import re
12
 
13
+ # Field names we recognise (ccusage camelCase and snake_case variants)
14
+ _FIELD_ALIASES = {
15
+ "input": ("inputTokens", "input_tokens"),
16
+ "output": ("outputTokens", "output_tokens"),
17
+ "cache_create": ("cacheCreationTokens", "cache_creation_input_tokens"),
18
+ "cache_read": ("cacheReadTokens", "cache_read_input_tokens",
19
+ "cachedInputTokens", "cached_input_tokens"),
20
+ "cost": ("totalCost", "costUSD", "cost"),
21
+ "reasoning": ("reasoningOutputTokens", "reasoning_output_tokens"),
22
+ }
23
+
24
+ def _has_named_fields(text):
25
+ """True if text contains at least two recognised ccusage field names."""
26
+ hits = 0
27
+ for aliases in _FIELD_ALIASES.values():
28
+ for alias in aliases:
29
+ if alias in text:
30
+ hits += 1
31
+ break
32
+ return hits >= 2
33
+
34
+
35
+ def _try_fix_json(text):
36
+ """Try to turn a partial JSON fragment into valid JSON.
37
+
38
+ Common pastes:
39
+ "totals": { ... } → {"totals": { ... }}
40
+ { "totals": { ... } → add missing closing brace
41
+ "inputTokens": 123, ... → { ... }
42
+ """
43
+ t = text.strip()
44
+ # Already valid JSON
45
+ if t[0] in "{[":
46
+ try:
47
+ json.loads(t)
48
+ return t
49
+ except json.JSONDecodeError:
50
+ # Missing closing brace — try adding one
51
+ try:
52
+ json.loads(t + "}")
53
+ return t + "}"
54
+ except json.JSONDecodeError:
55
+ try:
56
+ json.loads(t + "}}")
57
+ return t + "}}"
58
+ except json.JSONDecodeError:
59
+ pass
60
+ # Starts with a quoted key like "totals": { ... } or "inputTokens": 123
61
+ if t.startswith('"'):
62
+ candidate = "{" + t
63
+ # Try adding closing braces
64
+ for suffix in ("", "}", "}}"):
65
+ try:
66
+ json.loads(candidate + suffix)
67
+ return candidate + suffix
68
+ except json.JSONDecodeError:
69
+ continue
70
+ return None
71
+
72
+
73
+ def _extract_by_name(text):
74
+ """Extract token values from text containing named fields (any format).
75
+
76
+ Works on partial JSON, terminal output, or any text where field names
77
+ appear next to their values. Returns (i, o, cw, cr, cost) or None.
78
+ """
79
+ def grab(aliases):
80
+ for alias in aliases:
81
+ # Match "fieldName": 12345 or "fieldName": 12345.67
82
+ m = re.search(rf'"{re.escape(alias)}"\s*:\s*([\d,.]+)', text)
83
+ if m:
84
+ raw = m.group(1).replace(",", "")
85
+ return int(float(raw))
86
+ return 0
87
+
88
+ i = grab(_FIELD_ALIASES["input"])
89
+ o = grab(_FIELD_ALIASES["output"])
90
+ cw = grab(_FIELD_ALIASES["cache_create"])
91
+ cr = grab(_FIELD_ALIASES["cache_read"])
92
+ cost_val = None
93
+ for alias in _FIELD_ALIASES["cost"]:
94
+ m = re.search(rf'"{re.escape(alias)}"\s*:\s*([\d,.]+)', text)
95
+ if m:
96
+ raw = m.group(1).replace(",", "")
97
+ cost_val = float(raw)
98
+ if cost_val > 0:
99
+ break
100
+ reasoning = grab(_FIELD_ALIASES["reasoning"])
101
+ if reasoning > 0:
102
+ o += reasoning
103
+
104
+ if i + o + cw + cr == 0:
105
+ return None
106
+ return i, o, cw, cr, cost_val
107
+
108
+
109
  def parse_ccusage(text):
110
  """Accept raw `ccusage --json` output (any known shape). Returns (i,o,cw,cr,cost)."""
111
  d = json.loads(text)
 
188
 
189
  Two pathways depending on operator telemetry:
190
 
191
+ Pathway Alpha (Standard): No Claude footprint -> 3:2:1 baseline.
192
+ estimated_user_input = outputTokens * 2.0
193
 
194
+ Pathway Beta (Claude Engine): Operator has verified Claude profile ->
195
  dynamic 1:9 transmission velocity extraction.
196
  estimated_user_input = outputTokens / 9.0
197
 
 
234
  def ingest_meta(text, operator_profile=None):
235
  """Returns (i,o,cw,cr,meta) with estimated/caveat/cost.
236
 
237
+ Handles:
238
+ - Full ccusage JSON (Claude: measured, Codex: two-pathway estimation)
239
+ - Partial JSON fragments ("totals": { ... } pasted from terminal)
240
+ - Text with named fields (extracts by field name, not position)
241
+ - Four bare numbers: input output cache_create cache_read
242
+
243
  operator_profile: optional dict with at least {"model_type": "claude"}
244
  when the submitting user has a verified Claude session profile. This
245
  switches the Codex parser from the 3:2:1 baseline to the 1:9 closed-loop
 
247
  """
248
  text=text.strip()
249
  if not text: raise ValueError("empty")
250
+
251
+ # --- Strategy 1: Try to parse as valid JSON (or fix partial JSON) ---
252
+ fixed = None
253
+ if text[0] in '{["':
254
+ fixed = _try_fix_json(text)
255
+ if fixed:
256
+ d = json.loads(fixed)
257
  if is_codex_shape(d):
258
  return parse_codex_submission(d, operator_profile=operator_profile)
259
+ i,o,cw,cr,cost = parse_ccusage(fixed)
260
  return i,o,cw,cr,{"source":"ccusage","estimated":False,"caveat":None,"cost":cost}
261
+
262
+ # --- Strategy 2: Text has named fields — extract by name ---
263
+ if _has_named_fields(text):
264
+ result = _extract_by_name(text)
265
+ if result:
266
+ i, o, cw, cr, cost_val = result
267
+ has_codex_fields = any(
268
+ alias in text
269
+ for alias in ("cachedInputTokens", "cached_input_tokens",
270
+ "reasoningOutputTokens", "reasoning_output_tokens")
271
+ )
272
+ if has_codex_fields:
273
+ # Re-route through Codex pathway with extracted totals
274
+ raw_cache = 0
275
+ for alias in _FIELD_ALIASES["cache_read"]:
276
+ m = re.search(rf'"{re.escape(alias)}"\s*:\s*([\d,.]+)', text)
277
+ if m:
278
+ raw_cache = int(float(m.group(1).replace(",", "")))
279
+ break
280
+ raw_in = 0
281
+ for alias in _FIELD_ALIASES["input"]:
282
+ m = re.search(rf'"{re.escape(alias)}"\s*:\s*([\d,.]+)', text)
283
+ if m:
284
+ raw_in = int(float(m.group(1).replace(",", "")))
285
+ break
286
+ raw_out = o # already includes reasoning from _extract_by_name
287
+ if operator_profile and operator_profile.get("model_type") == "claude":
288
+ est_input = raw_out / 9.0
289
+ parsing_mode = "Claude Closed-Loop Calibration (1:9)"
290
+ else:
291
+ est_input = raw_out * 2.0
292
+ parsing_mode = "Standard Open-Loop Baseline (3:2:1)"
293
+ context_debt = max(0, raw_in - int(est_input))
294
+ meta = {
295
+ "source": "codex", "estimated": True,
296
+ "parsing_mode": parsing_mode,
297
+ "caveat": f"* {parsing_mode}",
298
+ "anchor": parsing_mode, "cost": cost_val,
299
+ }
300
+ return int(est_input), raw_out, context_debt, raw_cache, meta
301
+ else:
302
+ cost = cost_val if cost_val and cost_val > 0 else None
303
+ return i, o, cw, cr, {
304
+ "source": "ccusage", "estimated": False,
305
+ "caveat": None, "cost": cost,
306
+ }
307
+
308
+ # --- Strategy 3: Four bare numbers ---
309
  i,o,cw,cr = parse_four(text)
310
  return i,o,cw,cr,{"source":"manual","estimated":False,"caveat":None,"cost":None}