mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 07:25:16 +00:00
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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"), and "revert"/"undo" only match
|
|
// demonstratives ("that", "this", "the last") so an opening request like
|
|
// "revert the migration we shipped" never trips them. Every pattern requires a
|
|
// correction context so praise like "you were right" never counts.
|
|
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|your|the last|the change)\b/i,
|
|
/\bundo (?:that|it|this|your|the last|the change)\b/i,
|
|
// "answer" is deliberately absent from the noun list: injected skill/system
|
|
// prose lands in the user-message slot and reads as ordinary writing where
|
|
// "the wrong answer" is an idiom ("a well-composed page is never the wrong
|
|
// answer" counted 4 phantom corrections on real data). The other nouns are
|
|
// concrete work artifacts that prose rarely uses with "wrong".
|
|
/\bwrong (?:file|approach|place|method|function|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) {
|
|
// A correction is a FOLLOW-UP by definition: the session's opening
|
|
// prompt cannot be correcting this assistant, however correction-shaped
|
|
// its task text reads ("revert the last release", "you broke it" pasted
|
|
// from a bug report). Skipping it trades a few missed corrections in
|
|
// resumed transcripts for never counting openers — the conservative side.
|
|
let sawPrompt = false
|
|
for (const turn of session.turns) {
|
|
const msg = turn.userMessage
|
|
if (!msg || !msg.trim()) continue
|
|
userTurns++
|
|
if (!sawPrompt) {
|
|
sawPrompt = true
|
|
continue
|
|
}
|
|
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)
|
|
// The FIRST edit is the measurement target; if its timestamp is
|
|
// unparseable the session's time-to-first-edit is unknown. Skipping to
|
|
// the next parseable edit would silently measure a later one.
|
|
if (Number.isNaN(editMs)) return null
|
|
// 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
|
|
}
|
|
|
|
/// Forward-slash normalization: tool calls carry model-emitted paths verbatim,
|
|
/// so on Windows they arrive backslashed. Without this the relativize and
|
|
/// basename passes (which split on '/') are no-ops there and the full absolute
|
|
/// path, username included, would ship in a payload that can leave the machine.
|
|
function normalizeSlashes(p: string): string {
|
|
return p.replace(/\\/g, '/')
|
|
}
|
|
|
|
function relativizePath(absPath: string, projectPath: string): string {
|
|
const project = normalizeSlashes(projectPath)
|
|
if (project && (absPath === project || absPath.startsWith(project + '/'))) {
|
|
return absPath.slice(project.length + 1) || absPath
|
|
}
|
|
const home = normalizeSlashes(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
|
|
// Key on the normalized path so the same file emitted with
|
|
// backslashes and forward slashes accumulates as one entry.
|
|
const file = normalizeSlashes(tc.file)
|
|
let acc = byPath.get(file)
|
|
if (!acc) {
|
|
acc = { path: relativizePath(file, project.projectPath), sessions: new Set(), edits: 0 }
|
|
byPath.set(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 }
|
|
|
|
/// Minimum edit turns before a category's one-shot rate is trusted (shared by
|
|
/// worstOneShotCategory and the coaching gate so they can never disagree).
|
|
export const MIN_ONE_SHOT_EDIT_TURNS = 5
|
|
|
|
/// 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 = MIN_ONE_SHOT_EDIT_TURNS): 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 >= MIN_ONE_SHOT_EDIT_TURNS && 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)
|
|
}
|