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:
amosblomqvist
2026-09-19 20:55:35 +03:00
parent c32915b232
commit 3e263a18cc
14 changed files with 666 additions and 37 deletions
+26
View File
@@ -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. */