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 };
}