mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
feat(process): the pipeline records model usage in the review document's machine block (#737)
The weekly process metrics weigh tracks and the nightly ship review in the order quality, speed, tokens (#707), but the third axis had no source: the claude-code-action step hides usage from the Actions log on purpose, nothing read its execution_file, and the #728 reader printed "no data" every week. scripts/model-usage.mjs is the single module that builds and parses the line: `<!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` (sums over every model in the last `result` message, `result.usage` when modelUsage is absent) or `<!-- hp:usage-none reason=<code> -->`. Only the result message is read; the rest of the file holds tool results, and no byte of it is printed. A new step right after Review in both model_review jobs (always(), continue-on-error) hands the line out as the job output `usage`. Usage is a reporting figure like the stage duration, so it travels as a job output and not through the sealed artifact: REQUIRED_FILES and the #556 gate are unchanged. Publication treats the line as untrusted input and writes the normalized form as the last line of the anchor block (review-doc-guard --anchor --usage=) or right after the SHIP-REVIEW block; empty becomes reason=missing, anything off-format reason=invalid. The #728 reader now takes the line only from the machine block: a reviewer quoting the previous round in prose no longer doubles its usage, and "no data" is counted as missing, never as zero. PROCESS.md §10.4 documents the source, the format and why it is a job output. Issue: #737 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Расход модели одной машинной строкой (#737, PROCESS.md §10.4).
|
||||
*
|
||||
* node scripts/model-usage.mjs --execution-file=<путь>
|
||||
*
|
||||
* Шаг `Review` (`claude-code-action`) отдаёт выход `execution_file` — JSON-массив
|
||||
* всех сообщений сессии SDK, он пишется и при ошибке SDK. Журнал Actions
|
||||
* расхода не покажет: action сознательно печатает результат урезанным
|
||||
* («without exposing token usage or cost details»). Других следов расхода у
|
||||
* конвейера нет, а без них у отчёта «Метрики процесса» нет третьей оси
|
||||
* «качество → скорость → токены» (#707, #728).
|
||||
*
|
||||
* Здесь — единственное место, которое собирает и разбирает строку (у
|
||||
* публикации и отчёта копии формата нет):
|
||||
*
|
||||
* <!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->
|
||||
* <!-- hp:usage-none reason=<код> -->
|
||||
*
|
||||
* Ровно пять ключей в этом порядке, N — десятичное целое без знака и без
|
||||
* ведущих нулей, не длиннее 12 цифр. Формат замораживается первой публикацией:
|
||||
* документы ревью задним числом не правят. Поэтому ключи — имена SDK без
|
||||
* перевода, а «нет данных» — отдельный маркер, а не отсутствие строки: иначе
|
||||
* «конвейер не смог» не отличить от «документа до #737». Предварительный
|
||||
* читатель #728 строку данных читает, а `hp:usage-none` — нет: после
|
||||
* `hp:usage` ему нужен пробел. В строке нет SHA и хешей: блок якорей кода
|
||||
* считает якорем каждые 40 hex-символов (`materialAnchorsFrom`).
|
||||
*
|
||||
* Из файла берётся только последнее сообщение `type: "result"`. Остальное в
|
||||
* нём — результаты инструментов, то есть возможные секреты, и ни одна их
|
||||
* буква не печатается: даже сообщение об ошибке JSON.parse цитирует текст,
|
||||
* поэтому причины здесь — коды, а не тексты ошибок. Скрипт не падает на
|
||||
* данных: любой исход — строка, код выхода 0.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
/** Ключи строки данных — в порядке строки. */
|
||||
export const USAGE_KEYS = Object.freeze([
|
||||
'input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'num_turns',
|
||||
]);
|
||||
|
||||
/** Токены — поля `modelUsage[<модель>]` SDK (camelCase) и `result.usage` (snake_case). */
|
||||
const MODEL_USAGE_FIELDS = Object.freeze({
|
||||
input_tokens: 'inputTokens',
|
||||
output_tokens: 'outputTokens',
|
||||
cache_creation_input_tokens: 'cacheCreationInputTokens',
|
||||
cache_read_input_tokens: 'cacheReadInputTokens',
|
||||
});
|
||||
|
||||
/**
|
||||
* Причины «нет данных». Первые четыре — снятие из `execution_file`, две
|
||||
* последние — публикация: строки от стадии модели нет (`missing`) или она
|
||||
* пришла не по формату (`invalid`).
|
||||
*/
|
||||
export const USAGE_REASONS = Object.freeze([
|
||||
'no-execution-file', 'unreadable', 'no-result', 'no-usage', 'missing', 'invalid',
|
||||
]);
|
||||
|
||||
/** Наибольшее значение, которое помещается в 12 цифр. */
|
||||
const MAX_COUNT = 999_999_999_999;
|
||||
const COUNT = '(0|[1-9]\\d{0,11})';
|
||||
const DATA_RE = new RegExp(`^<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${COUNT}`).join(' ')} -->$`);
|
||||
const NONE_RE = new RegExp(`^<!-- hp:usage-none reason=(${USAGE_REASONS.join('|')}) -->$`);
|
||||
|
||||
const isCount = (value) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_COUNT;
|
||||
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Строка из разобранного вида: `{ input_tokens, …, num_turns }` либо
|
||||
* `{ reason }`. Обратна `parseUsageLine`. Вход не по контракту — исключение:
|
||||
* это ошибка вызывающего кода, не данных.
|
||||
*/
|
||||
export function formatUsage(usage) {
|
||||
if (isRecord(usage) && 'reason' in usage) {
|
||||
if (!USAGE_REASONS.includes(usage.reason)) throw new TypeError(`model-usage: неизвестная причина ${JSON.stringify(usage.reason)}`);
|
||||
return `<!-- hp:usage-none reason=${usage.reason} -->`;
|
||||
}
|
||||
if (!isRecord(usage) || !USAGE_KEYS.every((key) => isCount(usage[key]))) {
|
||||
throw new TypeError('model-usage: расход не по формату');
|
||||
}
|
||||
return `<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${usage[key]}`).join(' ')} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строгий разбор одной строки: `{ input_tokens, …, num_turns }`,
|
||||
* `{ reason }` или `null` (не по формату). Пробелы по краям, лишний или
|
||||
* пропущенный ключ, другой порядок, знак, дробь, ведущий ноль, 13 цифр,
|
||||
* перевод строки внутри — `null`.
|
||||
*/
|
||||
export function parseUsageLine(line) {
|
||||
const text = String(line ?? '');
|
||||
const data = DATA_RE.exec(text);
|
||||
if (data) return Object.fromEntries(USAGE_KEYS.map((key, index) => [key, Number(data[index + 1])]));
|
||||
const none = NONE_RE.exec(text);
|
||||
return none ? { reason: none[1] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка для документа ревью из недоверенного выхода стадии модели (#556):
|
||||
* пусто — `missing`, не по формату — `invalid`, иначе та же строка.
|
||||
*/
|
||||
export function publishedUsageLine(raw) {
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return formatUsage({ reason: 'missing' });
|
||||
return formatUsage(parseUsageLine(text) ?? { reason: 'invalid' });
|
||||
}
|
||||
|
||||
/** Последняя строка формата в тексте машинного блока, разобранная, либо `null`. */
|
||||
export function lastUsageIn(text) {
|
||||
let found = null;
|
||||
for (const line of String(text ?? '').split('\n')) {
|
||||
const parsed = parseUsageLine(line.trim());
|
||||
if (parsed) found = parsed;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Расход из сообщений сессии SDK: сумма по всем моделям `modelUsage`
|
||||
* последнего `result` (лимит подписки тратят и вспомогательные модели); без
|
||||
* `modelUsage` — те же ключи из `result.usage`. Значение не целое ≥ 0 — это
|
||||
* не данные (`unreadable`), а не ноль.
|
||||
*/
|
||||
export function usageFromMessages(messages) {
|
||||
if (!Array.isArray(messages)) return { reason: 'unreadable' };
|
||||
const result = messages.findLast((message) => isRecord(message) && message.type === 'result');
|
||||
if (!result) return { reason: 'no-result' };
|
||||
const models = isRecord(result.modelUsage) ? Object.values(result.modelUsage) : [];
|
||||
const usage = {};
|
||||
if (models.length) {
|
||||
for (const [key, field] of Object.entries(MODEL_USAGE_FIELDS)) {
|
||||
usage[key] = 0;
|
||||
for (const model of models) {
|
||||
const value = isRecord(model) ? model[field] : undefined;
|
||||
if (!isCount(value)) return { reason: 'unreadable' };
|
||||
usage[key] += value;
|
||||
}
|
||||
}
|
||||
} else if (isRecord(result.usage)) {
|
||||
for (const key of Object.keys(MODEL_USAGE_FIELDS)) usage[key] = result.usage[key];
|
||||
} else {
|
||||
return { reason: 'no-usage' };
|
||||
}
|
||||
usage.num_turns = result.num_turns;
|
||||
return USAGE_KEYS.every((key) => isCount(usage[key])) ? usage : { reason: 'unreadable' };
|
||||
}
|
||||
|
||||
/** Расход из файла `execution_file`; файла нет или путь пуст — `no-execution-file`. */
|
||||
export function usageFromExecutionFile(path, read = (file) => readFileSync(file, 'utf8')) {
|
||||
if (!String(path ?? '').trim()) return { reason: 'no-execution-file' };
|
||||
let text;
|
||||
try {
|
||||
text = read(path);
|
||||
} catch (error) {
|
||||
return { reason: error?.code === 'ENOENT' ? 'no-execution-file' : 'unreadable' };
|
||||
}
|
||||
let messages;
|
||||
try {
|
||||
messages = JSON.parse(text);
|
||||
} catch {
|
||||
return { reason: 'unreadable' };
|
||||
}
|
||||
return usageFromMessages(messages);
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const flag = process.argv.slice(2).find((item) => item.startsWith('--execution-file='));
|
||||
let line;
|
||||
try {
|
||||
line = formatUsage(usageFromExecutionFile(flag ? flag.slice('--execution-file='.length) : ''));
|
||||
} catch {
|
||||
line = formatUsage({ reason: 'unreadable' });
|
||||
}
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
+37
-20
@@ -28,8 +28,9 @@ import { isMainModule } from './spawn-portable.mjs';
|
||||
import { classify } from './change-classes.mjs';
|
||||
import { labelTrack, parseNumstat } from './process-track.mjs';
|
||||
import { issueTrailers } from './release-membership.mjs';
|
||||
import { verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { parseAnchorBlock } from './ship-review.mjs';
|
||||
import { USAGE_KEYS, lastUsageIn } from './model-usage.mjs';
|
||||
import { ANCHOR_MARKER, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { SHIP_REVIEW_ANCHOR, parseAnchorBlock } from './ship-review.mjs';
|
||||
import { PIPELINE_EVENTS } from './wait-verdict.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
@@ -195,7 +196,7 @@ export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red',
|
||||
export const PIPELINE_STAGES = ['guard', 'prepare', 'model', 'integrate'];
|
||||
export const VOLUME_BUCKETS = ['≤30', '31–200', '201–1000', '>1000'];
|
||||
export const VALIDATE_WORKFLOW = 'Проверка (CI)';
|
||||
export const TOKENS_NO_DATA = 'Токены: нет данных (конвейер не записывает расход модели)';
|
||||
export const TOKENS_NO_DATA = 'Токены: нет данных (ни один документ ревью не несёт расход модели)';
|
||||
|
||||
/**
|
||||
* Причины `validate-red` и `conflict` (К3). У конвейера нет для них отдельной
|
||||
@@ -209,12 +210,6 @@ export const NOT_RUN_VALIDATE_RE = /^\*\*Ревью не запускалось:
|
||||
export const NOT_RUN_CONFLICT_RE = /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m;
|
||||
/** Маршрут вердикта show (#726): машинная строка комментария конвейера. */
|
||||
export const ROUTE_RE = /<!--\s*hp:route\s+(reclassify|owner-question)\b[^>]*-->/;
|
||||
/**
|
||||
* Машинная строка расхода модели в документе ревью — её запишет конвейер
|
||||
* (issue F, К6 #728): `<!-- hp:usage input_tokens=N output_tokens=N … -->`.
|
||||
* Формат предварительный: пока строки нет нигде, отчёт печатает «нет данных».
|
||||
*/
|
||||
export const USAGE_LINE_RE = /<!--\s*hp:usage\s+([^>]*?)\s*-->/g;
|
||||
|
||||
/** Признак конвейера по `kind`: переименование в `wait-verdict.mjs` ломает загрузку, а не молча даёт `unknown`. */
|
||||
function pipelineEvent(kind) {
|
||||
@@ -444,19 +439,37 @@ export function shipFindings(reviewDocs = []) {
|
||||
return { byIssue, docs };
|
||||
}
|
||||
|
||||
/** К6. Расход модели по машинным строкам документов ревью; ни одной — `null` («нет данных»). */
|
||||
/**
|
||||
* #737: строка расхода модели документа ревью — только из машинного блока,
|
||||
* который пишет конвейер: после `ANCHOR_MARKER` (ревью ТЗ и кода) или после
|
||||
* последнего `SHIP_REVIEW_ANCHOR` (`SHIP-REVIEW-*`). Проза выше маркера не
|
||||
* источник: ревьюер r2 цитирует документ r1, и расход r1 считался бы дважды.
|
||||
* Формат и разбор — `model-usage.mjs`; строки нет — `null`.
|
||||
*/
|
||||
export function reviewDocUsage(doc) {
|
||||
const text = String(doc?.text ?? '');
|
||||
const at = SHIP_DOC.test(String(doc?.path || '')) ? text.lastIndexOf(SHIP_REVIEW_ANCHOR) : text.indexOf(ANCHOR_MARKER);
|
||||
return at < 0 ? null : lastUsageIn(text.slice(at));
|
||||
}
|
||||
|
||||
/**
|
||||
* К6 (#728, #737). Расход модели по документам ревью: `docs` — документы с
|
||||
* данными, `totals` — суммы по ключам строки (`null`, пока данных нет),
|
||||
* `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы
|
||||
* не входит. Документы без строки (до #737) не считаются ни тем, ни другим.
|
||||
*/
|
||||
export function tokenUsage(reviewDocs = []) {
|
||||
const totals = {};
|
||||
const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0]));
|
||||
let docs = 0;
|
||||
let missing = 0;
|
||||
for (const doc of reviewDocs || []) {
|
||||
let found = false;
|
||||
for (const match of String(doc?.text ?? '').matchAll(USAGE_LINE_RE)) {
|
||||
found = true;
|
||||
for (const [, key, value] of match[1].matchAll(/([a-z_]+)=(\d+)/g)) totals[key] = (totals[key] || 0) + Number(value);
|
||||
}
|
||||
if (found) docs += 1;
|
||||
const usage = reviewDocUsage(doc);
|
||||
if (!usage) continue;
|
||||
if ('reason' in usage) { missing += 1; continue; }
|
||||
docs += 1;
|
||||
for (const key of USAGE_KEYS) totals[key] += usage[key];
|
||||
}
|
||||
return docs ? { docs, totals } : null;
|
||||
return { docs, totals: docs ? totals : null, missing };
|
||||
}
|
||||
|
||||
/** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */
|
||||
@@ -953,9 +966,13 @@ function renderTokens(lines, tokens) {
|
||||
lines.push('');
|
||||
lines.push('### Токены');
|
||||
lines.push('');
|
||||
lines.push(tokens
|
||||
? `Токены по ${tokens.docs} документам ревью: ${Object.entries(tokens.totals).map(([key, value]) => `${key} ${value}`).join(' · ')}.`
|
||||
lines.push(tokens?.docs
|
||||
? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.`
|
||||
: `${TOKENS_NO_DATA}.`);
|
||||
if (tokens?.missing) {
|
||||
lines.push('');
|
||||
lines.push(`Без данных о расходе: ${tokens.missing}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompare(lines, c) {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { publishedUsageLine } from './model-usage.mjs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const REVIEW_DOC_ALLOWLIST = ['docs/reviews/'];
|
||||
@@ -312,7 +313,7 @@ const ANCHOR_ROUTES = ['fix', 'reclassify'];
|
||||
/** Идентификатор критерия в якоре: только формат, смысл судит `reviewRoute`. */
|
||||
const ANCHOR_CRITERION = /^[a-z][a-z0-9-]{0,39}$/;
|
||||
|
||||
export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody, route, criterion } = {}) {
|
||||
export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody, route, criterion, usage } = {}) {
|
||||
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
|
||||
const lines = [
|
||||
ANCHOR_MARKER,
|
||||
@@ -360,6 +361,12 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, hi
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
// #737: расход сессии модели — последней строкой блока (`model-usage.mjs`).
|
||||
// Значение приходит выходом недоверенной стадии (#556) и разбирается строго:
|
||||
// пусто — `reason=missing`, не по формату — `reason=invalid`. Прежние строки
|
||||
// блока не меняются, их разбор тоже. Не передано вовсе (вызов до #737) —
|
||||
// строки нет.
|
||||
if (usage != null) lines.push(publishedUsageLine(usage));
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
@@ -806,6 +813,8 @@ if (invokedDirectly) {
|
||||
// #726: маршрут и критерий вердикта; вне словаря и формата — не пишутся.
|
||||
route: value('route'),
|
||||
criterion: value('criterion'),
|
||||
// #737: строка расхода модели; без флага (вызов до #737) строки нет.
|
||||
usage: argv.some((item) => item.startsWith('--usage=')) ? value('usage') : undefined,
|
||||
};
|
||||
const text = readFileSync(path, 'utf8');
|
||||
writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8');
|
||||
|
||||
@@ -33,6 +33,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { lastUsageIn, publishedUsageLine } from './model-usage.mjs';
|
||||
import { issueTrailers, readCandidateHistory } from './release-membership.mjs';
|
||||
import { parseDocName } from './reviews-index.mjs';
|
||||
|
||||
@@ -341,9 +342,16 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '', doc =
|
||||
/**
|
||||
* Машинный блок документа: его пишет публикация, читает `check`. #727: строки
|
||||
* `mode` и `patches` дописываются в конец, прежние строки не меняются.
|
||||
*
|
||||
* #737: `usage` — строка расхода модели (`model-usage.mjs`) сразу после
|
||||
* закрывающего ``` блока, а не строкой внутри: формат один на все документы
|
||||
* ревью, а содержимое блока, которое читают гейт беты и покрытие, не меняется.
|
||||
* Значение — выход недоверенной стадии: пусто — `reason=missing`, не по
|
||||
* формату — `reason=invalid`. Не передано (вызов до #737) — строки нет.
|
||||
*/
|
||||
export function anchorBlock({
|
||||
tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '', mode = null, patches = null,
|
||||
usage = null,
|
||||
}) {
|
||||
return [
|
||||
SHIP_REVIEW_ANCHOR,
|
||||
@@ -361,6 +369,7 @@ export function anchorBlock({
|
||||
...(mode ? [`mode ${mode}`] : []),
|
||||
...(patches ? [`patches ${formatPatches(patches)}`] : []),
|
||||
'```',
|
||||
...(usage != null ? [publishedUsageLine(usage)] : []),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -368,6 +377,8 @@ export function anchorBlock({
|
||||
/**
|
||||
* Поля машинного блока. `base`, `mode` и `patches` появляются, только если
|
||||
* блок их несёт: документ до #727 без `patches` покрывает задачи по номеру.
|
||||
* #737: `usage` — разобранная строка расхода после последнего маркера блока,
|
||||
* если она есть (`{ input_tokens, …, num_turns }` либо `{ reason }`).
|
||||
*/
|
||||
export function parseAnchorBlock(text = '') {
|
||||
const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR);
|
||||
@@ -380,6 +391,7 @@ export function parseAnchorBlock(text = '') {
|
||||
}));
|
||||
const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null);
|
||||
const given = (value) => value != null && value !== '' && value !== '—';
|
||||
const usage = lastUsageIn(String(text).slice(at));
|
||||
return {
|
||||
tag: fields.tag || null,
|
||||
candidate: fields.candidate || null,
|
||||
@@ -390,6 +402,7 @@ export function parseAnchorBlock(text = '') {
|
||||
...(given(fields.base) ? { base: fields.base } : {}),
|
||||
...(given(fields.mode) ? { mode: fields.mode } : {}),
|
||||
...('patches' in fields ? { patches: parsePatches(fields.patches) } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user