mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
fix(process-metrics): count model tokens only for the report window (#761)
tokenUsage summed the usage line of every review document in HEAD: the report had no window, and every week repeated the whole history. A document now enters a week's tokens when the commit that added it to dev falls in [since, until]. fetchSnapshot reads the committer date from git log -M --diff-filter=AR over docs/reviews and legacy/reviews: an add sets the date, a rename (the #682 archive move) carries it to the new path instead of adding the document again. The "missing" count of #737 (hp:usage-none) follows the same window. ship findings keep reading every SHIP-REVIEW document; only the token sum is windowed. Without the date map (a unit over ready documents) there is no window, as before. Proof is a temporary git repository with dated commits: a document outside the window, one inside, an hp:usage-none pair on both sides and an archive move inside the window; only the inside documents count. Issue: #761 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:
@@ -19,6 +19,9 @@
|
||||
// у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию
|
||||
// текста держит контрактный тест на шаблонах `_process.yml`.
|
||||
//
|
||||
// #761: токены — только за окно отчёта: документ ревью входит в неделю, если
|
||||
// коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление).
|
||||
//
|
||||
// node scripts/process-metrics.mjs --repo=<owner/repo> --days=7 [--until=ISO] [--output=path.md] [--json=path.json]
|
||||
// [--compare=2026-09-28] [--compare-days=28]
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -457,6 +460,7 @@ export function reviewDocUsage(doc) {
|
||||
* данными, `totals` — суммы по ключам строки (`null`, пока данных нет),
|
||||
* `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы
|
||||
* не входит. Документы без строки (до #737) не считаются ни тем, ни другим.
|
||||
* Окно недели (#761) применяет вызывающий: `tokenDocs`.
|
||||
*/
|
||||
export function tokenUsage(reviewDocs = []) {
|
||||
const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0]));
|
||||
@@ -472,6 +476,45 @@ export function tokenUsage(reviewDocs = []) {
|
||||
return { docs, totals: docs ? totals : null, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* #761: дата добавления документа ревью в `dev` по выводу
|
||||
* `git log -M --diff-filter=AR --reverse --name-status --format=%x1e%cI`.
|
||||
* Добавление (`A`) ставит дату коммиттера; переименование (`R`, перенос в
|
||||
* архив `legacy/reviews/` #682) переносит дату на новый путь, а не добавляет
|
||||
* документ заново. → Map путь в `HEAD` → дата (ISO).
|
||||
*/
|
||||
export function reviewDocAddedAt(text = '') {
|
||||
const added = new Map();
|
||||
for (const record of String(text).split('\x1e')) {
|
||||
const [date = '', ...rows] = record.split('\n');
|
||||
for (const row of rows) {
|
||||
const [status = '', from, to] = row.split('\t');
|
||||
if (status === 'A' && from) added.set(from, date.trim());
|
||||
else if (/^R\d*$/.test(status) && to && added.has(from)) {
|
||||
added.set(to, added.get(from));
|
||||
added.delete(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* #761: документы для токенов недели — добавленные в `dev` в `[since, until]`
|
||||
* (`reviewDocAddedAt`). Документ, чьё добавление не найдено, в окно не входит.
|
||||
* Без карты дат (`null`: снимок без git, юнит над готовыми документами) окна
|
||||
* нет, как до #761.
|
||||
*/
|
||||
export function tokenDocs(reviewDocs = [], { added = null, since, until } = {}) {
|
||||
if (!added) return reviewDocs || [];
|
||||
const from = toMs(since);
|
||||
const to = toMs(until);
|
||||
return (reviewDocs || []).filter((doc) => {
|
||||
const moment = at(added.get(String(doc?.path ?? '')));
|
||||
return moment >= from && moment <= to;
|
||||
});
|
||||
}
|
||||
|
||||
/** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */
|
||||
export function parseGitLog(text = '') {
|
||||
return String(text).split('\x1e').filter((record) => record.trim()).map((record) => {
|
||||
@@ -759,6 +802,8 @@ export function buildReport({
|
||||
// #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые
|
||||
// таймлайны, тексты документов ревью, коммиты dev и окно сравнения.
|
||||
allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {},
|
||||
// #761: дата добавления документов ревью в dev — окно токенов; `null` — окна нет.
|
||||
reviewDocAdded = null,
|
||||
}) {
|
||||
const perIssue = issues.map((issue) => issueMetrics(issue, timelines.get(Number(issue.number)) || []));
|
||||
const rounds = reviewRounds(reviewFiles);
|
||||
@@ -788,12 +833,12 @@ export function buildReport({
|
||||
runs: runMetrics(runs),
|
||||
pipeline: pipelineMetrics(runs),
|
||||
jobs: jobsByRun ? jobMinutes(jobsByRun) : null,
|
||||
...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }),
|
||||
...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }),
|
||||
};
|
||||
}
|
||||
|
||||
/** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */
|
||||
function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }) {
|
||||
function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }) {
|
||||
const changes = issueChanges(commits);
|
||||
const ship = shipFindings(reviewDocs);
|
||||
const seen = new Set();
|
||||
@@ -816,7 +861,10 @@ function trackReport({ since, until, issues, timelines, timelineTruncated, revie
|
||||
return {
|
||||
tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }),
|
||||
stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null,
|
||||
tokens: tokenUsage(reviewDocs),
|
||||
// #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено.
|
||||
tokens: reviewDocAdded
|
||||
? { ...tokenUsage(tokenDocs(reviewDocs, { added: reviewDocAdded, since, until })), window: { since, until } }
|
||||
: tokenUsage(reviewDocs),
|
||||
compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }),
|
||||
};
|
||||
}
|
||||
@@ -966,6 +1014,10 @@ function renderTokens(lines, tokens) {
|
||||
lines.push('');
|
||||
lines.push('### Токены');
|
||||
lines.push('');
|
||||
if (tokens?.window) {
|
||||
lines.push('Документы ревью, добавленные в `dev` за окно отчёта: дата коммиттера добавившего коммита, перенос в архив `legacy/reviews/` — не добавление.');
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(tokens?.docs
|
||||
? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.`
|
||||
: `${TOKENS_NO_DATA}.`);
|
||||
@@ -1019,7 +1071,8 @@ function ghJson(args) {
|
||||
* иначе задача помечается «таймлайн усечён»;
|
||||
* - jobs (К5) — для `jobRuns`, недоступные jobs — `null`;
|
||||
* - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой
|
||||
* расхода, коммиты `origin/dev` с `--numstat` за окно сравнения.
|
||||
* расхода, дата добавления каждого документа в dev (#761), коммиты
|
||||
* `origin/dev` с `--numstat` за окно сравнения.
|
||||
*/
|
||||
export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, compare = {} }) {
|
||||
const sinceIso = new Date(since).toISOString();
|
||||
@@ -1082,6 +1135,7 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com
|
||||
}
|
||||
let reviewFiles = [];
|
||||
const reviewDocs = [];
|
||||
let reviewDocAdded = null;
|
||||
let commits = [];
|
||||
if (git) {
|
||||
const listing = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']);
|
||||
@@ -1095,12 +1149,16 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com
|
||||
for (const path of new Set([...paths.filter((p) => SHIP_DOC.test(p)), ...usage])) {
|
||||
reviewDocs.push({ path, text: git(['show', `HEAD:${path}`]) });
|
||||
}
|
||||
// #761: окно токенов — по коммиту, добавившему документ в dev; с -M перенос
|
||||
// в архив (#682) — переименование, а не добавление.
|
||||
reviewDocAdded = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status',
|
||||
'--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
|
||||
let ref = 'origin/dev';
|
||||
try { git(['rev-parse', '--verify', '-q', ref]); } catch { ref = 'HEAD'; }
|
||||
// Коммиты задачи бывают раньше её S8: запас в одно окно до начала сравнения.
|
||||
commits = readCommits(git, { ref, since: at(earliestIso) - days * DAY_MS });
|
||||
}
|
||||
return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, commits };
|
||||
return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, reviewDocAdded, commits };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics,
|
||||
NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER,
|
||||
compareCohorts, fetchSnapshot, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage,
|
||||
readCommits, returnSignal, shipFindings, stageMinutes, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
|
||||
readCommits, returnSignal, reviewDocAddedAt, shipFindings, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
|
||||
} from '../scripts/process-metrics.mjs';
|
||||
import { labelTrack } from '../scripts/process-track.mjs';
|
||||
import { commentFor } from '../scripts/merge-candidate.mjs';
|
||||
@@ -612,3 +612,72 @@ test('#728 fetchSnapshot: state=all, таймлайн до 10 страниц, jo
|
||||
assert.deepEqual(snap.reviewDocs, [{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: 'ship doc' }]);
|
||||
assert.equal(issueChanges(snap.commits).get(701).lines, 4);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #761: токены — только за окно отчёта, по коммиту, добавившему документ в dev.
|
||||
|
||||
test('#761 токены за окно: документ вне окна, перенос в архив в окне и hp:usage-none вне окна не считаются (AC1–AC3)', (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-761-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (args, env = {}) => {
|
||||
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout;
|
||||
};
|
||||
git(['init', '-q', '-b', 'dev']);
|
||||
git(['config', 'user.email', 't@t']); git(['config', 'user.name', 't']);
|
||||
git(['config', 'core.hooksPath', '/dev/null']);
|
||||
const usage = (input, output) => formatUsage({ input_tokens: input, output_tokens: output, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, num_turns: 1 });
|
||||
const doc = (title, line) => withMaterialAnchors(`# ${title}\n\nВердикт: зелёный.\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: line });
|
||||
const commitAt = (iso, message, files) => {
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
mkdirSync(join(dir, dirname(path)), { recursive: true });
|
||||
writeFileSync(join(dir, path), text);
|
||||
}
|
||||
git(['add', '-A']);
|
||||
git(['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso });
|
||||
};
|
||||
const since = '2026-09-24T00:00:00Z';
|
||||
const until = '2026-10-01T00:00:00Z';
|
||||
// Вне окна: документ с расходом и документ без данных (hp:usage-none).
|
||||
commitAt('2026-09-10T10:00:00Z', 'docs: review document for #700', {
|
||||
'docs/reviews/CODE-REVIEW-700-r1.md': doc('CODE-REVIEW-700-r1', usage(1000, 100)),
|
||||
'docs/reviews/SPEC-REVIEW-701-r1.md': doc('SPEC-REVIEW-701-r1', ''),
|
||||
});
|
||||
// Внутри окна: документ с расходом и документ без данных.
|
||||
commitAt('2026-09-26T10:00:00Z', 'docs: review document for #702', {
|
||||
'docs/reviews/CODE-REVIEW-702-r1.md': doc('CODE-REVIEW-702-r1', usage(20, 2)),
|
||||
'docs/reviews/SPEC-REVIEW-703-r1.md': doc('SPEC-REVIEW-703-r1', ''),
|
||||
});
|
||||
// Внутри окна: перенос документов вне окна в архив (#682) — переименование, не добавление.
|
||||
mkdirSync(join(dir, 'legacy/reviews/v1.0.0'), { recursive: true });
|
||||
git(['mv', 'docs/reviews/CODE-REVIEW-700-r1.md', 'legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md']);
|
||||
git(['mv', 'docs/reviews/SPEC-REVIEW-701-r1.md', 'legacy/reviews/v1.0.0/SPEC-REVIEW-701-r1.md']);
|
||||
commitAt('2026-09-28T10:00:00Z', 'chore(legacy): archive the v1.0.0 review documents', {});
|
||||
// После окна: документ с расходом (отчёт за прошлую неделю его не видит).
|
||||
commitAt('2026-10-02T10:00:00Z', 'docs: review document for #704', { 'docs/reviews/CODE-REVIEW-704-r1.md': doc('CODE-REVIEW-704-r1', usage(5, 5)) });
|
||||
|
||||
const added = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
|
||||
assert.equal(Date.parse(added.get('legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md')), Date.parse('2026-09-10T10:00:00Z'), 'перенос в архив сохраняет дату добавления');
|
||||
assert.equal(added.has('docs/reviews/CODE-REVIEW-700-r1.md'), false, 'прежнего пути в HEAD нет');
|
||||
|
||||
const gh = (args) => (String(args[1]).includes('/actions/runs?') ? { workflow_runs: [] } : []);
|
||||
const snap = fetchSnapshot({ repo: 'o/r', since, until, gh, git: (args) => git(args) });
|
||||
assert.equal(snap.reviewDocs.length, 5, 'тексты читаются из HEAD, как раньше');
|
||||
const report = buildReport({ since, until, ...snap });
|
||||
assert.equal(report.tokens.docs, 1, 'AC1: только документ, добавленный в окне');
|
||||
assert.deepEqual([report.tokens.totals.input_tokens, report.tokens.totals.output_tokens], [20, 2]);
|
||||
assert.equal(report.tokens.missing, 1, 'AC3: hp:usage-none — по тому же окну');
|
||||
const md = renderMarkdown(report);
|
||||
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
|
||||
assert.match(section, /^Документы ревью, добавленные в `dev` за окно отчёта/m);
|
||||
assert.match(section, /^Токены по 1 документам ревью: input_tokens 20 · output_tokens 2 /m);
|
||||
assert.match(section, /^Без данных о расходе: 1\.$/m);
|
||||
|
||||
// Неделя, в которую документ #700 был добавлен, его видит — в архиве он или нет.
|
||||
const early = buildReport({ since: '2026-09-07T00:00:00Z', until: '2026-09-14T00:00:00Z', ...snap });
|
||||
assert.deepEqual([early.tokens.docs, early.tokens.totals.input_tokens, early.tokens.missing], [1, 1000, 1]);
|
||||
// Без карты дат (юнит над готовыми документами) окна нет — как до #761.
|
||||
assert.equal(tokenDocs(snap.reviewDocs).length, 5);
|
||||
assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user