mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-28 18:35:29 +00:00
feat(insights): workflow intelligence (corrections, time-to-first-edit, file churn, coverage) + dash wire-up (#756)
* feat(insights): workflow intelligence (corrections, time-to-first-edit, file churn, coverage) + dash wire-up * menubar: propagate workflow-intelligence fields onto the cache-backed headline The carry-forward headline (#755) serves totals from the durable day set and merges only session-derived fields from the scan. The new workflow, topReworkedFiles, and pricingCoverage fields were computed by the scan but never propagated, so they silently defaulted on the all-provider path (pricingCoverage rendered a dishonest 1.0). Propagate them like the other scan-only fields and pin it with a carried-headline regression test.
This commit is contained in:
parent
e99ee4bf37
commit
f32c00d2b1
7 changed files with 677 additions and 8 deletions
|
|
@ -83,9 +83,6 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
|
|||
const toolBars: BarItem[] = c
|
||||
? Object.entries(c.providers).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
: []
|
||||
const modelBars: BarItem[] = c
|
||||
? c.topModels.filter((m) => m.cost > 0).slice(0, 8).map((m) => ({ name: m.name, value: m.cost, display: usd(m.cost) }))
|
||||
: []
|
||||
const activityBars: BarItem[] = c
|
||||
? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) }))
|
||||
: []
|
||||
|
|
@ -136,7 +133,37 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
|
|||
<BarList items={toolBars} total={c?.cost} />
|
||||
</Panel>
|
||||
<Panel title="Top models">
|
||||
<BarList items={modelBars} total={c?.cost} />
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Model' },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'savings', label: 'Savings', num: true },
|
||||
]}
|
||||
rows={(c?.topModels ?? []).filter((m) => m.cost > 0).slice(0, 8).map((m) => ({
|
||||
name: m.name,
|
||||
cost: usd(m.cost),
|
||||
calls: fmtNum(m.calls),
|
||||
savings: usd(m.savingsUSD),
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<Panel title="Model efficiency">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Model' },
|
||||
{ key: 'costPerEdit', label: 'Cost/edit', num: true },
|
||||
{ key: 'oneShot', label: 'One-shot', num: true },
|
||||
]}
|
||||
rows={(c?.modelEfficiency ?? []).slice(0, 10).map((m) => ({
|
||||
name: m.name,
|
||||
costPerEdit: usd(m.costPerEdit),
|
||||
oneShot: `${Math.round(m.oneShotRate * 100)}%`,
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,11 +179,13 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
|
|||
{ key: 'name', label: 'Project' },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
{ key: 'sessions', label: 'Sessions', num: true },
|
||||
{ key: 'avgCost', label: 'Avg/session', num: true },
|
||||
]}
|
||||
rows={(c?.topProjects ?? []).slice(0, 10).map((p) => ({
|
||||
name: p.name,
|
||||
cost: usd(p.cost),
|
||||
sessions: fmtNum(p.sessions),
|
||||
avgCost: usd(p.avgCostPerSession),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ export type PeriodData = {
|
|||
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
|
||||
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
|
||||
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }>
|
||||
/// Workflow-intelligence rollups (issue: workflow intelligence). Optional so
|
||||
/// the day-aggregator PeriodData path (which has no per-turn data) can omit
|
||||
/// them; the fresh-parse payload path always sets them.
|
||||
workflow?: { corrections: number; correctionRate: number | null; medianTimeToFirstEditMs: number | null }
|
||||
/// Files most reworked by edit-family calls, relative to project root, ranked
|
||||
/// by distinct sessions then edits. Full (top 15) list; the payload basenames
|
||||
/// and trims it.
|
||||
topReworkedFiles?: ReworkedFile[]
|
||||
/// Share (0-1) of cost-bearing calls that resolved a price.
|
||||
pricingCoverage?: number
|
||||
}
|
||||
|
||||
export type ProviderCost = {
|
||||
|
|
@ -44,6 +54,7 @@ export type ProviderCost = {
|
|||
import type { OptimizeResult } from './optimize.js'
|
||||
import { getCurrency } from './currency.js'
|
||||
import type { GranularHistory } from './granular-history.js'
|
||||
import type { ReworkedFile } from './workflow-insights.js'
|
||||
|
||||
const TOP_ACTIVITIES_LIMIT = 20
|
||||
const TOP_MODELS_LIMIT = 20
|
||||
|
|
@ -53,6 +64,7 @@ const SYNTHETIC_MODEL_NAME = '<synthetic>'
|
|||
const TOP_PROJECTS_LIMIT = 5
|
||||
const TOP_SESSIONS_LIMIT = 3
|
||||
const MODEL_EFFICIENCY_LIMIT = 5
|
||||
const TOP_REWORKED_FILES_LIMIT = 8
|
||||
|
||||
export type DailyModelBreakdown = {
|
||||
name: string
|
||||
|
|
@ -215,6 +227,21 @@ export type MenubarPayload = {
|
|||
calls: number
|
||||
date: string
|
||||
}>
|
||||
/// Workflow-intelligence rollup for the period. `unansweredSessions` is
|
||||
/// add-only and optional: sessions that ended with no assistant reply are
|
||||
/// not computable from the session cache (the parser drops a trailing
|
||||
/// unanswered user turn), so it stays unset until a parse-time capture lands.
|
||||
workflow: {
|
||||
corrections: number
|
||||
correctionRate: number | null
|
||||
medianTimeToFirstEditMs: number | null
|
||||
unansweredSessions?: number
|
||||
}
|
||||
/// Files most reworked by edit-family calls (top 8). Path is basename-only
|
||||
/// for privacy; distinct sessions and total edit calls per file.
|
||||
topReworkedFiles: Array<{ path: string; sessions: number; edits: number }>
|
||||
/// Share (0-1) of cost-bearing calls that resolved a price.
|
||||
pricingCoverage: number
|
||||
retryTax: {
|
||||
totalUSD: number
|
||||
retries: number
|
||||
|
|
@ -377,6 +404,22 @@ function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPay
|
|||
.map(m => ({ name: m.name, costPerEdit: m.costPerEdit, oneShotRate: m.oneShotRate }))
|
||||
}
|
||||
|
||||
function buildWorkflow(workflow: PeriodData['workflow']): MenubarPayload['current']['workflow'] {
|
||||
return {
|
||||
corrections: workflow?.corrections ?? 0,
|
||||
correctionRate: workflow?.correctionRate ?? null,
|
||||
medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopReworkedFiles(files: PeriodData['topReworkedFiles']): MenubarPayload['current']['topReworkedFiles'] {
|
||||
return (files ?? [])
|
||||
.slice(0, TOP_REWORKED_FILES_LIMIT)
|
||||
// Basename only: the menubar/web payload can leave the machine, so drop the
|
||||
// directory path and keep just the file name.
|
||||
.map(f => ({ path: f.path.split('/').pop() || f.path, sessions: f.sessions, edits: f.edits }))
|
||||
}
|
||||
|
||||
function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] {
|
||||
return (sessions ?? [])
|
||||
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
|
||||
|
|
@ -432,6 +475,9 @@ export function buildMenubarPayload(
|
|||
topProjects: buildTopProjects(current.projects ?? []),
|
||||
modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []),
|
||||
topSessions: buildTopSessions(current.topSessions ?? []),
|
||||
workflow: buildWorkflow(current.workflow),
|
||||
topReworkedFiles: buildTopReworkedFiles(current.topReworkedFiles),
|
||||
pricingCoverage: current.pricingCoverage ?? 1,
|
||||
retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: breakdowns?.tools ?? [],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
|
|||
import { formatCost } from './currency.js'
|
||||
import { formatTokens } from './format.js'
|
||||
import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js'
|
||||
import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
|
||||
|
||||
// ============================================================================
|
||||
// Display constants
|
||||
|
|
@ -339,6 +340,10 @@ export type OptimizeJsonReport = {
|
|||
estimatedSavingsUSD: number
|
||||
fix: WasteAction
|
||||
}>
|
||||
/// Files most reworked by edit-family calls, relative to project root (top 15).
|
||||
topReworkedFiles: ReworkedFile[]
|
||||
/// 1-3 templated one-liners keyed on the strongest workflow signals.
|
||||
coachingNotes: string[]
|
||||
modelRecommendations?: Array<ModelDefaultRecommendation>
|
||||
}
|
||||
|
||||
|
|
@ -3130,6 +3135,28 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
|
|||
return lines
|
||||
}
|
||||
|
||||
function renderWorkflowSection(reworkedFiles: ReworkedFile[], coachingNotes: string[]): string[] {
|
||||
const lines: string[] = []
|
||||
if (reworkedFiles.length > 0) {
|
||||
lines.push(chalk.bold.hex(ORANGE)(' Top reworked files'))
|
||||
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
||||
for (const f of reworkedFiles) {
|
||||
const meta = chalk.dim(`${f.sessions} session${f.sessions === 1 ? '' : 's'}, ${f.edits} edit${f.edits === 1 ? '' : 's'}`)
|
||||
lines.push(` ${f.path} ${meta}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
if (coachingNotes.length > 0) {
|
||||
lines.push(chalk.bold.hex(ORANGE)(' Coaching notes'))
|
||||
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
||||
for (const note of coachingNotes) {
|
||||
lines.push(wrap('- ' + note, PANEL_WIDTH, ' '))
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function renderOptimize(
|
||||
findings: WasteFinding[],
|
||||
costRate: number,
|
||||
|
|
@ -3139,6 +3166,8 @@ function renderOptimize(
|
|||
callCount: number,
|
||||
healthScore: number,
|
||||
healthGrade: HealthGrade,
|
||||
reworkedFiles: ReworkedFile[],
|
||||
coachingNotes: string[],
|
||||
appliedHeader?: string,
|
||||
previouslyApplied?: Record<string, string>,
|
||||
modelRecommendations?: ModelDefaultRecommendation[],
|
||||
|
|
@ -3165,6 +3194,7 @@ function renderOptimize(
|
|||
lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
|
||||
lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.'))
|
||||
lines.push('')
|
||||
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
|
@ -3187,7 +3217,9 @@ function renderOptimize(
|
|||
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
||||
lines.push(chalk.dim(' Estimates only.'))
|
||||
lines.push('')
|
||||
|
||||
|
||||
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
|
||||
|
||||
if (modelRecommendations && modelRecommendations.length > 0) {
|
||||
lines.push(chalk.bold.hex(ORANGE)(' Model defaults recommendation'))
|
||||
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
||||
|
|
@ -3203,6 +3235,22 @@ function renderOptimize(
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// File churn + coaching notes for the workflow-intelligence section. Both are
|
||||
// derived from the same parsed projects the detectors already ran on, so this
|
||||
// adds no extra file I/O.
|
||||
function buildWorkflowReport(projects: ProjectSummary[]): { topReworkedFiles: ReworkedFile[]; coachingNotes: string[] } {
|
||||
const topReworkedFiles = aggregateFileChurn(projects)
|
||||
const corrections = scanUserCorrections(projects)
|
||||
const coachingNotes = buildCoachingNotes({
|
||||
worstOneShot: worstOneShotCategory(projects),
|
||||
corrections: corrections.corrections,
|
||||
correctionRate: corrections.correctionRate,
|
||||
topReworkedFile: topReworkedFiles[0] ?? null,
|
||||
medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects),
|
||||
})
|
||||
return { topReworkedFiles, coachingNotes }
|
||||
}
|
||||
|
||||
export async function runOptimize(
|
||||
projects: ProjectSummary[],
|
||||
periodLabel: string,
|
||||
|
|
@ -3230,7 +3278,8 @@ export async function runOptimize(
|
|||
return
|
||||
}
|
||||
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
|
||||
const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
|
||||
console.log(output)
|
||||
}
|
||||
|
||||
|
|
@ -3277,6 +3326,7 @@ export function buildOptimizeJsonReport(
|
|||
estimatedSavingsUSD: f.tokensSaved * result.costRate,
|
||||
fix: f.fix,
|
||||
})),
|
||||
...buildWorkflowReport(projects),
|
||||
modelRecommendations: result.modelRecommendations,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { stat } from 'node:fs/promises'
|
|||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { aggregateModels } from './models-report.js'
|
||||
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
|
|
@ -42,6 +43,13 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
|
|||
}
|
||||
}
|
||||
|
||||
const unpricedModels = findUnpricedModels(Object.entries(modelTotals)
|
||||
.map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })))
|
||||
const costBearingCalls = Object.entries(modelTotals)
|
||||
.reduce((s, [model, d]) => s + (model === '<synthetic>' ? 0 : d.calls), 0)
|
||||
const unpricedCalls = unpricedModels.reduce((s, m) => s + m.calls, 0)
|
||||
const corrections = scanUserCorrections(projects)
|
||||
|
||||
return {
|
||||
label,
|
||||
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
|
||||
|
|
@ -56,8 +64,14 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
|
|||
models: Object.entries(modelTotals)
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD, estimatedCostUSD: d.estimatedCostUSD })),
|
||||
unpricedModels: findUnpricedModels(Object.entries(modelTotals)
|
||||
.map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))),
|
||||
unpricedModels,
|
||||
workflow: {
|
||||
corrections: corrections.corrections,
|
||||
correctionRate: corrections.correctionRate,
|
||||
medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects),
|
||||
},
|
||||
topReworkedFiles: aggregateFileChurn(projects),
|
||||
pricingCoverage: computePricingCoverage(costBearingCalls, unpricedCalls),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,6 +319,14 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// detection, both derivable solely from surviving sessions.
|
||||
currentData.estimatedCostUSD = scanData.estimatedCostUSD
|
||||
currentData.unpricedModels = scanData.unpricedModels
|
||||
// Workflow-intelligence metrics are session-derived by nature (they need
|
||||
// turn text, timestamps, and tool sequences that day entries don't
|
||||
// carry), so like the fields above they come from the scan. Note
|
||||
// pricingCoverage therefore describes surviving-session calls, a smaller
|
||||
// population than the carried headline cost.
|
||||
currentData.workflow = scanData.workflow
|
||||
currentData.topReworkedFiles = scanData.topReworkedFiles
|
||||
currentData.pricingCoverage = scanData.pricingCoverage
|
||||
// Sessions: the cache buckets a session on its START day, the scan
|
||||
// counts it on any day it was ACTIVE. Each undercounts differently
|
||||
// (day-filtered views miss midnight-spanners; the scan misses expired
|
||||
|
|
|
|||
225
src/workflow-insights.ts
Normal file
225
src/workflow-insights.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { homedir } from 'os'
|
||||
|
||||
import { EDIT_TOOLS } from './classifier.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
||||
// User-side mirror of compare-stats.ts scanSelfCorrections (which scans the
|
||||
// assistant's own apologies). These match a *user* follow-up telling the
|
||||
// assistant it got something wrong. Deliberately conservative: bare "wrong" or
|
||||
// "undo" are excluded because they show up in ordinary task requests ("fix the
|
||||
// wrong output", "undo the migration"). Every pattern requires a correction
|
||||
// context so praise like "you were right" or "that's right" never trips it.
|
||||
export const USER_CORRECTION_PATTERNS: RegExp[] = [
|
||||
/\bthat'?s (?:not|n'?t) (?:what|right|correct|it)\b/i,
|
||||
/\bthat'?s (?:wrong|incorrect)\b/i,
|
||||
/\bthat is (?:wrong|incorrect|not right)\b/i,
|
||||
/\bnot what I (?:meant|wanted|asked|said)\b/i,
|
||||
/\bno,? I (?:meant|wanted|said|asked for)\b/i,
|
||||
/\byou (?:missed|forgot|misunderstood|broke)\b/i,
|
||||
/\brevert (?:that|it|this|the|your)\b/i,
|
||||
/\bundo (?:that|it|this|your|the last|the change)\b/i,
|
||||
/\bwrong (?:file|approach|place|method|function|answer|way|direction)\b/i,
|
||||
/\bstill (?:wrong|broken|failing|not working)\b/i,
|
||||
]
|
||||
|
||||
function matchesCorrection(text: string): boolean {
|
||||
return USER_CORRECTION_PATTERNS.some(p => p.test(text))
|
||||
}
|
||||
|
||||
export type UserCorrectionStats = {
|
||||
corrections: number
|
||||
/// Turns carrying a real user prompt (non-empty userMessage). The denominator
|
||||
/// for correctionRate — continuation turns with no fresh prompt are excluded.
|
||||
userTurns: number
|
||||
correctionRate: number | null
|
||||
}
|
||||
|
||||
export function scanUserCorrections(projects: ProjectSummary[]): UserCorrectionStats {
|
||||
let corrections = 0
|
||||
let userTurns = 0
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
const msg = turn.userMessage
|
||||
if (!msg || !msg.trim()) continue
|
||||
userTurns++
|
||||
if (matchesCorrection(msg)) corrections++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { corrections, userTurns, correctionRate: userTurns > 0 ? corrections / userTurns : null }
|
||||
}
|
||||
|
||||
function callHasEditTools(tools: string[]): boolean {
|
||||
return tools.some(t => EDIT_TOOLS.has(t))
|
||||
}
|
||||
|
||||
/// Per-session ms from the session's first turn to the first assistant call
|
||||
/// that used an edit-family tool. Returns null for sessions that never edited,
|
||||
/// so those are excluded from the median rather than counted as zero.
|
||||
export function sessionTimeToFirstEditMs(session: ProjectSummary['sessions'][number]): number | null {
|
||||
const startMs = Date.parse(session.turns[0]?.timestamp ?? '')
|
||||
if (Number.isNaN(startMs)) return null
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
if (!callHasEditTools(call.tools)) continue
|
||||
const editMs = Date.parse(call.timestamp)
|
||||
if (Number.isNaN(editMs)) continue
|
||||
// Clamp: out-of-order timestamps across resumed transcripts would
|
||||
// otherwise pull the median negative.
|
||||
return Math.max(0, editMs - startMs)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function medianTimeToFirstEditMs(projects: ProjectSummary[]): number | null {
|
||||
const samples: number[] = []
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
const ms = sessionTimeToFirstEditMs(session)
|
||||
if (ms !== null) samples.push(ms)
|
||||
}
|
||||
}
|
||||
if (samples.length === 0) return null
|
||||
samples.sort((a, b) => a - b)
|
||||
const mid = Math.floor(samples.length / 2)
|
||||
return samples.length % 2 === 0 ? (samples[mid - 1]! + samples[mid]!) / 2 : samples[mid]!
|
||||
}
|
||||
|
||||
export type ReworkedFile = {
|
||||
path: string
|
||||
sessions: number
|
||||
edits: number
|
||||
}
|
||||
|
||||
function relativizePath(absPath: string, projectPath: string): string {
|
||||
if (projectPath && (absPath === projectPath || absPath.startsWith(projectPath + '/'))) {
|
||||
return absPath.slice(projectPath.length + 1) || absPath
|
||||
}
|
||||
const home = homedir()
|
||||
if (absPath === home || absPath.startsWith(home + '/')) return '~' + absPath.slice(home.length)
|
||||
return absPath
|
||||
}
|
||||
|
||||
/// Ranks the files touched most by edit-family tool calls. File paths come from
|
||||
/// each call's toolSequence, which the parser/cache retain per edit tool_use
|
||||
/// (see parser.ts and session-cache CachedCall.toolSequence). Rank by distinct
|
||||
/// sessions first (a file reworked across many sessions is the real churn
|
||||
/// signal), then total edit calls.
|
||||
export function aggregateFileChurn(projects: ProjectSummary[], limit = 15): ReworkedFile[] {
|
||||
type Acc = { path: string; sessions: Set<string>; edits: number }
|
||||
const byPath = new Map<string, Acc>()
|
||||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
if (!call.toolSequence) continue
|
||||
for (const step of call.toolSequence) {
|
||||
for (const tc of step) {
|
||||
if (!EDIT_TOOLS.has(tc.tool) || !tc.file) continue
|
||||
let acc = byPath.get(tc.file)
|
||||
if (!acc) {
|
||||
acc = { path: relativizePath(tc.file, project.projectPath), sessions: new Set(), edits: 0 }
|
||||
byPath.set(tc.file, acc)
|
||||
}
|
||||
acc.sessions.add(session.sessionId)
|
||||
acc.edits++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byPath.values()]
|
||||
.map(a => ({ path: a.path, sessions: a.sessions.size, edits: a.edits }))
|
||||
.sort((a, b) => b.sessions - a.sessions || b.edits - a.edits || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/// Share (0-1) of cost-bearing calls that resolved a price. `unpricedCalls` are
|
||||
/// the calls of models with usage but no pricing table entry (findUnpricedModels).
|
||||
/// Returns 1 when there is nothing to price (no coverage gap to report).
|
||||
export function computePricingCoverage(totalCostBearingCalls: number, unpricedCalls: number): number {
|
||||
if (totalCostBearingCalls <= 0) return 1
|
||||
const priced = Math.max(0, totalCostBearingCalls - unpricedCalls)
|
||||
return priced / totalCostBearingCalls
|
||||
}
|
||||
|
||||
export type CategoryOneShot = { category: string; rate: number; editTurns: number }
|
||||
|
||||
/// The task category with the weakest one-shot rate (over enough edit turns to
|
||||
/// trust), used by the coaching notes. Rate is a percentage (0-100), matching
|
||||
/// model-efficiency and the report's category one-shot figures.
|
||||
export function worstOneShotCategory(projects: ProjectSummary[], minEditTurns = 5): CategoryOneShot | null {
|
||||
const acc = new Map<string, { editTurns: number; oneShotTurns: number }>()
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [cat, d] of Object.entries(session.categoryBreakdown)) {
|
||||
const e = acc.get(cat) ?? { editTurns: 0, oneShotTurns: 0 }
|
||||
e.editTurns += d.editTurns
|
||||
e.oneShotTurns += d.oneShotTurns
|
||||
acc.set(cat, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
let worst: CategoryOneShot | null = null
|
||||
for (const [cat, d] of acc) {
|
||||
if (d.editTurns < minEditTurns) continue
|
||||
const rate = (d.oneShotTurns / d.editTurns) * 100
|
||||
if (!worst || rate < worst.rate) {
|
||||
worst = { category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, rate, editTurns: d.editTurns }
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
// Coaching-note thresholds. Kept conservative so a note only fires on a signal
|
||||
// strong enough to act on.
|
||||
const ONE_SHOT_LOW_PERCENT = 60
|
||||
const CORRECTION_HIGH_RATE = 0.15
|
||||
const CORRECTION_MIN_COUNT = 3
|
||||
const CHURN_MIN_SESSIONS = 3
|
||||
const TTFE_SLOW_MS = 5 * 60 * 1000
|
||||
|
||||
export type CoachingInput = {
|
||||
worstOneShot?: CategoryOneShot | null
|
||||
corrections?: number
|
||||
correctionRate?: number | null
|
||||
topReworkedFile?: ReworkedFile | null
|
||||
medianTimeToFirstEditMs?: number | null
|
||||
}
|
||||
|
||||
function formatDurationShort(ms: number): string {
|
||||
if (ms >= 60_000) return `${Math.round(ms / 60_000)}m`
|
||||
return `${Math.round(ms / 1000)}s`
|
||||
}
|
||||
|
||||
/// 1-3 templated one-liners keyed on the strongest workflow signals. Pure
|
||||
/// templating on already-computed numbers. Copy is dry and specific and uses no
|
||||
/// em-dashes (UI copy house style).
|
||||
export function buildCoachingNotes(input: CoachingInput): string[] {
|
||||
const notes: string[] = []
|
||||
|
||||
const ws = input.worstOneShot
|
||||
if (ws && ws.editTurns >= 5 && ws.rate < ONE_SHOT_LOW_PERCENT) {
|
||||
notes.push(`One-shot rate on ${ws.category} is ${Math.round(ws.rate)}% over ${ws.editTurns} edit turns. Add the constraints up front or split the work into smaller edits.`)
|
||||
}
|
||||
|
||||
if (input.correctionRate != null && input.correctionRate >= CORRECTION_HIGH_RATE && (input.corrections ?? 0) >= CORRECTION_MIN_COUNT) {
|
||||
notes.push(`You corrected the assistant on ${Math.round(input.correctionRate * 100)}% of prompts (${input.corrections} times). State the requirements in the first message to cut the back and forth.`)
|
||||
}
|
||||
|
||||
const cf = input.topReworkedFile
|
||||
if (cf && cf.sessions >= CHURN_MIN_SESSIONS) {
|
||||
notes.push(`${cf.path} was reworked across ${cf.sessions} sessions (${cf.edits} edits). A focused pass on it may cost less than the repeated churn.`)
|
||||
}
|
||||
|
||||
if (input.medianTimeToFirstEditMs != null && input.medianTimeToFirstEditMs >= TTFE_SLOW_MS) {
|
||||
notes.push(`Median time to first edit is ${formatDurationShort(input.medianTimeToFirstEditMs)}. Point the assistant at the target file to cut the exploration before it starts editing.`)
|
||||
}
|
||||
|
||||
return notes.slice(0, 3)
|
||||
}
|
||||
|
|
@ -103,6 +103,15 @@ describe('carried history reaches the user-visible headline', () => {
|
|||
expect(payload.current.topModels.some(m => m.name === 'Opus 4.8' && m.cost === 100)).toBe(true)
|
||||
const projects = payload.current.topProjects
|
||||
expect(projects.some(p => p.name === 'proj-x' && p.cost === 100 && p.sessions === 3)).toBe(true)
|
||||
|
||||
// Scan-derived workflow-intelligence fields must be PROPAGATED onto the
|
||||
// cache-authoritative headline, not silently dropped by the selective
|
||||
// merge (the regression that made them dead on the default path). With no
|
||||
// surviving sessions they are the empty-scan values, but they must exist.
|
||||
expect(payload.current.workflow).toBeDefined()
|
||||
expect(payload.current.workflow?.corrections).toBe(0)
|
||||
expect(Array.isArray(payload.current.topReworkedFiles)).toBe(true)
|
||||
expect(typeof payload.current.pricingCoverage).toBe('number')
|
||||
})
|
||||
|
||||
it('merges live today data on top of carried history without double counting', async () => {
|
||||
|
|
|
|||
288
tests/workflow-insights.test.ts
Normal file
288
tests/workflow-insights.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
scanUserCorrections,
|
||||
medianTimeToFirstEditMs,
|
||||
aggregateFileChurn,
|
||||
computePricingCoverage,
|
||||
worstOneShotCategory,
|
||||
buildCoachingNotes,
|
||||
USER_CORRECTION_PATTERNS,
|
||||
} from '../src/workflow-insights.js'
|
||||
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TaskCategory, ToolCall } from '../src/types.js'
|
||||
|
||||
function call(opts: { tools?: string[]; timestamp?: string; toolSequence?: ToolCall[][]; model?: string; costUSD?: number } = {}): ParsedApiCall {
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: opts.model ?? 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
},
|
||||
costUSD: opts.costUSD ?? 0,
|
||||
tools: opts.tools ?? [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
timestamp: opts.timestamp ?? '2026-05-05T00:00:00.000Z',
|
||||
bashCommands: [],
|
||||
deduplicationKey: 'k',
|
||||
...(opts.toolSequence ? { toolSequence: opts.toolSequence } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function turn(opts: { userMessage?: string; calls?: ParsedApiCall[]; timestamp?: string; sessionId?: string } = {}): ClassifiedTurn {
|
||||
return {
|
||||
userMessage: opts.userMessage ?? '',
|
||||
assistantCalls: opts.calls ?? [],
|
||||
timestamp: opts.timestamp ?? '2026-05-05T00:00:00.000Z',
|
||||
sessionId: opts.sessionId ?? 's1',
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: (opts.calls ?? []).some(c => c.tools.some(t => t === 'Edit' || t === 'Write')),
|
||||
}
|
||||
}
|
||||
|
||||
function session(sessionId: string, turns: ClassifiedTurn[], categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']): SessionSummary {
|
||||
return {
|
||||
sessionId,
|
||||
project: 'app',
|
||||
firstTimestamp: turns[0]?.timestamp ?? '2026-05-05T00:00:00.000Z',
|
||||
lastTimestamp: '2026-05-05T01:00:00.000Z',
|
||||
totalCostUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalSavingsUSD: 0,
|
||||
apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0),
|
||||
turns,
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown,
|
||||
skillBreakdown: {}, subagentBreakdown: {},
|
||||
}
|
||||
}
|
||||
|
||||
function project(sessions: SessionSummary[], projectPath = '/home/u/app'): ProjectSummary {
|
||||
return {
|
||||
project: 'app', projectPath, sessions,
|
||||
totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function cat(editTurns: number, oneShotTurns: number): SessionSummary['categoryBreakdown'][TaskCategory] {
|
||||
return { turns: editTurns, costUSD: 0, savingsUSD: 0, retries: 0, editTurns, oneShotTurns }
|
||||
}
|
||||
|
||||
describe('scanUserCorrections', () => {
|
||||
it('counts turns whose user message signals a correction', () => {
|
||||
const p = project([session('s1', [
|
||||
turn({ userMessage: "no, I meant the other file" }),
|
||||
turn({ userMessage: "that's not what I asked for" }),
|
||||
turn({ userMessage: "you missed the edge case" }),
|
||||
turn({ userMessage: "revert that change" }),
|
||||
turn({ userMessage: "add a new feature please" }),
|
||||
])])
|
||||
const r = scanUserCorrections([p])
|
||||
expect(r.corrections).toBe(4)
|
||||
expect(r.userTurns).toBe(5)
|
||||
expect(r.correctionRate).toBeCloseTo(0.8)
|
||||
})
|
||||
|
||||
it('does not flag praise or ordinary requests (false-positive guards)', () => {
|
||||
const phrases = [
|
||||
'you were right about that',
|
||||
"you're right, thanks",
|
||||
"that's right, ship it",
|
||||
'looks correct to me',
|
||||
'undo the migration when done',
|
||||
'the build is failing, can you fix it',
|
||||
'what went wrong here',
|
||||
]
|
||||
const p = project([session('s1', phrases.map(m => turn({ userMessage: m })))])
|
||||
const r = scanUserCorrections([p])
|
||||
expect(r.corrections).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores continuation turns with no fresh prompt', () => {
|
||||
const p = project([session('s1', [
|
||||
turn({ userMessage: '' }),
|
||||
turn({ userMessage: ' ' }),
|
||||
turn({ userMessage: 'that is wrong' }),
|
||||
])])
|
||||
const r = scanUserCorrections([p])
|
||||
expect(r.userTurns).toBe(1)
|
||||
expect(r.corrections).toBe(1)
|
||||
expect(r.correctionRate).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a null rate for empty input', () => {
|
||||
const r = scanUserCorrections([])
|
||||
expect(r).toEqual({ corrections: 0, userTurns: 0, correctionRate: null })
|
||||
})
|
||||
|
||||
it('every pattern is case-insensitive', () => {
|
||||
for (const re of USER_CORRECTION_PATTERNS) expect(re.flags).toContain('i')
|
||||
})
|
||||
})
|
||||
|
||||
describe('medianTimeToFirstEditMs', () => {
|
||||
const edit = (ts: string) => call({ tools: ['Edit'], timestamp: ts })
|
||||
|
||||
it('measures from the first turn to the first edit call', () => {
|
||||
const s = session('s1', [
|
||||
turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'], timestamp: '2026-05-05T00:00:10.000Z' })] }),
|
||||
turn({ timestamp: '2026-05-05T00:01:00.000Z', calls: [edit('2026-05-05T00:00:30.000Z')] }),
|
||||
])
|
||||
expect(medianTimeToFirstEditMs([project([s])])).toBe(30_000)
|
||||
})
|
||||
|
||||
it('excludes sessions with no edits (not counted as zero)', () => {
|
||||
const withEdit = session('s1', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [edit('2026-05-05T00:00:20.000Z')] })])
|
||||
const noEdit = session('s2', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'], timestamp: '2026-05-05T00:05:00.000Z' })] })])
|
||||
expect(medianTimeToFirstEditMs([project([withEdit, noEdit])])).toBe(20_000)
|
||||
})
|
||||
|
||||
it('takes the median across sessions (even count averages the middle two)', () => {
|
||||
const mk = (id: string, secs: number) => session(id, [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [edit(new Date(Date.parse('2026-05-05T00:00:00.000Z') + secs * 1000).toISOString())] })])
|
||||
// deltas: 10, 20, 30, 40 -> median (20+30)/2 = 25s
|
||||
expect(medianTimeToFirstEditMs([project([mk('a', 10), mk('b', 20), mk('c', 30), mk('d', 40)])])).toBe(25_000)
|
||||
})
|
||||
|
||||
it('clamps out-of-order timestamps to zero', () => {
|
||||
const s = session('s1', [turn({ timestamp: '2026-05-05T00:01:00.000Z', calls: [edit('2026-05-05T00:00:00.000Z')] })])
|
||||
expect(medianTimeToFirstEditMs([project([s])])).toBe(0)
|
||||
})
|
||||
|
||||
it('returns null when no session has an edit', () => {
|
||||
const s = session('s1', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'] })] })])
|
||||
expect(medianTimeToFirstEditMs([project([s])])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregateFileChurn', () => {
|
||||
const editSeq = (file: string): ToolCall[][] => [[{ tool: 'Edit', file }]]
|
||||
|
||||
it('ranks by distinct sessions then total edits, and relativizes paths', () => {
|
||||
const s1 = session('s1', [
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Write'], toolSequence: [[{ tool: 'Write', file: '/home/u/app/src/b.ts' }]] })] }),
|
||||
])
|
||||
const s2 = session('s2', [
|
||||
turn({ sessionId: 's2', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
|
||||
])
|
||||
const files = aggregateFileChurn([project([s1, s2])])
|
||||
expect(files[0]).toEqual({ path: 'src/a.ts', sessions: 2, edits: 3 })
|
||||
expect(files[1]).toEqual({ path: 'src/b.ts', sessions: 1, edits: 1 })
|
||||
})
|
||||
|
||||
it('breaks a session-count tie by edits then path', () => {
|
||||
const s = session('s1', [
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/z.ts') })] }),
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/z.ts') })] }),
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/a.ts') })] }),
|
||||
])
|
||||
const files = aggregateFileChurn([project([s])])
|
||||
// both touched by 1 session; z.ts has more edits so it ranks first
|
||||
expect(files.map(f => f.path)).toEqual(['z.ts', 'a.ts'])
|
||||
})
|
||||
|
||||
it('ignores non-edit tools and calls without a file path', () => {
|
||||
const s = session('s1', [
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Read'], toolSequence: [[{ tool: 'Read', file: '/home/u/app/r.ts' }]] })] }),
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: [[{ tool: 'Edit' }]] })] }),
|
||||
])
|
||||
expect(aggregateFileChurn([project([s])])).toEqual([])
|
||||
})
|
||||
|
||||
it('respects the limit', () => {
|
||||
const turns = Array.from({ length: 5 }, (_, i) =>
|
||||
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq(`/home/u/app/f${i}.ts`) })] }))
|
||||
const files = aggregateFileChurn([project([session('s1', turns)])], 3)
|
||||
expect(files).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePricingCoverage', () => {
|
||||
it('is 1 when everything is priced', () => {
|
||||
expect(computePricingCoverage(100, 0)).toBe(1)
|
||||
})
|
||||
it('is the priced share when some calls are unpriced', () => {
|
||||
expect(computePricingCoverage(100, 25)).toBe(0.75)
|
||||
})
|
||||
it('is 1 when there are no cost-bearing calls', () => {
|
||||
expect(computePricingCoverage(0, 0)).toBe(1)
|
||||
})
|
||||
it('clamps to 0 if unpriced somehow exceeds the total', () => {
|
||||
expect(computePricingCoverage(10, 20)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worstOneShotCategory', () => {
|
||||
it('picks the lowest one-shot rate above the edit-turn floor', () => {
|
||||
const s = session('s1', [], {
|
||||
feature: cat(10, 3), // 30%
|
||||
debugging: cat(8, 6), // 75%
|
||||
} as SessionSummary['categoryBreakdown'])
|
||||
const worst = worstOneShotCategory([project([s])])
|
||||
expect(worst?.category).toBe('Feature Dev')
|
||||
expect(worst?.rate).toBe(30)
|
||||
expect(worst?.editTurns).toBe(10)
|
||||
})
|
||||
|
||||
it('excludes categories below the edit-turn floor', () => {
|
||||
const s = session('s1', [], {
|
||||
feature: cat(2, 0), // 0% but only 2 edit turns
|
||||
debugging: cat(10, 8), // 80%
|
||||
} as SessionSummary['categoryBreakdown'])
|
||||
const worst = worstOneShotCategory([project([s])])
|
||||
expect(worst?.category).toBe('Debugging')
|
||||
})
|
||||
|
||||
it('returns null when nothing qualifies', () => {
|
||||
const s = session('s1', [], { feature: cat(1, 0) } as SessionSummary['categoryBreakdown'])
|
||||
expect(worstOneShotCategory([project([s])])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCoachingNotes', () => {
|
||||
it('emits a note per strong signal and never uses em-dashes', () => {
|
||||
const notes = buildCoachingNotes({
|
||||
worstOneShot: { category: 'Feature Dev', rate: 40, editTurns: 12 },
|
||||
corrections: 6,
|
||||
correctionRate: 0.2,
|
||||
topReworkedFile: { path: 'src/a.ts', sessions: 4, edits: 20 },
|
||||
medianTimeToFirstEditMs: 6 * 60 * 1000,
|
||||
})
|
||||
expect(notes.length).toBeGreaterThan(0)
|
||||
expect(notes.length).toBeLessThanOrEqual(3)
|
||||
for (const n of notes) expect(n).not.toContain('—')
|
||||
})
|
||||
|
||||
it('caps at 3 notes even when all four signals fire', () => {
|
||||
const notes = buildCoachingNotes({
|
||||
worstOneShot: { category: 'Feature Dev', rate: 40, editTurns: 12 },
|
||||
corrections: 6,
|
||||
correctionRate: 0.2,
|
||||
topReworkedFile: { path: 'src/a.ts', sessions: 4, edits: 20 },
|
||||
medianTimeToFirstEditMs: 6 * 60 * 1000,
|
||||
})
|
||||
expect(notes).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('stays silent when every signal is below threshold', () => {
|
||||
const notes = buildCoachingNotes({
|
||||
worstOneShot: { category: 'Feature Dev', rate: 90, editTurns: 12 },
|
||||
corrections: 1,
|
||||
correctionRate: 0.02,
|
||||
topReworkedFile: { path: 'src/a.ts', sessions: 1, edits: 2 },
|
||||
medianTimeToFirstEditMs: 10 * 1000,
|
||||
})
|
||||
expect(notes).toEqual([])
|
||||
})
|
||||
|
||||
it('requires both a high rate and a minimum count for the correction note', () => {
|
||||
const highRateLowCount = buildCoachingNotes({ correctionRate: 0.5, corrections: 1 })
|
||||
expect(highRateLowCount).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue