Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- Cluster namer bills the money channel now: per-step usage + model captured via onStep, persisted as NamingRunUsage records to data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted runs persist too — money spent either way). callUsage promoted to extraction.ts module scope for reuse. - core/cost-summary.ts: the pure fold behind the pill — chat runs, notes-source runs, and naming runs into stage totals (extraction / audit / naming) + newest-first recent runs. Dollars INFERRED read-time via prices.ts, never stored; unpriced models excluded-but-counted (render '—', never a silent zero); tokens always sum. - GET /api/graph/costs (src/server graphCosts composition, thin route). - Graph view: bottom-left cost pill (cumulative dollars headline) + expandable breakdown panel (stage rows with tokens secondary, recent runs with time/title/merged/cost); fetched on mount, refetched when a pipeline run finishes; merged into one bottom-left stack with the running-pipeline pills. - Tests: pure-fold suite, naming capture wiring (full importNotes -> naming path), store idempotency + strict reads.
This commit is contained in:
@@ -44,6 +44,7 @@ import type {
|
||||
ProvenancePoint,
|
||||
CoverageEntry,
|
||||
SessionCheckpoint,
|
||||
NamingRunUsage,
|
||||
} from "../core/contracts.ts";
|
||||
import type { GraphStore } from "../core/ports.ts";
|
||||
|
||||
@@ -90,6 +91,19 @@ function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
|
||||
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
|
||||
isRecord(v) && isString(v.chatId) && isString(v.messageId);
|
||||
|
||||
const isNamingStats = (v: unknown): v is NamingRunUsage["stats"] =>
|
||||
isRecord(v) &&
|
||||
["detected", "claimed", "named", "appended"].every((k) => typeof v[k] === "number");
|
||||
|
||||
const isNamingRun = (v: unknown): v is NamingRunUsage =>
|
||||
isRecord(v) &&
|
||||
typeof v.startedAt === "number" &&
|
||||
isString(v.at) &&
|
||||
typeof v.durationMs === "number" &&
|
||||
isString(v.model) &&
|
||||
Array.isArray(v.steps) &&
|
||||
isNamingStats(v.stats);
|
||||
|
||||
const isClusterLabeling = (v: unknown): v is ClusterLabeling =>
|
||||
isRecord(v) &&
|
||||
isString(v.id) &&
|
||||
@@ -198,6 +212,18 @@ export const graphStore: GraphStore = {
|
||||
return readValid(LEDGER_FILES.clusterLabels, isClusterLabeling);
|
||||
},
|
||||
|
||||
// ── cluster-naming run usage (config.LEDGER_FILES.namingRuns) ──
|
||||
|
||||
/** Idempotent per startedAt — the same discipline as every usage
|
||||
* channel (chats.appendExtractionUsage, appendSourceUsage). */
|
||||
appendNamingUsage(entry: NamingRunUsage): void {
|
||||
if (this.readNamingRuns().some((r) => r.startedAt === entry.startedAt)) return;
|
||||
appendLine(LEDGER_FILES.namingRuns, entry);
|
||||
},
|
||||
readNamingRuns(): NamingRunUsage[] {
|
||||
return readValid(LEDGER_FILES.namingRuns, isNamingRun);
|
||||
},
|
||||
|
||||
// ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ──
|
||||
|
||||
/** Upsert: keeps the existing record's usage log when re-importing. */
|
||||
|
||||
Reference in New Issue
Block a user