Fix field mapping bug: detect partial JSON, extract by name, add Supabase migration
Browse filesCritical 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>
- SUPABASE_MIGRATION.md +97 -0
- ingest.py +161 -6
|
@@ -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)
|
|
@@ -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
|
| 96 |
-
estimated_user_input = outputTokens
|
| 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 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
if is_codex_shape(d):
|
| 151 |
return parse_codex_submission(d, operator_profile=operator_profile)
|
| 152 |
-
i,o,cw,cr,cost = parse_ccusage(
|
| 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}
|