17fb981f99
ROOT CAUSE: the Dockerfile copied src/poller/scripts/supabase but NOT content/. mediaEngine.js read content/stark-lines.json with an unguarded module-load readFileSync; ENOENT in the image threw at require time, and via app.js → routes/desk → deskService → mediaEngine that crashed the ENTIRE API at boot. The Coolify healthcheck rolled back to the last healthy image (4d2b27d), so every deploy since219167esilently served a 14-hour-old build — S11 live tracking, S6 API code, the settlement boot line, and SNAPSHOT_EXPECTED_INTERVAL were all merged but NOT running. FIX (one train): 1. Dockerfile COPYs content/ into the runner image. 2. mediaEngine: stark-lines.json is OPTIONAL (garnish, never load-bearing) — loadStark() try/catch → {} → posts render without the Stark kicker, never a crash. Belt AND suspenders with #1. 3. src/preflight.js (§A4): boot prints '[preflight] OK' or 'DEGRADED' naming exactly what content/env is missing — before the healthcheck can fail silently. Run first in server.js. 4. Full fragility sweep: mediaEngine was the ONLY unguarded module-load file read; coachSignals (config/coaches.json) was already lazy + try/catch + copied. No others. Verified: requiring app.js + deskService + mediaEngine with stark-lines.json ABSENT now boots clean (reproduced the exact prod failure). 2757 -> 2763 tests (tests/unit/bootResilience.test.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* mediaEngine — VOICE v1.1 template engine (Session 63 / A1-S4).
|
||
*
|
||
* Deterministic templates for the wire formats (MORNING WIRE, SIGNAL,
|
||
* STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH),
|
||
* filled ONLY from pipeline JSON (snapshot/ledger/streaks). Templates never
|
||
* compose numbers — the Data Semantics Rule extends to marketing. Record
|
||
* claims obey the product's n≥20 gate. Stark-layer lines are SELECTED from
|
||
* the curated committed library (content/stark-lines.json), never generated.
|
||
*
|
||
* Every emitted string passes lintVoice() — the banned list + the
|
||
* no-exclamation law are enforced at build time by tests and at runtime by
|
||
* assembly (a template that fails lint throws in dev, drops the line in prod).
|
||
*
|
||
* NOTHING here posts anywhere. Output goes to /desk for Kev to publish.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// Session 65 (P0 deploy fix) — the Stark-layer library is a GARNISH, never
|
||
// load-bearing. This used to be an unguarded module-load readFileSync; when
|
||
// the file wasn't in the container image it threw at require time, which
|
||
// (via app.js → routes/desk → deskService → here) crashed the WHOLE API at
|
||
// boot and silently rolled the deploy back for 14 hours. A witty kicker is
|
||
// not allowed to take down the record. Missing/corrupt file → empty library
|
||
// → every format posts WITHOUT the Stark line (starkLine returns null),
|
||
// which is a valid, still-VOICE-compliant post. Never throws.
|
||
const STARK_PATH = path.join(__dirname, '..', '..', 'content', 'stark-lines.json');
|
||
function loadStark() {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(STARK_PATH, 'utf8'));
|
||
} catch (e) {
|
||
console.warn(`[mediaEngine] stark-lines.json unavailable (${e.code || e.message}); posting without the Stark layer.`);
|
||
return {};
|
||
}
|
||
}
|
||
const STARK = loadStark();
|
||
|
||
// ---- VOICE lint -----------------------------------------------------------
|
||
const BANNED_PATTERNS = [
|
||
/!/, // no exclamation points, ever
|
||
/\block(s|ed)?\b/i, /🔒/,
|
||
/🔥/, /💰/, /🚀/, /💯/,
|
||
/we['’]?re so back/i, /it['’]?s so over/i,
|
||
/\btail\b/i, /\bfade\b/i, // as calls to action — banned outright
|
||
/can['’]?t lose/i, /free money/i, /guarantee/i,
|
||
/RT if/i, /who['’]?s tailing/i, /\btailing\b/i,
|
||
/\beating\b/i, /\bcooking\b/i, /\bGOAT\b/,
|
||
];
|
||
|
||
/** Returns [] when clean, else the matched patterns (as strings). */
|
||
function lintVoice(text) {
|
||
const hits = [];
|
||
for (const re of BANNED_PATTERNS) {
|
||
if (re.test(String(text || ''))) hits.push(re.source);
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
function assertVoice(text, context) {
|
||
const hits = lintVoice(text);
|
||
if (hits.length > 0) {
|
||
const msg = `[mediaEngine] VOICE lint failed (${context}): ${hits.join(', ')}`;
|
||
if (process.env.NODE_ENV === 'test') throw new Error(msg);
|
||
console.warn(msg);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---- helpers ---------------------------------------------------------------
|
||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o');
|
||
|
||
function etTime(iso, withDay = false) {
|
||
const d = iso ? new Date(iso) : new Date();
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
const opts = withDay
|
||
? { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric' }
|
||
: { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' };
|
||
return new Intl.DateTimeFormat('en-US', opts).format(d);
|
||
}
|
||
|
||
/** Deterministic stark selection: day-of-year rotation, never random
|
||
* (Data Semantics: reproducible output for the same inputs + date). */
|
||
function starkLine(category, dateIso) {
|
||
const lines = STARK[category] || [];
|
||
if (lines.length === 0) return null;
|
||
const d = dateIso ? new Date(dateIso) : new Date();
|
||
const doy = Math.floor((d.getTime() - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86_400_000);
|
||
return lines[doy % lines.length];
|
||
}
|
||
|
||
/** Segment a post into ≤280-char tweets on line boundaries. */
|
||
function segmentThread(text, limit = 280) {
|
||
const lines = String(text || '').split('\n');
|
||
const tweets = [];
|
||
let cur = '';
|
||
for (const line of lines) {
|
||
const candidate = cur ? `${cur}\n${line}` : line;
|
||
if (candidate.length > limit && cur) {
|
||
tweets.push(cur);
|
||
cur = line;
|
||
} else {
|
||
cur = candidate;
|
||
}
|
||
}
|
||
if (cur.trim()) tweets.push(cur);
|
||
return tweets;
|
||
}
|
||
|
||
// ---- formats ---------------------------------------------------------------
|
||
|
||
/** THE MORNING WIRE. data = { dateIso, counts: {mlb, wnba, ...}, loudest: grade row|null } */
|
||
function morningWire(data = {}) {
|
||
const parts = Object.entries(data.counts || {})
|
||
.filter(([, n]) => n > 0)
|
||
.map(([sp, n]) => `${n} ${sp.toUpperCase()}.`);
|
||
const lines = [`THE WIRE — ${etTime(data.dateIso, true)}`];
|
||
if (parts.length > 0) lines.push(parts.join(' '));
|
||
if (data.loudest && data.loudest.grade) {
|
||
const l = data.loudest;
|
||
const arch = l.archetype ? `a ${l.archetype} read` : `an ${l.grade} read`;
|
||
lines.push(`Loudest signal: ${arch} — ${l.player} ${l.stat_type || l.stat} ${sideCh(l.direction)}${l.line} · ${l.grade}.`);
|
||
}
|
||
const stark = starkLine('morning', data.dateIso);
|
||
if (stark) lines.push(stark);
|
||
lines.push('Slate’s graded. 3 free reads a day.', 'vyndr.app');
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'morningWire');
|
||
return text;
|
||
}
|
||
|
||
/** SIGNAL. g = one graded snapshot row (must carry gradedAt). */
|
||
function signal(g = {}) {
|
||
if (!g.player || !g.grade) return null;
|
||
const lines = [
|
||
`${g.player} — ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`,
|
||
];
|
||
if (g.archetype) lines.push(`${g.archetype} read.`);
|
||
const odds = g.gradedAt && g.gradedAt.odds != null ? ` at ${g.gradedAt.odds}` : '';
|
||
const ts = g.gradedAt && g.gradedAt.timestamp ? `Graded ${etTime(g.gradedAt.timestamp)} ET${odds}.` : null;
|
||
if (ts) lines.push(ts);
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'signal');
|
||
return text;
|
||
}
|
||
|
||
/** STREAK WATCH. rows = lens-applied streak rows. */
|
||
function streakWatch(rows = [], dateIso) {
|
||
const real = rows.filter((r) => r && r.lens && r.lens.read).slice(0, 4);
|
||
if (real.length === 0) return null;
|
||
const lines = [`STREAK WATCH — ${etTime(dateIso, true)}`];
|
||
for (const r of real) lines.push(`${r.player}: ${r.lens.read}.`);
|
||
const stark = starkLine('streak', dateIso);
|
||
if (stark) lines.push(stark);
|
||
lines.push('vyndr.app/explore');
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'streakWatch');
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* THE SETTLE. agg = ledger model aggregate; misses = settled miss rows.
|
||
* Record percentages ONLY at n≥20 (the aggregate already nulls them) —
|
||
* below that, counts only, honestly labeled.
|
||
*/
|
||
function theSettle({ aggregate, misses = [], dateIso } = {}) {
|
||
const a = aggregate || {};
|
||
const lines = [`THE SETTLE — ${etTime(dateIso, true)}`];
|
||
const tierA = a.by_tier && a.by_tier['A'];
|
||
if (tierA && tierA.settled > 0) lines.push(`A-tier: ${tierA.hits}-${tierA.misses}`);
|
||
if (a.settled > 0) lines.push(`All grades: ${a.hits}-${a.misses}${a.pushes ? ` (${a.pushes} push)` : ''}`);
|
||
else lines.push('Nothing settled today.');
|
||
if (a.hit_pct == null && a.settled > 0) {
|
||
lines.push(`Record building — ${a.settled} settled, ${a.pending || 0} pending. Percentages post at 20.`);
|
||
}
|
||
const missRows = misses.filter((m) => m && m.player_name).slice(0, 6);
|
||
if (missRows.length > 0) {
|
||
lines.push(`Misses, by name: ${missRows.map((m) => `${m.player_name} ${humanStat(m.stat)} ${sideCh(m.side)}${m.line} ❌`).join(' · ')}`);
|
||
}
|
||
const stark = starkLine(missRows.length > 0 ? 'settle_losing' : 'settle_winning', dateIso);
|
||
if (stark) lines.push(stark);
|
||
lines.push('vyndr.app/ledger');
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'theSettle');
|
||
return text;
|
||
}
|
||
|
||
/** RECEIPTS. row = settled ledger row with lock + close + outcome. */
|
||
function receipt(row = {}) {
|
||
if (!row.player_name || !row.outcome) return null;
|
||
const s = sideCh(row.side);
|
||
const lines = [
|
||
`Posted ${etTime(row.graded_at)} ET: ${row.player_name} ${humanStat(row.stat)} ${s}${row.line} · ${row.grade}${row.locked_odds ? ` · ${row.locked_odds}` : ''}`,
|
||
];
|
||
if (row.closing_line != null && row.clv_result) {
|
||
const clvNote = row.clv_result === 'beat' ? 'Market chased the read.'
|
||
: row.clv_result === 'faded' ? 'Market went the other way.' : 'Close held.';
|
||
lines.push(`Close: ${s}${row.closing_line}${row.closing_odds ? ` at ${row.closing_odds}` : ''}. ${clvNote}`);
|
||
}
|
||
const mark = row.outcome === 'hit' ? '✅' : row.outcome === 'miss' ? '❌' : 'push';
|
||
lines.push(`Result: ${row.actual_value != null ? `${row.actual_value} ` : ''}${mark}`);
|
||
if (row.clv_result === 'beat' && row.outcome === 'hit') {
|
||
const stark = starkLine('steam', row.settled_at);
|
||
if (stark) lines.push(stark);
|
||
}
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'receipt');
|
||
return text;
|
||
}
|
||
|
||
/** ARCHETYPE WATCH. grades = snapshot rows. */
|
||
function archetypeWatch(grades = [], dateIso) {
|
||
const byArch = {};
|
||
for (const g of grades) {
|
||
if (!g.archetype || !g.grade) continue;
|
||
byArch[g.archetype] = byArch[g.archetype] || [];
|
||
byArch[g.archetype].push(g);
|
||
}
|
||
const top = Object.entries(byArch).sort((a, b) => b[1].length - a[1].length)[0];
|
||
if (!top || top[1].length < 2) return null;
|
||
const [arch, rows] = top;
|
||
const lines = [
|
||
`${arch} watch: ${rows.length} on tonight’s slate.`,
|
||
...rows.slice(0, 4).map((g) => `${g.player} ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`),
|
||
'vyndr.app',
|
||
];
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'archetypeWatch');
|
||
return text;
|
||
}
|
||
|
||
/** LINE DISPATCH. move = intraday movement row on a graded prop. */
|
||
function lineDispatch(g = {}, nowIso) {
|
||
const m = g.movement;
|
||
if (!m || m.delta == null) return null;
|
||
const s = sideCh(g.direction);
|
||
const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line;
|
||
const arrow = m.currentLine > locked ? '▲' : '▼';
|
||
const kind = m.kind === 'steam' ? 'Steam.' : m.kind === 'value' ? 'Value — the read holds at the new number.' : 'Line moved.';
|
||
const lines = [
|
||
`${g.player} ${humanStat(g.stat_type || g.stat)}: ${s}${locked} → ${s}${m.currentLine} ${arrow}`,
|
||
`${kind} ${etTime(nowIso)} ET.`,
|
||
];
|
||
const text = lines.join('\n');
|
||
assertVoice(text, 'lineDispatch');
|
||
return text;
|
||
}
|
||
|
||
const STAT_HUMAN = {
|
||
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
|
||
strikeouts: 'Ks', stolen_bases: 'SB', earned_runs: 'ER', innings_pitched: 'IP',
|
||
points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT', outs: 'Outs', doubles: '2B',
|
||
};
|
||
function humanStat(stat) {
|
||
const k = String(stat || '').toLowerCase();
|
||
return STAT_HUMAN[k] || k.replace(/_/g, ' ');
|
||
}
|
||
|
||
module.exports = {
|
||
lintVoice,
|
||
segmentThread,
|
||
starkLine,
|
||
morningWire,
|
||
signal,
|
||
streakWatch,
|
||
theSettle,
|
||
receipt,
|
||
archetypeWatch,
|
||
lineDispatch,
|
||
__internals: { BANNED_PATTERNS, etTime, humanStat, assertVoice, STARK },
|
||
};
|