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:
+24
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user