@linxiraos/pi-stats 1.3.2 → 1.4.1
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/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +91 -91
- package/dist/types/client/app/LanguageToggle.d.ts +7 -0
- package/dist/types/client/i18n/index.d.ts +11 -3
- package/dist/types/client/i18n/messages/common.d.ts +4 -0
- package/dist/types/client/i18n/messages/en.d.ts +76 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +72 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +1 -1
- package/dist/types/client/i18n/useLocale.d.ts +7 -0
- package/dist/types/db.d.ts +34 -1
- package/dist/types/model-identity.d.ts +29 -0
- package/dist/types/model-metrics.d.ts +53 -0
- package/dist/types/model-side.d.ts +6 -0
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/shared-types.d.ts +88 -0
- package/dist/types/types.d.ts +47 -1
- package/dist/types/user-metrics.d.ts +22 -2
- package/package.json +4 -4
- package/src/aggregator.ts +1 -0
- package/src/client/App.tsx +4 -0
- package/src/client/app/LanguageToggle.tsx +30 -0
- package/src/client/app/Shell.tsx +12 -7
- package/src/client/app/ThemeToggle.tsx +11 -8
- package/src/client/i18n/index.ts +49 -6
- package/src/client/i18n/messages/common.ts +4 -0
- package/src/client/i18n/messages/frustration.ts +77 -0
- package/src/client/i18n/useLocale.ts +11 -0
- package/src/client/routes/FrustrationRoute.tsx +439 -4
- package/src/client/routes/ProvidersRoute.tsx +2 -4
- package/src/client/routes/ToolsRoute.tsx +7 -9
- package/src/client/routes/frustration.css +13 -0
- package/src/client/styles.css +13 -0
- package/src/db.ts +321 -2
- package/src/frustration.ts +8 -33
- package/src/model-identity.ts +61 -0
- package/src/model-metrics.ts +153 -0
- package/src/model-side.ts +77 -0
- package/src/parser.ts +104 -11
- package/src/shared-types.ts +93 -0
- package/src/types.ts +48 -1
- package/src/user-metrics.ts +9 -7
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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)
|
|
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 {
|
|
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,
|
package/src/shared-types.ts
CHANGED
|
@@ -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 `
|
|
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
|
package/src/user-metrics.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|