Content studio API + preview page; widen the reachability guard; correct

two inventory errors

INVENTORY CORRECTION, and it was mine. Phase 2's two "orphans" are NOT
orphans -- my board grepped only web/src/app and missed component-level
mounting. The transitive check says both are already mounted:

  BookComparisonPanel -> GradeResultCard -> app/scan/page.tsx
  NewsWire            -> ExploreHub      -> app/explore/page.tsx

So book comparison is DONE (wired to /api/books, rendering on the grade
card) and THE WIRE is DONE-BY-DESIGN, mounted in ExploreHub. Its header
names an "Offseason Hub" as its home, and that hub genuinely does not
exist -- but that is board item #8, not a mounting bug, and inventing a
surface to satisfy a comment would be the wrong fix.

The lesson is the same one this session keeps teaching: I checked one
directory and reported a conclusion the check could not support.

ALSO CAUGHT: I overwrote src/routes/content.js, which was the Session-29
content-templates route, by picking a filename without looking. Restored
from git with no work lost; the new surface lives at
/api/content-studio and both now coexist.

PHASE 0/1 — /api/content-studio serves finished posts (copy, branded card,
card_svg, the fact_contract each was REQUIRED to have, and the facts that
actually backed it) plus a POST for editorial status in Redis. Private via
internal key; the Next proxy holds the key server-side so the browser
never does. /studio renders it as a thin client -- copy and card side by
side with the fact contract visible, because reviewing copy by reading it
is exactly how a wrong number ships. Never-blank: a night with nothing
generated says so.

API-FIRST is the point: the endpoint an autonomous poster will call is the
one the page already renders, so the agent handoff is a pointer change,
not a rebuild. Contract documented at docs/CONTENT-STUDIO-API.md.

EXPRESS 5 BROKE 23 SUITES at first: `router.get('/:date?')` throws at
mount time in Express 5, taking down everything that imports app.js. Two
explicit routes instead.

PHASE 3 — the reachability guard is widened from grade-fields-only to a
general built-but-unread check. Book comparison, THE WIRE and the content
studio are now registered surfaces; a page counts as its own entry point
(Next mounts it by convention) while everything else must trace to one.
22 checks green; a registered-but-unimported surface still goes red.

FULLY ISOLATED: read-only on model/slate/ledger, serving fingerprint
verified unchanged, accrual clock unchanged at 0 eligible dates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-07 18:51:52 -04:00
parent 74aa75945e
commit c575a708c7
9 changed files with 435 additions and 26 deletions
+4
View File
@@ -212,6 +212,10 @@ app.use('/api/slips', require('./routes/slips'));
// the public surface; the Next.js admin route proxies through with
// the key kept server-side.
app.use('/api/internal', internalRoutes);
// Content STUDIO — finished posts (copy + card + fact-contract) for review and,
// later, for an autonomous poster. Distinct from /api/content (Session 29),
// which serves structured content objects by data level.
app.use('/api/content-studio', require('./routes/contentStudio'));
// A1 S3 — partner attribution report. Internal-key gated (router-level
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
app.use('/api/partners', require('./routes/partners'));
+175
View File
@@ -0,0 +1,175 @@
'use strict';
/**
* /api/content-studio — the generated-post review surface.
*
* NOT to be confused with `/api/content` (Session 29), which serves structured
* content OBJECTS by data level. This serves finished POSTS from the content
* engine: copy, branded card, and the fact-contract each was built from.
*
* ── API-FIRST, ON PURPOSE ────────────────────────────────────────────────
* This is the contract Kev's preview page consumes today and an autonomous
* poster consumes later. The page is a thin client; no posting logic lives in
* it. Pointing a bot here needs no change on this side, which is the entire
* reason it is an API before it is a screen.
*
* ── READ-ONLY WHERE IT COUNTS ────────────────────────────────────────────
* It serves generated content. Beyond the read-only pulls the engine already
* makes, it touches no serving, model or ledger table — zero effect on the
* repaired-champion accrual clock. Approval status lives in Redis, because an
* editorial decision is not a model fact and must never sit beside ledger rows.
*/
const express = require('express');
const { requireInternalAuth } = require('../middleware/internalAuth');
const engine = require('../services/content/contentEngine');
const { toSvg } = require('../services/content/cardRenderer');
const router = express.Router();
router.use(requireInternalAuth({ loopbackOnly: false })); // private: Kev's desk, then an agent's
const STATUS_KEY = (date) => `contentstudio:status:${date}`;
const VALID_STATUS = new Set(['pending', 'approved', 'skipped', 'regenerate_requested']);
const dateET = () => new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
function registerTemplates() {
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
try { engine.registerTemplate(require(`../services/content/templates/${t}`)); } catch { /* idempotent */ }
}
}
/**
* GET /api/content-studio/:date?
*
* → { date, count, posts: [{ id, label, sport, status, ok, skipped, reason,
* honest_absence, copy, card, card_svg, fact_contract, facts }] }
*
* `fact_contract` is what the post was REQUIRED to have; `facts` is what
* actually backed it. A reviewer — or an agent — can check the claim rather
* than trust the sentence.
*/
// EXPRESS 5 DROPPED THE `?` OPTIONAL-PARAM SYNTAX -- `'/:date?'` throws at mount
// time and takes down every suite that imports app.js. Two explicit routes.
async function handleGet(req, res) {
try {
registerTemplates();
const date = req.params.date || dateET();
const deps = req.app.get('contentStudioDeps') || (await buildDeps(date));
const results = await engine.generateAll({ ...deps, date });
let statuses = {};
try { statuses = (await require('../utils/redis').cacheGet(STATUS_KEY(date))) || {}; } catch { statuses = {}; }
const posts = results.map((r) => {
const t = engine.getTemplate(r.id) || {};
return {
id: r.id,
label: t.label || r.id,
sport: t.sport || null,
status: statuses[r.id] || 'pending',
ok: r.ok === true,
skipped: r.skipped === true,
reason: r.reason || null,
honest_absence: r.honest_absence === true,
copy: r.copy || null,
card: r.card || null,
card_svg: r.card ? toSvg(r.card) : null,
fact_contract: t.requires || [],
facts: r.facts || null,
};
});
res.json({ date, count: posts.length, posts });
} catch (e) {
res.status(500).json({ error: e.message });
}
}
router.get('/', handleGet);
router.get('/:date', handleGet);
/** POST /api/content-studio/:date/:id/status { status } — editorial state only. */
router.post('/:date/:id/status', express.json(), async (req, res) => {
const { date, id } = req.params;
const status = String((req.body || {}).status || '');
if (!VALID_STATUS.has(status)) {
return res.status(400).json({ error: `status must be one of ${[...VALID_STATUS].join(', ')}` });
}
try {
const { cacheGet, cacheSet } = require('../utils/redis');
const cur = (await cacheGet(STATUS_KEY(date))) || {};
cur[id] = status;
await cacheSet(STATUS_KEY(date), cur, 60 * 60 * 24 * 14);
return res.json({ ok: true, date, id, status });
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
/** Real sources. All READ-ONLY. */
async function buildDeps(date) {
const sg = require('../services/model/servedGrade');
const { knownNumber } = require('../utils/known');
const sb = require('../utils/supabase').getSupabaseServiceClient();
const mlb = require('../services/adapters/mlbStatsAdapter');
const gradeDistribution = async () => {
if (!sb) return { total: null };
const { data } = await sb.from('model_snapshots')
.select('p_win, refused').eq('sport', 'mlb').eq('game_date', date).limit(5000);
const usable = (data || []).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 };
};
const settledStreaks = async () => {
if (!sb) return [];
const { data } = await sb.from('ledger_entries')
.select('player_name, player_key, game_date, outcome')
.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
.in('outcome', ['hit', 'miss']).limit(5000);
const by = new Map();
for (const r of data || []) {
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 hitterForm = async () => {
if (!sb) return [];
const { data } = await sb.from('model_snapshots')
.select('player_name').eq('sport', 'mlb').eq('game_date', date).limit(400);
const names = [...new Set((data || []).map((r) => r.player_name).filter(Boolean))].slice(0, 40);
const out = [];
for (const n of names) {
try {
const r = await mlb.getPlayerStats(n);
const log = (r && r.found && Array.isArray(r.fullLog)) ? r.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;
};
return { servedGrade: sg, gradeDistribution, settledStreaks, hitterForm };
}
module.exports = router;
module.exports.__internals = { buildDeps, VALID_STATUS, STATUS_KEY };