@linxiraos/pi-stats 1.3.2 → 1.4.0

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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Behavioral metrics extracted from a single assistant message: the model-side
3
+ * counterpart of `user-metrics.ts`.
4
+ *
5
+ * The dashboard calls this "AI breakdown level" (AI 崩溃度): how often a model's
6
+ * replies show the textual marks of a turn that is going badly. Every signal
7
+ * is a count, so the dashboard can report a share of turns that carry it:
8
+ *
9
+ * - `selfBlame` — apologies and admissions ("I'm sorry", "my mistake", "是我搞错了")
10
+ * - `restatement` — "as I said earlier", "to reiterate", "如前所述", plus
11
+ * near-duplicate repetition of the previous assistant prose (language-neutral)
12
+ * - `hedge` — hedged fallbacks ("I'm not sure", "you could also try", "仅供参考")
13
+ * - `verbose` — 1 when the turn runs past {@link VERBOSE_WORD_THRESHOLD} words
14
+ * - `toolSpiral` — 1 when the turn follows {@link TOOL_SPIRAL_THRESHOLD}+ failed
15
+ * tool results (the parser counts `toolResult.isError`, the same flag
16
+ * `tool_calls.is_error` stores)
17
+ * - `collapse` — the per-turn aggregate: the marker counts plus failed tool
18
+ * results (capped), so one turn with one marker scores 1
19
+ *
20
+ * Lexicons are summed together, never chosen by detected language: a mixed
21
+ * Chinese/English reply, or one written in a language a recognizer mislabels,
22
+ * must not silently lose half its markers. Code fences, XML wrappers, URLs and
23
+ * inline code are stripped by {@link analysisProse} before scoring, so a
24
+ * model quoting a failing test or a config file does not look broken.
25
+ *
26
+ * Pure and side-effect free, like the user analyzer.
27
+ */
28
+ import { countMatches, detectRepeatedMessage, detectUnsupportedScript, WORD_RE } from "./user-metrics";
29
+
30
+ export interface AssistantMessageMetrics {
31
+ /** Characters of analyzed prose (code and markup stripped, capped). */
32
+ chars: number;
33
+ /** Whitespace-delimited words of analyzed prose. */
34
+ words: number;
35
+ /** Apology / self-blame markers. */
36
+ selfBlame: number;
37
+ /** Repeated-restatement markers plus near-duplicate previous turn. */
38
+ restatement: number;
39
+ /** Hedged fallback markers. */
40
+ hedge: number;
41
+ /** 1 when the turn is long-winded (see {@link VERBOSE_WORD_THRESHOLD}). */
42
+ verbose: number;
43
+ /** Failed tool results preceding this turn, as counted at ingest. */
44
+ toolErrors: number;
45
+ /** 1 when {@link toolErrors} reaches {@link TOOL_SPIRAL_THRESHOLD}. */
46
+ toolSpiral: number;
47
+ /** Per-turn aggregate (see the module docs). */
48
+ collapse: number;
49
+ /** 1 when the prose leans on a script no lexicon covers; such rows are unscored, not calm. */
50
+ unsupportedScript: number;
51
+ }
52
+
53
+ /**
54
+ * Words past which a reply counts as long-winded. Coding-agent answers usually
55
+ * land well under this; a 400-word turn is the point where the model is
56
+ * explaining at the user instead of answering.
57
+ */
58
+ export const VERBOSE_WORD_THRESHOLD = 400;
59
+
60
+ /**
61
+ * Character floor for the same signal. Chinese prose has no spaces, so
62
+ * `WORD_RE` sees one "word" per line; roughly 1.5 characters per word makes
63
+ * this the equivalent of {@link VERBOSE_WORD_THRESHOLD}.
64
+ */
65
+ export const VERBOSE_CHAR_THRESHOLD = 2_400;
66
+
67
+ /** Consecutive failed tool results that make a turn part of a failure spiral. */
68
+ export const TOOL_SPIRAL_THRESHOLD = 3;
69
+
70
+ /** Failed tool results beyond this many add no further weight to `collapse`. */
71
+ export const TOOL_ERROR_COLLAPSE_CAP = 10;
72
+
73
+ // Apologies and admissions, English and Chinese. `apologies` stands alone;
74
+ // "I'm afraid" is hedged uncertainty and lives in HEDGE_RE instead.
75
+ const SELF_BLAME_RE =
76
+ /(?:\bi(?:'m| am) (?:so |really |very )?sorry\b|\bmy (?:apologies|bad|mistake|fault)\b|\bi apologi[sz]e\b|\bapologies\b|\bi was (?:wrong|mistaken)\b|\bi made (?:a|an|the) (?:mistake|error|bug)\b|\bi(?:'ve| have) (?:made|introduced) (?:a|an|the) (?:mistake|error|bug)\b|\bthat(?:'s| is| was) my (?:mistake|fault|error)\b|\bi stand corrected\b|\bsorry (?:about|for) (?:that|this|the confusion)\b)/gi;
77
+ const ZH_SELF_BLAME_RE =
78
+ /(?:抱歉|对不起|不好意思|是我(?:的)?错|我错了|我搞错(?:了)?|我弄错(?:了)?|是我疏忽|我疏忽了|我犯(?:了)?错|实在抱歉|非常抱歉|请原谅)/g;
79
+
80
+ // Explicit restatement. Bare "again" / "repeating" are far too common in
81
+ // technical prose ("repeating pattern") to count on their own.
82
+ const RESTATEMENT_RE =
83
+ /(?:\bas i (?:said|mentioned|noted) (?:earlier|before|above)\b|\bas (?:previously|mentioned|noted) (?:above|earlier)\b|\bto reiterate\b|\bto restate\b|\blet me (?:repeat|restate|clarify)\b|\bin other words\b|\bonce again\b|\bas noted (?:above|earlier)\b|\blike i said\b|\bto repeat\b)/gi;
84
+ const ZH_RESTATEMENT_RE =
85
+ /(?:再说一遍|再说一次|重复一遍|重复一次|如前所述|正如(?:我)?(?:之前|前面)(?:所说|提到)|我(?:已经)?说过|再次(?:说明|强调)|换句话(?:说)?|重申)/g;
86
+
87
+ // Uncertainty and work-avoiding fallbacks. Deliberately excludes bare
88
+ // "I think" / "maybe", which are ordinary model prose and would swamp the
89
+ // signal; only explicit inability or deferral counts.
90
+ const HEDGE_RE =
91
+ /(?:\bi(?:'m| am) (?:not (?:entirely |completely |100% )?sure|unsure|not certain)\b|\bnot 100% sure\b|\bit depends\b|\bas far as i can tell\b|\bif that helps\b|\blet me know if\b|\byou could also\b|\balternatively, you could\b|\bone option is\b|\bin theory\b|\bi can(?:no|')?t (?:be sure|guarantee|promise)\b|\bi(?:'m| am) afraid\b|\bhopefully\b)/gi;
92
+ const ZH_HEDGE_RE =
93
+ /(?:我不(?:太)?确定|不太确定|不确定|可能|也许|或许|应该(?:可以|能|没问题)|大概(?:是|可以|能)?|你可以(?:试试|尝试)|可以试试|试试看|仅供参考|如果(?:还)?不行|希望(?:这|对你)?(?:有)?(?:所)?帮助|不太好说|说不准)/g;
94
+
95
+ /** Empty metrics constant for callers that need a default. */
96
+ export const EMPTY_ASSISTANT_METRICS: AssistantMessageMetrics = Object.freeze({
97
+ chars: 0,
98
+ words: 0,
99
+ selfBlame: 0,
100
+ restatement: 0,
101
+ hedge: 0,
102
+ verbose: 0,
103
+ toolErrors: 0,
104
+ toolSpiral: 0,
105
+ collapse: 0,
106
+ unsupportedScript: 0,
107
+ });
108
+
109
+ /**
110
+ * Compute behavioral metrics for one assistant turn.
111
+ *
112
+ * `text` is the turn's analysis prose (see {@link analysisProse}) — the parser
113
+ * strips thinking blocks and structured content before calling this. Empty or
114
+ * whitespace-only input returns zeros.
115
+ */
116
+ export function computeAssistantMessageMetrics(
117
+ text: string,
118
+ options: {
119
+ /** The previous prose-bearing assistant turn in the same session, for restatement. */
120
+ prevText?: string;
121
+ /** Failed tool results since the previous assistant turn. */
122
+ toolErrors?: number;
123
+ } = {},
124
+ ): AssistantMessageMetrics {
125
+ const trimmed = text.trim();
126
+ const toolErrors = Math.max(0, Math.trunc(options.toolErrors ?? 0));
127
+ if (!trimmed)
128
+ return { ...EMPTY_ASSISTANT_METRICS, toolErrors, toolSpiral: toolErrors >= TOOL_SPIRAL_THRESHOLD ? 1 : 0 };
129
+
130
+ const words = countMatches(trimmed, WORD_RE);
131
+ const selfBlame = countMatches(trimmed, SELF_BLAME_RE) + countMatches(trimmed, ZH_SELF_BLAME_RE);
132
+ const restatement =
133
+ countMatches(trimmed, RESTATEMENT_RE) +
134
+ countMatches(trimmed, ZH_RESTATEMENT_RE) +
135
+ detectRepeatedMessage(trimmed, options.prevText ?? "");
136
+ const hedge = countMatches(trimmed, HEDGE_RE) + countMatches(trimmed, ZH_HEDGE_RE);
137
+ const verbose = words >= VERBOSE_WORD_THRESHOLD || trimmed.length >= VERBOSE_CHAR_THRESHOLD ? 1 : 0;
138
+ const toolSpiral = toolErrors >= TOOL_SPIRAL_THRESHOLD ? 1 : 0;
139
+ const toolPenalty = toolSpiral === 1 ? Math.min(toolErrors, TOOL_ERROR_COLLAPSE_CAP) : 0;
140
+
141
+ return {
142
+ chars: trimmed.length,
143
+ words,
144
+ selfBlame,
145
+ restatement,
146
+ hedge,
147
+ verbose,
148
+ toolErrors,
149
+ toolSpiral,
150
+ collapse: selfBlame + restatement + hedge + verbose + toolPenalty,
151
+ unsupportedScript: detectUnsupportedScript(trimmed),
152
+ };
153
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Model-side dashboard: how often each model's own replies show the textual
3
+ * marks of a turn going badly ("AI breakdown level", AI 崩溃度).
4
+ *
5
+ * The signals are computed at ingest (`model-metrics.ts`) and stored in
6
+ * `assistant_text`; this module merges them by catalog identity and shapes the
7
+ * payload for the frustration page's second section. It never judges anything:
8
+ * there is no model call and no extra storage beyond the capped prose row.
9
+ */
10
+ import { getTimeRangeConfig } from "./aggregator";
11
+ import {
12
+ getModelSideByModel,
13
+ getModelSideBySession,
14
+ getModelSideOverall,
15
+ getModelSideTimeSeries,
16
+ initDb,
17
+ type ModelSideModelRow,
18
+ } from "./db";
19
+ import { compareModelIdentity, modelIdentityOf } from "./model-identity";
20
+ import type { ModelSideDashboardStats, ModelSideModelStats } from "./shared-types";
21
+
22
+ /** Sessions the payload carries. The long tail is available only through the DB. */
23
+ const SESSION_LIMIT = 40;
24
+
25
+ /** Merge per-(model, provider) rows into catalog identities, same grouping as the user side. */
26
+ export function mergeModelSideRows(rows: readonly ModelSideModelRow[]): ModelSideModelStats[] {
27
+ const merged = new Map<string, ModelSideModelStats>();
28
+ for (const row of rows) {
29
+ const identity = modelIdentityOf(row.provider, row.model);
30
+ const existing = merged.get(identity.key);
31
+ if (existing) {
32
+ existing.messages += row.messages;
33
+ existing.blamed += row.blamed;
34
+ existing.restated += row.restated;
35
+ existing.hedged += row.hedged;
36
+ existing.verbose += row.verbose;
37
+ existing.spiraled += row.spiraled;
38
+ existing.breakdown += row.breakdown;
39
+ existing.collapse += row.collapse;
40
+ existing.words += row.words;
41
+ existing.toolErrors += row.toolErrors;
42
+ existing.unscored += row.unscored;
43
+ existing.firstSeen = Math.min(existing.firstSeen, row.firstSeen);
44
+ if (!existing.models.includes(row.model)) existing.models.push(row.model);
45
+ continue;
46
+ }
47
+ merged.set(identity.key, {
48
+ ...identity,
49
+ models: [row.model],
50
+ firstSeen: row.firstSeen,
51
+ messages: row.messages,
52
+ blamed: row.blamed,
53
+ restated: row.restated,
54
+ hedged: row.hedged,
55
+ verbose: row.verbose,
56
+ spiraled: row.spiraled,
57
+ breakdown: row.breakdown,
58
+ collapse: row.collapse,
59
+ words: row.words,
60
+ toolErrors: row.toolErrors,
61
+ unscored: row.unscored,
62
+ });
63
+ }
64
+ return [...merged.values()].sort(compareModelIdentity);
65
+ }
66
+
67
+ /** Model-side section of `GET /api/stats/frustration`. */
68
+ export async function getModelSideDashboardStats(range?: string | null): Promise<ModelSideDashboardStats> {
69
+ await initDb();
70
+ const { cutoff, bucketMs } = getTimeRangeConfig(range);
71
+ return {
72
+ overall: getModelSideOverall(cutoff),
73
+ byModel: mergeModelSideRows(getModelSideByModel(cutoff)),
74
+ series: getModelSideTimeSeries({ cutoff, bucketMs }),
75
+ bySession: getModelSideBySession(cutoff, SESSION_LIMIT),
76
+ };
77
+ }
package/src/parser.ts CHANGED
@@ -16,6 +16,7 @@ import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
16
16
  import { getSessionsDir, isEnoent, readLines } from "@linxiraos/pi-utils";
17
17
  import type {
18
18
  AgentType,
19
+ AssistantTextStats,
19
20
  MessageStatsInput,
20
21
  SessionEntry,
21
22
  SessionMessageEntry,
@@ -26,7 +27,8 @@ import type {
26
27
  UserMessageLink,
27
28
  UserMessageStats,
28
29
  } from "./types";
29
- import { computeUserMessageMetrics, judgeProse } from "./user-metrics";
30
+ import { computeAssistantMessageMetrics } from "./model-metrics";
31
+ import { analysisProse, computeUserMessageMetrics } from "./user-metrics";
30
32
 
31
33
  /** Basename of an advisor agent's transcript inside a session artifacts dir. */
32
34
  const ADVISOR_TRANSCRIPT_BASENAME = "__advisor.jsonl";
@@ -112,21 +114,30 @@ function isToolResultMessage(entry: SessionEntry): entry is SessionMessageEntry
112
114
  }
113
115
 
114
116
  /**
115
- * Extract plain text from a user message content payload.
117
+ * Concatenate the `text` content blocks of a message payload, skipping every
118
+ * other block type (thinking/reasoning, tool calls, tool results, images).
116
119
  */
117
- function extractUserText(content: unknown): string {
118
- if (typeof content === "string") return content;
120
+ function extractTextBlocks(content: unknown): string {
119
121
  if (!Array.isArray(content)) return "";
120
122
  const parts: string[] = [];
121
123
  for (const block of content) {
122
- if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
123
- const text = (block as { text?: unknown }).text;
124
- if (typeof text === "string") parts.push(text);
125
- }
124
+ if (!block || typeof block !== "object" || !("type" in block)) continue;
125
+ const type = block.type;
126
+ if (type !== "text" || !("text" in block)) continue;
127
+ const text = block.text;
128
+ if (typeof text === "string") parts.push(text);
126
129
  }
127
130
  return parts.join("");
128
131
  }
129
132
 
133
+ /**
134
+ * Extract plain text from a user message content payload.
135
+ */
136
+ function extractUserText(content: unknown): string {
137
+ if (typeof content === "string") return content;
138
+ return extractTextBlocks(content);
139
+ }
140
+
130
141
  /**
131
142
  * Build user-message stats from an entry. Returns null for empty/synthetic content.
132
143
  */
@@ -141,7 +152,7 @@ function extractUserStats(
141
152
  const text = extractUserText(msg.content);
142
153
  if (!text.trim()) return null;
143
154
  const metrics = computeUserMessageMetrics(text, { prevText });
144
- const prose = judgeProse(text);
155
+ const prose = analysisProse(text);
145
156
  const ts = Date.parse(entry.timestamp);
146
157
  return {
147
158
  sessionFile,
@@ -165,6 +176,53 @@ function extractUserStats(
165
176
  };
166
177
  }
167
178
 
179
+ /**
180
+ * Build the model-side analysis row for an assistant turn. Only `text` blocks
181
+ * are read: thinking/reasoning blocks are the model's scratchpad, not what it
182
+ * said to the user (see {@link extractTextBlocks}). Returns null for turns
183
+ * without text or without an attributable model/provider: a tool-call-only
184
+ * reply has nothing to analyze, and an unattributable turn cannot join a
185
+ * per-model aggregate.
186
+ */
187
+ function extractAssistantTextStats(
188
+ sessionFile: string,
189
+ folder: string,
190
+ entry: SessionMessageEntry,
191
+ agentType: AgentType,
192
+ prevProse: string,
193
+ toolErrors: number,
194
+ ): AssistantTextStats | null {
195
+ const msg = entry.message as AssistantMessage;
196
+ if (msg?.role !== "assistant") return null;
197
+ if (typeof msg.model !== "string" || typeof msg.provider !== "string") return null;
198
+ const text = extractTextBlocks(msg.content);
199
+ if (!text.trim()) return null;
200
+ const prose = analysisProse(text);
201
+ // A reply that was nothing but code/markup has no analysis body to store.
202
+ if (!prose) return null;
203
+ const metrics = computeAssistantMessageMetrics(prose, { prevText: prevProse, toolErrors });
204
+ return {
205
+ sessionFile,
206
+ entryId: entry.id,
207
+ folder,
208
+ timestamp: coerceEntryTimestamp(msg.timestamp, entry),
209
+ model: msg.model,
210
+ provider: msg.provider,
211
+ agentType,
212
+ chars: metrics.chars,
213
+ words: metrics.words,
214
+ selfBlame: metrics.selfBlame,
215
+ restatement: metrics.restatement,
216
+ hedge: metrics.hedge,
217
+ verbose: metrics.verbose,
218
+ toolErrors: metrics.toolErrors,
219
+ toolSpiral: metrics.toolSpiral,
220
+ collapse: metrics.collapse,
221
+ unsupportedScript: metrics.unsupportedScript,
222
+ prose,
223
+ };
224
+ }
225
+
168
226
  /**
169
227
  * Session JSONL is written by older versions and foreign producers, so a token
170
228
  * counter is whatever was persisted, not what `Usage` declares. A non-numeric
@@ -512,6 +570,7 @@ export interface SessionParserState {
512
570
  export interface ParseSessionResult {
513
571
  stats: MessageStatsInput[];
514
572
  userStats: UserMessageStats[];
573
+ assistantTexts: AssistantTextStats[];
515
574
  userLinks: UserMessageLink[];
516
575
  toolCalls: ToolCallStats[];
517
576
  toolResults: ToolResultLink[];
@@ -557,6 +616,7 @@ export async function parseSessionFile(
557
616
  const agentType = classifyAgentType(sessionPath);
558
617
  const stats: MessageStatsInput[] = [];
559
618
  const userStats: UserMessageStats[] = [];
619
+ const assistantTexts: AssistantTextStats[] = [];
560
620
  const userLinks: UserMessageLink[] = [];
561
621
  const toolCalls: ToolCallStats[] = [];
562
622
  const toolResults: ToolResultLink[] = [];
@@ -565,6 +625,13 @@ export async function parseSessionFile(
565
625
  // incremental parse starts mid-session, so the first message of a resumed
566
626
  // pass simply carries no predecessor.
567
627
  let prevUserText = "";
628
+ // Model-side analysis: the previous prose-bearing assistant turn (for
629
+ // repeated restatement) and the failed tool results since the last
630
+ // assistant turn (for tool-failure spirals). Both start empty on a resumed
631
+ // tail read: the first assistant turn of that pass carries no predecessor
632
+ // and no spiral count, and the next full re-parse fills them in.
633
+ let prevAssistantText = "";
634
+ let pendingToolErrors = 0;
568
635
 
569
636
  // Reduce each entry immediately so full replays do not retain tool-output/message bodies.
570
637
  const visit = (entry: SessionEntry): void => {
@@ -582,7 +649,10 @@ export async function parseSessionFile(
582
649
  }
583
650
  if (isToolResultMessage(entry)) {
584
651
  const link = extractToolResultLink(sessionPath, entry);
585
- if (link) toolResults.push(link);
652
+ if (link) {
653
+ toolResults.push(link);
654
+ if (link.isError) pendingToolErrors++;
655
+ }
586
656
  return;
587
657
  }
588
658
  if (isModelUsage(entry)) {
@@ -594,6 +664,20 @@ export async function parseSessionFile(
594
664
  const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
595
665
  if (msgStats) stats.push(msgStats);
596
666
  toolCalls.push(...extractToolCalls(sessionPath, folder, entry, agentType));
667
+ const textStats = extractAssistantTextStats(
668
+ sessionPath,
669
+ folder,
670
+ entry,
671
+ agentType,
672
+ prevAssistantText,
673
+ pendingToolErrors,
674
+ );
675
+ if (textStats) {
676
+ assistantTexts.push(textStats);
677
+ prevAssistantText = textStats.prose;
678
+ }
679
+ // The next assistant turn starts a fresh failure chain.
680
+ pendingToolErrors = 0;
597
681
  // Persist links even when the user entry was ingested in an earlier tail read.
598
682
  const parentId = entry.parentId;
599
683
  const msg = entry.message;
@@ -646,7 +730,15 @@ export async function parseSessionFile(
646
730
  }
647
731
  } catch (err) {
648
732
  if (isEnoent(err))
649
- return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
733
+ return {
734
+ stats: [],
735
+ userStats: [],
736
+ assistantTexts: [],
737
+ userLinks: [],
738
+ toolCalls: [],
739
+ toolResults: [],
740
+ newOffset: fromOffset,
741
+ };
650
742
  throw err;
651
743
  }
652
744
 
@@ -654,6 +746,7 @@ export async function parseSessionFile(
654
746
  return {
655
747
  stats,
656
748
  userStats,
749
+ assistantTexts,
657
750
  userLinks,
658
751
  toolCalls,
659
752
  toolResults,
@@ -193,6 +193,97 @@ export interface AgentTypeStats {
193
193
  totalCost: number;
194
194
  }
195
195
 
196
+ /**
197
+ * Model-side tallies over assistant turns. Every field except `messages`,
198
+ * `collapse`, `words` and `toolErrors` counts turns carrying the signal, so
199
+ * `blamed / messages` is a share. `collapse` and `words` are sums: their means
200
+ * are `collapse / messages` and `words / messages`.
201
+ *
202
+ * Coverage is textual: only turns that produced prose are analyzed, so
203
+ * tool-call-only replies and thinking blocks are outside the denominator.
204
+ * `unscored` counts turns whose prose leans on a script no lexicon covers -
205
+ * their zero signal counts mean "unscored", not "calm".
206
+ */
207
+ export interface ModelSideCounts {
208
+ /** Assistant turns with stored prose. */
209
+ messages: number;
210
+ /** Turns with an apology or admission. */
211
+ blamed: number;
212
+ /** Turns restating earlier content. */
213
+ restated: number;
214
+ /** Turns with a hedged fallback. */
215
+ hedged: number;
216
+ /** Turns past the verbosity threshold. */
217
+ verbose: number;
218
+ /** Turns following a tool-failure spiral. */
219
+ spiraled: number;
220
+ /** Turns with a non-zero per-turn aggregate. */
221
+ breakdown: number;
222
+ /** Sum of per-turn aggregate values. */
223
+ collapse: number;
224
+ /** Sum of analyzed words. */
225
+ words: number;
226
+ /** Sum of failed tool results preceding the turns. */
227
+ toolErrors: number;
228
+ /** Turns in a script no lexicon covers. */
229
+ unscored: number;
230
+ }
231
+
232
+ /** Model-side tallies for one model version, merged by catalog identity. */
233
+ export interface ModelSideModelStats extends ModelSideCounts {
234
+ /** Stable key: `class/family/revision` when classified, else the raw model id. */
235
+ key: string;
236
+ /** Display label, e.g. `opus 4.5`; the raw model id when unclassified. */
237
+ label: string;
238
+ /** Catalog identity class, e.g. `anthropic`; `unknown` when unclassified. */
239
+ modelClass: string;
240
+ /** Catalog family within the class, e.g. `opus`. */
241
+ family: string | null;
242
+ /** Canonical `major.minor.patch`, e.g. `4.5.0`. */
243
+ revision: string | null;
244
+ /** Raw model ids merged into this row. */
245
+ models: string[];
246
+ /** Earliest turn timestamp (ms) in range. */
247
+ firstSeen: number;
248
+ }
249
+
250
+ /** One bucket of the model-side time series. */
251
+ export interface ModelSideTimeSeriesPoint {
252
+ /** Bucket start (Unix ms). */
253
+ timestamp: number;
254
+ messages: number;
255
+ breakdown: number;
256
+ blamed: number;
257
+ restated: number;
258
+ hedged: number;
259
+ spiraled: number;
260
+ collapse: number;
261
+ }
262
+
263
+ /** Model-side tallies for one session, busiest first in the dashboard payload. */
264
+ export interface ModelSideSessionStats extends ModelSideCounts {
265
+ /** Absolute transcript path. */
266
+ sessionFile: string;
267
+ /** Project folder the transcript belongs to. */
268
+ folder: string;
269
+ /** Distinct model ids in the session. */
270
+ models: string[];
271
+ /** Earliest and latest analyzed turn (ms). */
272
+ firstSeen: number;
273
+ lastSeen: number;
274
+ }
275
+
276
+ /** Payload of the model-side section of `GET /api/stats/frustration`. */
277
+ export interface ModelSideDashboardStats {
278
+ overall: ModelSideCounts;
279
+ /** Every model with analyzed turns in range. */
280
+ byModel: ModelSideModelStats[];
281
+ /** Bucketed trend over the active range. */
282
+ series: ModelSideTimeSeriesPoint[];
283
+ /** Sessions with the most analyzed turns, capped for payload size. */
284
+ bySession: ModelSideSessionStats[];
285
+ }
286
+
196
287
  /**
197
288
  * Frustration tallies over a set of user messages. Each message counts once,
198
289
  * classified by its cached judge verdict when one exists, else by the regex
@@ -269,6 +360,8 @@ export interface FrustrationDashboardStats {
269
360
  overall: FrustrationCounts;
270
361
  /** Every model with messages in range, ordered by class, then revision, then family. */
271
362
  byModel: FrustrationModelStats[];
363
+ /** Model-side (AI breakdown) analysis alongside the user-side tallies. */
364
+ modelSide: ModelSideDashboardStats;
272
365
  /** Whether this dashboard host can run the judge (standalone `omp-stats` cannot). */
273
366
  judgeAvailable: boolean;
274
367
  job: FrustrationJobStatus;
package/src/types.ts CHANGED
@@ -187,12 +187,59 @@ export interface UserMessageStats {
187
187
  emphasis: number;
188
188
  /** 1 when the prose leans on a script no lexicon covers; such rows are unscored, not calm. */
189
189
  unsupportedScript: number;
190
- /** Judge-facing prose (see `judgeProse`); empty when the message was all markup. */
190
+ /** Judge-facing prose (see `analysisProse`); empty when the message was all markup. */
191
191
  prose: string;
192
192
  /** Hex `Bun.hash` of `prose`, keying the shared verdict cache; empty when `prose` is. */
193
193
  proseHash: string;
194
194
  }
195
195
 
196
+ /**
197
+ * Model-side analysis for one assistant turn: the text body stored for
198
+ * offline analysis plus the signals computed from it at ingest (see
199
+ * `model-metrics.ts`). Keyed like `user_messages` by (session_file, entry_id).
200
+ * Turns without prose (tool-call-only replies) emit no row.
201
+ */
202
+ export interface AssistantTextStats {
203
+ /** Database ID */
204
+ id?: number;
205
+ /** Session file path */
206
+ sessionFile: string;
207
+ /** Assistant message entry ID within the session */
208
+ entryId: string;
209
+ /** Folder/project path (extracted from session filename) */
210
+ folder: string;
211
+ /** Unix timestamp in ms */
212
+ timestamp: number;
213
+ /** Model that produced the turn */
214
+ model: string;
215
+ /** Provider that served the turn */
216
+ provider: string;
217
+ /** Which agent produced the turn (main agent, task subagent, advisor) */
218
+ agentType: AgentType;
219
+ /** Analyzed-prose characters (code and markup stripped, capped) */
220
+ chars: number;
221
+ /** Analyzed-prose words */
222
+ words: number;
223
+ /** Apology / self-blame markers */
224
+ selfBlame: number;
225
+ /** Repeated-restatement markers plus near-duplicate previous turn */
226
+ restatement: number;
227
+ /** Hedged fallback markers */
228
+ hedge: number;
229
+ /** 1 when the turn is long-winded */
230
+ verbose: number;
231
+ /** Failed tool results preceding the turn */
232
+ toolErrors: number;
233
+ /** 1 when the preceding failures form a spiral */
234
+ toolSpiral: number;
235
+ /** Per-turn aggregate (see `AssistantMessageMetrics`) */
236
+ collapse: number;
237
+ /** 1 when the prose leans on a script no lexicon covers */
238
+ unsupportedScript: number;
239
+ /** Stored analysis prose (see `analysisProse`); never empty for an emitted row. */
240
+ prose: string;
241
+ }
242
+
196
243
  /**
197
244
  * Pair emitted by the parser when it sees an assistant message whose
198
245
  * `parentId` points to a user message that wasn't parsed in the same pass
@@ -460,7 +460,7 @@ const YELLING_THRESHOLD = 0.5;
460
460
  // classic shift-key mishit ("!!!111" / "!?!??111") so we count those as
461
461
  // part of the same drama burst.
462
462
  const DRAMA_RE = /[!?][!?1]{2,}/g;
463
- const WORD_RE = /\S+/g;
463
+ export const WORD_RE = /\S+/g;
464
464
 
465
465
  // Anguish/exasperation interjections. Each alternative is a case-insensitive
466
466
  // word-bounded pattern. Interjections whose short form collides with normal
@@ -664,7 +664,7 @@ const UNSUPPORTED_SCRIPT_RE =
664
664
  const UNSUPPORTED_SCRIPT_SHARE = 0.3;
665
665
 
666
666
  /** 1 when the prose leans on a script the lexicons do not cover. */
667
- function detectUnsupportedScript(prose: string): number {
667
+ export function detectUnsupportedScript(prose: string): number {
668
668
  const letters = countMatches(prose, SCRIPT_LETTER_RE);
669
669
  if (letters < 4) return 0;
670
670
  return countMatches(prose, UNSUPPORTED_SCRIPT_RE) / letters > UNSUPPORTED_SCRIPT_SHARE ? 1 : 0;
@@ -689,7 +689,7 @@ function bigrams(normalized: string): Set<string> {
689
689
  * the one frustration signal that needs no lexicon and therefore works for
690
690
  * any language and survives every transcription error.
691
691
  */
692
- function detectRepeatedMessage(prose: string, prevProse: string): number {
692
+ export function detectRepeatedMessage(prose: string, prevProse: string): number {
693
693
  const current = normalizeForComparison(prose);
694
694
  const previous = normalizeForComparison(prevProse);
695
695
  if (current.length < REPEATED_MESSAGE_MIN_CHARS || previous.length < REPEATED_MESSAGE_MIN_CHARS) return 0;
@@ -731,7 +731,7 @@ const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
731
731
  const MAX_PROSE_LINES = 3;
732
732
 
733
733
  /** Count regex hits without materializing the match array. */
734
- function countMatches(text: string, re: RegExp): number {
734
+ export function countMatches(text: string, re: RegExp): number {
735
735
  let count = 0;
736
736
  re.lastIndex = 0;
737
737
  while (re.exec(text) !== null) count++;
@@ -782,7 +782,7 @@ function countYellingSentences(text: string): number {
782
782
  * Each strip is replaced with a newline so subsequent line counting
783
783
  * reflects what was removed instead of merging neighbors.
784
784
  */
785
- function stripStructuredContent(text: string): string {
785
+ export function stripStructuredContent(text: string): string {
786
786
  return text
787
787
  .replace(FENCED_CODE_RE, "\n")
788
788
  .replace(XML_TAG_PAIR_RE, "\n")
@@ -800,11 +800,13 @@ function stripStructuredContent(text: string): string {
800
800
  export const PROSE_MAX_CHARS = 4000;
801
801
 
802
802
  /**
803
- * The prose a user message contributes to frustration judging: the same
803
+ * The prose a message contributes to behavioral analysis: the same
804
804
  * structured-content strip the regex signals score on, trimmed and capped at
805
805
  * {@link PROSE_MAX_CHARS}. Empty when the message was nothing but markup.
806
+ * Shared by the user-message judge and the model-side analyzer so both store
807
+ * exactly the same body.
806
808
  */
807
- export function judgeProse(text: string): string {
809
+ export function analysisProse(text: string): string {
808
810
  const prose = stripStructuredContent(text.trim()).trim();
809
811
  return prose.length > PROSE_MAX_CHARS ? prose.slice(0, PROSE_MAX_CHARS) : prose;
810
812
  }