jevstrudel.git / website / src / jev / critic.mjs
1// The art critic, as the site uses it: recorded per revision in each SPEC
2// (tools/critic/) and asked live by the REPL's "is it art?" about whatever
3// is in the editor. The rubric itself (the criteria, the request, reading
4// the answers, the mean of runs) is worker/src/art-rubric.mjs, which the
5// Worker also scores listeners' songs with; it is re-exported here so every
6// caller keeps one import. This file adds recording verdicts in SPEC.md
7// and reading a song the way the critic does.
8import { CRITERIA, CRITIC_MODEL, RUBRIC } from '../../../worker/src/art-rubric.mjs';
9
10export { artOf, artRequest, CRITERIA, CRITIC_MODEL, meanVerdict, RUBRIC, songFacts } from '../../../worker/src/art-rubric.mjs';
11
12// Recording a verdict in SPEC.md frontmatter (tools/critic/), as text
13// edits, so the rest of the frontmatter keeps its quoting and comments.
14// `art:` goes on the revision after its output line, with `artRuns:` under
15// it; an unscorable revision is `art: null  # not scored: <reason>`.
16// Either replaces whatever the entry held before.
17export function setRevisionArt(text, rev, verdict) {
18  const entry = new RegExp(`^ {2}- rev: ${rev}\\s*\\n((?:^ {4}.*\\n)*)`, 'm');
19  const m = text.match(entry);
20  if (!m) throw new Error(`no rev ${rev} entry`);
21  const lines = m[1].split('\n').filter((l) => l && !/^ {4}art(?:Runs)?:/.test(l));
22  const at = lines.findIndex((l) => /^ {4}output:/.test(l));
23  const art = verdict.reason
24    ? [`    art: null  # not scored: ${verdict.reason}`]
25    : [`    art: ${verdict.art}`, `    artRuns: ${runsLine(verdict.artRuns)}`];
26  lines.splice(at < 0 ? lines.length : at + 1, 0, ...art);
27  return text.replace(entry, `  - rev: ${rev}\n${lines.join('\n')}\n`);
28}
29
30// The song-level `critic:` block, for the current revision's verdict,
31// replacing any; a new one goes after `song:`.
32export function setCritic(text, { art, artRuns, criteria }, date) {
33  const scores = Object.entries(criteria)
34    .map(([k, v]) => `    ${k}: ${v}\n`)
35    .join('');
36  const block =
37    `critic:\n  date: ${date}\n  model: ${CRITIC_MODEL}\n  method: ${RUBRIC}\n  art: ${art}\n` +
38    `  artRuns: ${runsLine(artRuns)}\n  criteria:\n${scores}`;
39  if (/^critic:\n(?: {2}.*\n)*/m.test(text)) return text.replace(/^critic:\n(?: {2}.*\n)*/m, block);
40  return text.replace(/^(song:.*\n)/m, `$1${block}`);
41}
42
43const runsLine = (runs) => `[${runs.join(', ')}]`;
44
45// The step a score lands on, for the hover: "3/5: a hook that sticks…".
46export const levelText = (key, score) => `${score.toFixed(1)}/5: ${CRITERIA[key].levels[Math.round(score)]}`;
47
48// The spec as the critic reads it: SPEC.md without its frontmatter, split
49// as the first critic did (`text.split('---', 2)`).
50export function specBody(text) {
51  const [, , ...rest] = text.split('---');
52  return rest.join('---').trim();
53}
54
55// The code as the critic reads it: song.js itself, without the `// @title`
56// line the site adds (songs.mjs).
57export const songCode = (code) => code.replace(/^\/\/ @title [^\n]*\n/, '');