@trazum/core 1.50.2 → 1.50.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/usage.ts CHANGED
@@ -5,6 +5,7 @@ import { createInputShapeTracker } from './input-shape.js';
5
5
  import { createRepeatsTracker } from './repeats.js';
6
6
  import { createTruncationRetryTracker } from './truncation-retry.js';
7
7
  import { createTtlFitTracker } from './ttl-fit.js';
8
+ import type { OutcomeTally } from './outcome.js';
8
9
  import { createSessionLedgerTracker } from './session-ledger.js';
9
10
  import { createSessionCostTracker } from './session-cost.js';
10
11
  import type { SessionCostShape } from './session-cost.js';
@@ -111,6 +112,17 @@ export interface UsageRecord {
111
112
  * refuses to read a log until it is annotated is a profile nobody runs.
112
113
  */
113
114
  label: string | null;
115
+ /**
116
+ * What happened, in the product's own vocabulary — `resolved`, `escalated`,
117
+ * `thumbs-down`, whatever the words are.
118
+ *
119
+ * The counterpart every other figure here has been missing. **Recorded by
120
+ * the caller and never inferred**: no absence of complaint counts as
121
+ * success, no short conversation counts as resolution, no retry counts as
122
+ * failure. `null` is "not recorded" and is reported as such, which is a
123
+ * different answer from any outcome and is treated as one everywhere.
124
+ */
125
+ outcome: string | null;
114
126
  /**
115
127
  * Optional conversation identifier, for measuring what re-sent history costs.
116
128
  *
@@ -241,6 +253,8 @@ export interface FieldCoverage {
241
253
  label: number;
242
254
  /** Records with a usable `session` or `conversation_id`. */
243
255
  session: number;
256
+ /** Records with a usable `outcome` or `trazum_outcome`. */
257
+ outcome: number;
244
258
  /** Records with a readable timestamp. */
245
259
  ts: number;
246
260
  /** Records with a `stop_reason` or `finish_reason`. */
@@ -426,6 +440,14 @@ export interface UsageProfileReport {
426
440
  * and a boolean would call it that.
427
441
  */
428
442
  fieldCoverage: FieldCoverage;
443
+ /**
444
+ * What each recorded outcome cost — measurement only, no judgement.
445
+ *
446
+ * `outcomeReport` turns this into a success rate where the config declares
447
+ * one, because which values mean success is a product judgement this module
448
+ * has no standing to make. An aggregate, never a list of calls.
449
+ */
450
+ outcomeTally: OutcomeTally;
429
451
  /**
430
452
  * Spend per hour of the UTC day, 0–23, over priced records that carry a
431
453
  * clock — and only the hours that saw traffic.
@@ -778,6 +800,13 @@ export function parseUsageLine(line: string): UsageRecord | null {
778
800
  * nobody sets measures nothing.
779
801
  */
780
802
  session: nameOf(record.session) ?? nameOf(record.conversation_id),
803
+ /**
804
+ * Read from either spelling, for the same reason `session` is: `outcome`
805
+ * in a log somebody wrote for this, `trazum_outcome` in one where a
806
+ * namespace was wanted. A field nobody sets measures nothing, and making
807
+ * its adoption a chore is how that happens.
808
+ */
809
+ outcome: nameOf(record.outcome) ?? nameOf(record.trazum_outcome),
781
810
  ts: moment.kind === 'ok' ? moment.ms : null,
782
811
  /**
783
812
  * Anthropic spells it `stop_reason: "max_tokens"`, OpenAI
@@ -977,7 +1006,9 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
977
1006
  const ledger = createSessionLedgerTracker({ catalogue, on });
978
1007
  const sessionCosts = createSessionCostTracker({ catalogue, on });
979
1008
  let hasSessions = false;
980
- const coverage = { label: 0, session: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
1009
+ const outcomes = new Map<string, { calls: number; usd: number }>();
1010
+ let unrecordedOutcomeUsd = 0;
1011
+ const coverage = { label: 0, session: 0, outcome: 0, ts: 0, stopReason: 0, cacheTtl: 0, cacheWrites: 0, parsed: 0 };
981
1012
  /**
982
1013
  * Raw lines already seen, for the duplicate check. Bounded by the number of
983
1014
  * *timestamped* lines — the price of catching a doubled bill, paid only on
@@ -1040,6 +1071,7 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1040
1071
  coverage.parsed += 1;
1041
1072
  if (record.label !== null) coverage.label += 1;
1042
1073
  if (record.session !== null) coverage.session += 1;
1074
+ if (record.outcome !== null) coverage.outcome += 1;
1043
1075
  if (record.ts !== null) coverage.ts += 1;
1044
1076
  if (record.truncated !== null) coverage.stopReason += 1;
1045
1077
  if (record.cacheWrite5mTokens + record.cacheWrite1hTokens > 0) {
@@ -1095,6 +1127,22 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1095
1127
  if (record.session !== null) {
1096
1128
  sessionUsd.set(record.session, (sessionUsd.get(record.session) ?? 0) + (total.totalUsd - usdBefore));
1097
1129
  }
1130
+ /**
1131
+ * Tallied from the same per-record dollar as everything else here, so a
1132
+ * success rate by spend and the bill it is a share of can never be two
1133
+ * different arithmetics that drifted apart.
1134
+ */
1135
+ {
1136
+ const usd = total.totalUsd - usdBefore;
1137
+ if (record.outcome === null) {
1138
+ unrecordedOutcomeUsd += usd;
1139
+ } else {
1140
+ const bucket = outcomes.get(record.outcome) ?? { calls: 0, usd: 0 };
1141
+ bucket.calls += 1;
1142
+ bucket.usd += usd;
1143
+ outcomes.set(record.outcome, bucket);
1144
+ }
1145
+ }
1098
1146
  if (record.ts !== null) {
1099
1147
  const day = new Date(record.ts).toISOString().slice(0, 10);
1100
1148
  const usd = total.totalUsd - usdBefore;
@@ -1189,6 +1237,14 @@ export function profileUsage(text: string, options: UsageProfileOptions): UsageP
1189
1237
  }),
1190
1238
  duplicateLines: duplicates,
1191
1239
  fieldCoverage: coverage,
1240
+ outcomeTally: {
1241
+ byValue: [...outcomes.entries()]
1242
+ .map(([value, bucket]) => ({ value, calls: bucket.calls, usd: bucket.usd }))
1243
+ .sort((a, b) => b.usd - a.usd),
1244
+ recorded: coverage.outcome,
1245
+ parsed: coverage.parsed,
1246
+ unrecordedUsd: unrecordedOutcomeUsd,
1247
+ },
1192
1248
  modelMixDrift: (() => {
1193
1249
  const ordered = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1194
1250
  if (ordered.length < 4) return null;