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
+24 -1
View File
@@ -18,7 +18,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
import type { AuditResult } from "../src/core/contracts.ts";
import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts";
import type { QuestionAnswerData } from "../src/core/question-core.ts";
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
import { graphStore } from "../src/store/graph.ts";
@@ -592,6 +592,29 @@ describe("graph ledger store", () => {
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
});
it("appendNamingUsage is idempotent per startedAt and strict-reads foreign shapes", () => {
const run: NamingRunUsage = {
startedAt: 1789800000000,
at: "2026-09-17T00:00:00.000Z",
durationMs: 1234,
model: "z-ai/glm-5.3-flash",
steps: [{ inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: undefined, reasoningTokens: 0 }],
stats: { detected: 4, claimed: 3, named: 1, appended: 1 },
};
graphStore.appendNamingUsage(run);
graphStore.appendNamingUsage(run); // re-append: same startedAt — no-op
// A torn append + a foreign shape (missing stats) — skipped by the guard.
fs.appendFileSync(
LEDGER_FILES.namingRuns,
`{not json}\n${JSON.stringify({ startedAt: 1789800000001, at: "x", durationMs: 1, model: "m", steps: [] })}\n`,
"utf-8",
);
const runs = graphStore.readNamingRuns().filter((r) => r.startedAt >= 1789800000000);
assert.equal(runs.length, 1, "one record — idempotent append, corrupt/foreign lines skipped");
assert.deepEqual(runs[0]!.stats, { detected: 4, claimed: 3, named: 1, appended: 1 });
assert.equal(runs[0]!.steps.length, 1);
});
it("returns RAW node lines — repeated ids (link-appends) are each their own line; getGraph merges", () => {
const line = (msgId: string) =>
JSON.stringify({