74aa75945e
PHASE 0 — contentEngine makes Truth Law structural, not careful. Copy is
token-substituted and an unbacked {token} REFUSES to render -- there is no
code path that produces a plausible default. The fact contract is asserted
before any string is built. Card and copy render from ONE fact object, so
a caption and a card cannot disagree. No live model writes factual claims:
the voice is in the template, the facts are pulled, and the voice-polish
port is deliberately unwired, because an LLM that can rewrite a sentence
can rewrite a number.
18 tests carry the proof. The one that matters most: ZERO IS PRESENT.
"0 cleared B+" is our most honest possible post, and treating 0 as missing
would be the Number(null)===0 breach wearing its opposite coat -- it would
silently delete exactly the post the brand is built on.
PHASE 1 — three templates, generating real posts from tonight's data:
hot hitters off the repaired full-season log, the honesty flex off the
real servedGrade distribution (2,140 graded / 70 cleared B+ / 42% not
separable / A unissuable), and streaks verified from settled outcomes only.
THE ENGINE CAUGHT A BUG IN ITSELF, and it is the sharpest lesson here. The
first run published "No hitter is meaningfully hot tonight -- we could
dress up a middling week as a streak. We don't." That was FALSE: the
box-score cache spans only the settled window, every player had under 20
games, and the pool was empty. A broken pull was publishing as considered
editorial judgement -- the fourth appearance of this class tonight and the
first where our OWN HONESTY COPY was the disguise.
Fixed structurally rather than by patching the number: an absent() variant
may now DECLINE to speak, and the template separates "no candidates at
all" (SKIP with a reason) from "candidates judged, none hot" (honest
absence). Both locked by test. Source corrected to mlbStatsAdapter.fullLog,
the same log the repaired champion reads.
PHASE 2 — cardRenderer emits SVG rather than canvas: it is text, so it
diffs in review and its numbers are greppable, which matters when the
whole claim is that the numbers are real. VYND white + R green, slashed-Y,
scanlines, mono. The card never formats its own facts -- every string
arrives pre-rendered and gate-checked.
PHASE 3 — scripts/generate-content.js writes copy + card per template to
.content-out/<date>/. Template N+1 is a registry entry: requires, pull,
copy, card, absent. Queued as stubs, not built: hot takes, daily reads,
"grades we DIDN'T give", cross-sport streak variants (the streak template
is already sport-agnostic -- settled outcomes and a noun).
FULLY ISOLATED: read-only on every source, zero writes to serving, model
or ledger tables. Serving fingerprint verified unchanged. The accrual clock
is untouched at 0 eligible dates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
138 lines
5.9 KiB
JavaScript
138 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* generate-content — tonight's posts, from tonight's real data.
|
|
*
|
|
* READ-ONLY on every source. This writes nothing to any serving, model or
|
|
* ledger table, so it has zero effect on the repaired-champion accrual clock.
|
|
*
|
|
* SUPABASE_URL=... node scripts/generate-content.js
|
|
* -> .content-out/<date>/<template>.txt and .svg
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const engine = require('../src/services/content/contentEngine');
|
|
const { toSvg } = require('../src/services/content/cardRenderer');
|
|
const sg = require('../src/services/model/servedGrade');
|
|
const { knownNumber } = require('../src/utils/known');
|
|
|
|
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
|
|
engine.registerTemplate(require(`../src/services/content/templates/${t}`));
|
|
}
|
|
|
|
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
|
|
const OUT = path.join(process.cwd(), '.content-out');
|
|
|
|
async function page(sb, t, sel, ob, f) {
|
|
const o = [];
|
|
for (let i = 0; ; i += 1000) {
|
|
const { data, error } = await f(sb.from(t).select(sel)).order(ob, { ascending: true }).range(i, i + 999);
|
|
if (error) throw new Error(`${t}: ${error.message}`);
|
|
if (!data || !data.length) break;
|
|
o.push(...data);
|
|
if (data.length < 1000) break;
|
|
}
|
|
return o;
|
|
}
|
|
|
|
(async () => {
|
|
const sb = createClient(process.env.SUPABASE_URL,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } });
|
|
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
|
|
|
// ── SOURCE 1: full-season hitter form (the REPAIRED window, not last10) ──
|
|
const lines = fs.existsSync(BOX) ? JSON.parse(fs.readFileSync(BOX, 'utf8')).lines : {};
|
|
const byPlayer = new Map();
|
|
for (const [k, b] of Object.entries(lines)) {
|
|
const [d, key] = k.split('|');
|
|
if (!byPlayer.has(key)) byPlayer.set(key, []);
|
|
byPlayer.get(key).push({ d, hits: b.hits, name: b.name });
|
|
}
|
|
// The box-score cache spans only the settled snapshot window, so it holds far
|
|
// fewer than a season per player. Season form comes from the SAME full log the
|
|
// repaired champion reads.
|
|
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
|
const hitterFormFull = async (names) => {
|
|
const out = [];
|
|
for (const n of names.slice(0, 60)) {
|
|
try {
|
|
const res = await mlb.getPlayerStats(n);
|
|
const log = (res && res.found && Array.isArray(res.fullLog)) ? res.fullLog : [];
|
|
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
|
|
if (vals.length < 20) continue;
|
|
const rate = (a) => a.filter((v) => v > 0).length / a.length;
|
|
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
|
|
} catch { /* absent player -> absent row */ }
|
|
}
|
|
return out;
|
|
};
|
|
const cacheForm = async () => [...byPlayer.entries()].map(([key, games]) => {
|
|
games.sort((a, b) => a.d.localeCompare(b.d));
|
|
const vals = games.map((g) => knownNumber(g.hits)).filter((v) => v !== null);
|
|
if (vals.length < 20) return null;
|
|
const rate = (arr) => arr.filter((v) => v > 0).length / arr.length;
|
|
return {
|
|
name: games[games.length - 1].name || key,
|
|
season_games: vals.length,
|
|
season_rate: rate(vals),
|
|
recent_rate: rate(vals.slice(-10)),
|
|
};
|
|
}).filter(Boolean);
|
|
const hitterForm = async () => {
|
|
const names = [...byPlayer.values()].map((g) => g[g.length - 1].name).filter(Boolean);
|
|
const full = await hitterFormFull([...new Set(names)]);
|
|
return full.length ? full : await cacheForm();
|
|
};
|
|
|
|
// ── SOURCE 2: the real served-grade distribution ──
|
|
const snaps = await page(sb, 'model_snapshots', 'p_win, refused, stat, game_date', 'id',
|
|
(q) => q.eq('sport', 'mlb').eq('game_date', date));
|
|
const gradeDistribution = async () => {
|
|
const usable = snaps.filter((r) => !r.refused && knownNumber(r.p_win) !== null);
|
|
const by = {}; let flat = 0;
|
|
for (const r of usable) {
|
|
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
|
|
by[g.letter] = (by[g.letter] || 0) + 1;
|
|
if (g.separates_from_base_rate === false) flat += 1;
|
|
}
|
|
return { total: usable.length || null, by_letter: by, not_separable: flat };
|
|
};
|
|
|
|
// ── SOURCE 3: streaks verified from SETTLED ledger outcomes only ──
|
|
const led = await page(sb, 'ledger_entries', 'player_name, player_key, game_date, outcome, stat', 'id',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits').in('outcome', ['hit', 'miss']));
|
|
const settledStreaks = async () => {
|
|
const by = new Map();
|
|
for (const r of led) {
|
|
if (!by.has(r.player_key)) by.set(r.player_key, []);
|
|
by.get(r.player_key).push(r);
|
|
}
|
|
const out = [];
|
|
for (const [, rows] of by) {
|
|
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
|
|
let n = 0;
|
|
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
|
|
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const deps = { date, hitterForm, gradeDistribution, settledStreaks, servedGrade: sg };
|
|
const results = await engine.generateAll(deps);
|
|
|
|
const dir = path.join(OUT, date);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const r of results) {
|
|
if (!r.ok) { console.log(`\n[SKIP] ${r.id} — ${r.reason}`); continue; }
|
|
fs.writeFileSync(path.join(dir, `${r.id}.txt`), r.copy);
|
|
fs.writeFileSync(path.join(dir, `${r.id}.svg`), toSvg(r.card));
|
|
console.log(`\n${'='.repeat(64)}\n${r.id.toUpperCase()}${r.honest_absence ? ' [HONEST ABSENCE]' : ''}\n${'='.repeat(64)}\n${r.copy}`);
|
|
}
|
|
console.log(`\n\noutput: ${dir}`);
|
|
process.exit(0);
|
|
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
|