scorecard-fence.ts4.2 KBView on GitHub
/**
 * Parsing + banding for the ```scorecard fence.
 *
 * The fence body is an ordinary GFM pipe table — column 1 the criterion, column 2 the score,
 * any further numeric column an extra series, and the first non-numeric column after the score
 * the reasoning. Keeping the body as plain markdown means the same content still reads correctly
 * in Slack, a git diff, or a plain-markdown export where the node view never runs.
 *
 * See apps/server/docs/coaching-agent.md §3.2 part E.
 */

export type ScoreTone = 'red' | 'amber' | 'green' | 'muted';

/** Peter's bands: "a 2 out of 5 is bad, a 3 to 4 is yellow, and 4 to 5 is green." */
export const SCORE_BANDS: ReadonlyArray<{ under: number; tone: ScoreTone }> = [
  { under: 3, tone: 'red' },
  { under: 4, tone: 'amber' },
  { under: Number.POSITIVE_INFINITY, tone: 'green' },
];

/** Radar domain upper bound. The rubric is defined 0–5 and does not vary. */
export const SCORE_MAX = 5;

export function toneForScore(score: number | null): ScoreTone {
  if (score === null || Number.isNaN(score)) return 'muted';
  return SCORE_BANDS.find((b) => score < b.under)?.tone ?? 'green';
}

export interface ScorecardRow {
  criterion: string;
  score: number | null;
  reason: string;
  /** Extra numeric columns, by header — each becomes its own radar series. */
  extra: Record<string, number | null>;
}

export interface ParsedScorecard {
  /** Header of column 1, e.g. "Discovery" — used as the card's title. */
  title: string;
  scoreLabel: string;
  reasonLabel: string | null;
  /** Numeric columns beyond `score`, in header order. */
  seriesKeys: string[];
  rows: ScorecardRow[];
  average: number | null;
}

const isDivider = (cells: string[]): boolean =>
  cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c.trim()));

function splitRow(line: string): string[] {
  const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
  return trimmed.split('|').map((c) => c.trim());
}

/** `4`, `4/5`, `3.8`, `na`, `n/a`, `—` → number | null. */
export function parseScore(raw: string): number | null {
  const cleaned = raw.trim().toLowerCase();
  if (!cleaned || cleaned === 'na' || cleaned === 'n/a' || cleaned === '—' || cleaned === '-')
    return null;
  const match = /^(-?\d+(?:\.\d+)?)/.exec(cleaned.replace(/\*/g, ''));
  if (!match) return null;
  const n = Number(match[1]);
  return Number.isFinite(n) ? n : null;
}

/**
 * Parse a fence body into a scorecard. Returns null on anything that is not a well-formed pipe
 * table, so a malformed fence renders its body raw instead of throwing inside the editor.
 */
export function parseScorecard(body: string): ParsedScorecard | null {
  const lines = body
    .split('\n')
    .map((l) => l.trim())
    .filter(Boolean);
  if (lines.length < 3) return null;

  const header = splitRow(lines[0]);
  if (header.length < 2) return null;
  if (!isDivider(splitRow(lines[1]))) return null;

  const [title, scoreLabel, ...rest] = header;
  const dataLines = lines.slice(2).filter((l) => l.includes('|'));
  if (dataLines.length === 0) return null;

  // A trailing column is "extra series" only when every data row has a number in it; the
  // reasoning column is whichever remaining column is not consistently numeric.
  const restIsNumeric = rest.map((_, i) =>
    dataLines.every((line) => parseScore(splitRow(line)[i + 2] ?? '') !== null),
  );

  const seriesKeys = rest.filter((_, i) => restIsNumeric[i]);
  const reasonIdx = restIsNumeric.findIndex((numeric) => !numeric);
  const reasonLabel = reasonIdx >= 0 ? rest[reasonIdx] : null;

  const rows: ScorecardRow[] = dataLines.map((line) => {
    const cells = splitRow(line);
    const extra: Record<string, number | null> = {};
    rest.forEach((key, i) => {
      if (restIsNumeric[i]) extra[key] = parseScore(cells[i + 2] ?? '');
    });
    return {
      criterion: cells[0] ?? '',
      score: parseScore(cells[1] ?? ''),
      reason: reasonIdx >= 0 ? (cells[reasonIdx + 2] ?? '') : '',
      extra,
    };
  });

  const scored = rows.map((r) => r.score).filter((s): s is number => s !== null);
  const average = scored.length
    ? Math.round((scored.reduce((a, b) => a + b, 0) / scored.length) * 10) / 10
    : null;

  return { title, scoreLabel, reasonLabel, seriesKeys, rows, average };
}