lib/tracker-addition.mjs
Rama main · réplica de lectura del repositorio original.
/**
* lib/tracker-addition.mjs — the helpers every headless evaluator needs to emit
* a tracker-addition row, in one place.
*
* Four evaluators grew private copies of `tsvSafe` / `normalizedTrackerScore`
* (three of them also `slugifyCompany`), and the copies drifted
* (career-ops-hq/career-ops#3796). `gemini-eval.mjs`'s version concatenated
* rather than parsed, so a model answering `SCORE: 4.2 (strong fit)` wrote
* `4.2 (strong fit)/5` — neither a score nor a sentinel, which is the
* undecidable cell `merge-tracker.mjs` refuses outright. The evaluation was
* skipped, loudly but wholly, while the sibling copy had been immune the whole
* time for the same input. Neither file recorded that the other existed.
*
* A helper whose entire job is keeping one column parseable must not exist
* four times: the failure mode is silent divergence, and each copy has to be
* found before it can be fixed. Consolidating also makes the guard in
* `tests/evaluator-score-cell.test.mjs` an assertion of single-sourcing rather
* than a sweep for copies.
*
* Lives in lib/ rather than in `tracker-utils.mjs`, which pulls in js-yaml and
* the writer-lock machinery — a dependency the evaluators otherwise do not
* need. Like lib/placeholder-cell.mjs and lib/local-today.mjs, this file
* imports nothing.
*/
/**
* Flatten a value into something safe to place in a tab-separated cell.
*
* A raw tab or newline inside a field silently shifts every field after it into
* the wrong column, so they collapse to a single space rather than being
* escaped.
*
* @param {unknown} value - Raw field value.
* @returns {string} The value with tabs/newlines collapsed to spaces, trimmed.
*/
export function tsvSafe(value) {
return String(value ?? '').replace(/[\t\r\n]+/g, ' ').trim();
}
/**
* Slugify a company name for report and addition filenames.
*
* @param {unknown} value - Raw company name.
* @returns {string} Lowercase dash slug, or "unknown" when nothing survives.
*/
export function slugifyCompany(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'unknown';
}
/**
* Whether a value is a complete http(s) URL, and so can become a dedup key.
*
* @param {unknown} value - Candidate posting URL.
* @returns {boolean} True only for a parseable http/https URL with a host.
*/
export function isPostingUrl(value) {
try {
const parsed = new URL(value);
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname !== '';
} catch {
return false;
}
}
/**
* Normalize a model-reported score into the tracker's score cell.
*
* Evaluators extract the whole rest of the `SCORE:` line and validate only its
* numeric prefix, so `SCORE: 4.2 (strong fit)` passes validation and arrives
* here intact.
*
* @param {unknown} value - Score as extracted from the model's summary block.
* @returns {string} `X.X/5`, or the documented `N/A` sentinel (#1799).
*/
export function normalizedTrackerScore(value) {
const clean = tsvSafe(value);
// Parse, do not pattern-match the string. Two bugs lived in the old guard:
// `/n\/?a/i` was unanchored with an optional slash, so bare `na` matched and a
// real score with trailing prose -- `4.2 (final)`, `4.2 (internal)`,
// `4.5 - strong signal` -- was recorded as `N/A`; and the `/5` early return kept
// the whole string, so `4.2/10` became `4.2/5` and merged as a genuine score.
// Trailing prose is tolerated because models produce it; a denominator that is
// not 5, or a value outside 0..5, is refused rather than reinterpreted.
// A decimal comma (4,2) is the nl, de and fr spelling. On a 0..5 scale it
// cannot be a thousands separator.
const parsed = clean.match(/^(\d+(?:[.,]\d+)?)/);
if (!parsed) return 'N/A';
const score = parseFloat(parsed[1].replace(',', '.'));
// The denominator is load-bearing wherever it sits. Requiring it immediately
// after the number read `4.2 (strong fit)/10` -- a ten-point score with an
// annotation -- as a bare 4.2 and wrote `4.2/5`, the same wrong number
// `8/10` used to produce. The first denominator in the cell is taken and must
// be 5; absent one, the scale is the contract's. A cell that puts an unrelated
// fraction first (`4.2 (fit 3/4 axes)`) is refused rather than guessed at --
// N/A is recoverable, a wrong score is not.
const denominator = clean.match(/\/\s*(\d+(?:\.\d+)?)/);
const scale = denominator ? parseFloat(denominator[1]) : 5;
if (!Number.isFinite(score) || scale !== 5 || score < 0 || score > 5) return 'N/A';
return `${score}/5`;
}
/**
* Header row plus one data row for a tracker addition.
*
* Labels match `TSV_ADDITION_HEADER` in tracker-parse.mjs, then `via` and
* `url` only when set, in that order. An empty URL is omitted, not written
* as a blank cell. persist-evaluation-report.mjs writes this text.
* web/src/lib/report-persist.mjs keeps a matching copy because the Next
* bundle cannot import this file. tests/persist-evaluation-report.test.mjs
* fails if the two copies disagree.
*
* @param {{num: string, today: string, company: string, role: string, score: string, reportFile: string, notes: string, via: string|null, url: string}} row
* @returns {string}
*/
export function trackerTsvLine({ num, today, company, role, score, reportFile, notes, via, url }) {
const labels = ['num', 'date', 'company', 'role', 'status', 'score', 'pdf', 'report', 'notes'];
const fields = [
num, today, company, role, 'Evaluated', score, '❌',
`[${num}](reports/${reportFile})`, notes,
];
if (via) {
labels.push('via');
fields.push(via);
}
if (url) {
labels.push('url');
fields.push(url);
}
return `${labels.join('\t')}\n${fields.join('\t')}\n`;
}
const SUMMARY_LINE = /^\s*(?:[-+]\s+|\*\s+)?[*_]*\s*(COMPANY|ROLE|SCORE|ARCHETYPE|LEGITIMACY)\s*[*_]*\s*:\s*[*_]*\s*(.*?)\s*[*_]*\s*$/i;
/**
* Read the fields of a model's `---SCORE_SUMMARY---` block.
*
* Models often bold or bullet the keys (`**COMPANY:** Acme`, `- **SCORE**: 4.2/5`).
* Those marks and a trailing `/5` on the score are dropped, so the values can
* go straight into a report header and a tracker cell.
*
* @param {string} text - The model's whole reply.
* @returns {{company: string, role: string, score: string, archetype: string, legitimacy: string} | null}
* null when the reply has no block. A key the block does not carry is ''.
*/
export function parseScoreSummary(text) {
const block = String(text ?? '').match(/---SCORE_SUMMARY---\s*([\s\S]*?)---END_SUMMARY---/);
if (!block) return null;
const fields = { company: '', role: '', score: '', archetype: '', legitimacy: '' };
for (const line of block[1].split('\n')) {
const m = line.match(SUMMARY_LINE);
const key = m?.[1].toLowerCase();
if (key && !fields[key]) fields[key] = m[2];
}
fields.score = fields.score.replace(/\s*\/\s*5(?!\d|\.\d)[.,;]?/, '');
return fields;
}