@oh-my-pi/omp-stats 14.9.5 → 14.9.7
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/package.json +3 -3
- package/src/aggregator.ts +146 -36
- package/src/client/components/BehaviorChart.tsx +11 -4
- package/src/client/components/BehaviorModelsTable.tsx +62 -19
- package/src/client/components/BehaviorSummary.tsx +30 -10
- package/src/client/types.ts +15 -6
- package/src/db.ts +151 -38
- package/src/index.ts +29 -3
- package/src/parser.ts +31 -14
- package/src/sync-worker.ts +31 -0
- package/src/types.ts +42 -10
- package/src/user-metrics.ts +217 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "14.9.
|
|
4
|
+
"version": "14.9.7",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-ai": "14.9.
|
|
41
|
-
"@oh-my-pi/pi-utils": "14.9.
|
|
40
|
+
"@oh-my-pi/pi-ai": "14.9.7",
|
|
41
|
+
"@oh-my-pi/pi-utils": "14.9.7",
|
|
42
42
|
"@tailwindcss/node": "^4.2.4",
|
|
43
43
|
"chart.js": "^4.5.1",
|
|
44
44
|
"date-fns": "^4.1.0",
|
package/src/aggregator.ts
CHANGED
|
@@ -19,67 +19,177 @@ import {
|
|
|
19
19
|
insertMessageStats,
|
|
20
20
|
insertUserMessageStats,
|
|
21
21
|
setFileOffset,
|
|
22
|
+
updateUserMessageLinks,
|
|
22
23
|
} from "./db";
|
|
23
|
-
import { getSessionEntry, listAllSessionFiles,
|
|
24
|
+
import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
|
|
25
|
+
import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
|
|
26
|
+
// `with { type: "file" }` resolves to the worker's absolute path at runtime
|
|
27
|
+
// (dev) and survives bundling (the asset is copied alongside the build).
|
|
28
|
+
// tsgo doesn't recognize Bun's file-URL import attribute and would raise
|
|
29
|
+
// TS1192/TS5097 here; Bun honors it. Same suppression pattern lives in
|
|
30
|
+
// `tab-supervisor.ts` and `context-manager.ts`.
|
|
31
|
+
// @ts-expect-error -- Bun file-URL import (see comment above).
|
|
32
|
+
import syncWorkerUrl from "./sync-worker.ts" with { type: "file" };
|
|
24
33
|
import type { BehaviorDashboardStats, DashboardStats, MessageStats, RequestDetails } from "./types";
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
36
|
+
* Apply a freshly parsed result to the database. Runs entirely on the
|
|
37
|
+
* main thread so the single SQLite handle owns every write.
|
|
29
38
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
39
|
+
function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
|
|
40
|
+
if (result.stats.length > 0) insertMessageStats(result.stats);
|
|
41
|
+
if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
|
|
42
|
+
if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
|
|
43
|
+
setFileOffset(sessionFile, result.newOffset, lastModified);
|
|
44
|
+
return result.stats.length + result.userStats.length;
|
|
45
|
+
}
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Progress event emitted after each session file is fully processed.
|
|
49
|
+
* `current` is the number of files completed (skipped + parsed),
|
|
50
|
+
* `total` is the size of the work set. `processed` is the running total
|
|
51
|
+
* of inserted rows.
|
|
52
|
+
*/
|
|
53
|
+
export interface SyncProgress {
|
|
54
|
+
current: number;
|
|
55
|
+
total: number;
|
|
56
|
+
processed: number;
|
|
57
|
+
sessionFile: string;
|
|
58
|
+
}
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
export interface SyncOptions {
|
|
61
|
+
/** Called after each file completes. Synchronous; keep it cheap. */
|
|
62
|
+
onProgress?: (event: SyncProgress) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Worker pool size. Defaults to a sensible value derived from the host
|
|
65
|
+
* (capped to avoid drowning a small machine in workers). Set to `1` to
|
|
66
|
+
* force serial parsing without spawning workers.
|
|
67
|
+
*/
|
|
68
|
+
workers?: number;
|
|
69
|
+
}
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
function defaultWorkerCount(): number {
|
|
72
|
+
// `navigator.hardwareConcurrency` is the portable answer in Bun; fall
|
|
73
|
+
// back to a small fixed pool if it's somehow unavailable.
|
|
74
|
+
const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
|
|
75
|
+
const raw = hw > 0 ? hw : 4;
|
|
76
|
+
// Cap at 8 - parse is JSON-bound, and SQLite writes serialize on main
|
|
77
|
+
// thread anyway, so more workers stop helping.
|
|
78
|
+
return Math.min(8, Math.max(2, Math.floor(raw)));
|
|
79
|
+
}
|
|
50
80
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
interface WorkerHandle {
|
|
82
|
+
worker: Worker;
|
|
83
|
+
busy: boolean;
|
|
84
|
+
resolve: ((res: ParseSessionResult) => void) | null;
|
|
85
|
+
reject: ((err: Error) => void) | null;
|
|
86
|
+
}
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
function spawnWorker(): WorkerHandle {
|
|
89
|
+
const worker = new Worker(syncWorkerUrl, { type: "module" });
|
|
90
|
+
const handle: WorkerHandle = { worker, busy: false, resolve: null, reject: null };
|
|
91
|
+
worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
|
|
92
|
+
const { resolve, reject } = handle;
|
|
93
|
+
handle.resolve = null;
|
|
94
|
+
handle.reject = null;
|
|
95
|
+
handle.busy = false;
|
|
96
|
+
if (!resolve || !reject) return;
|
|
97
|
+
if (event.data.ok) resolve(event.data.result);
|
|
98
|
+
else reject(new Error(event.data.error));
|
|
99
|
+
};
|
|
100
|
+
worker.onerror = (event: ErrorEvent) => {
|
|
101
|
+
const { reject } = handle;
|
|
102
|
+
handle.resolve = null;
|
|
103
|
+
handle.reject = null;
|
|
104
|
+
handle.busy = false;
|
|
105
|
+
reject?.(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
|
|
106
|
+
};
|
|
107
|
+
return handle;
|
|
108
|
+
}
|
|
60
109
|
|
|
61
|
-
|
|
110
|
+
function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<ParseSessionResult> {
|
|
111
|
+
if (handle.busy) {
|
|
112
|
+
return Promise.reject(new Error("worker is busy - this is a bug in the dispatcher"));
|
|
113
|
+
}
|
|
114
|
+
const { promise, resolve, reject } = Promise.withResolvers<ParseSessionResult>();
|
|
115
|
+
handle.busy = true;
|
|
116
|
+
handle.resolve = resolve;
|
|
117
|
+
handle.reject = reject;
|
|
118
|
+
handle.worker.postMessage(request);
|
|
119
|
+
return promise;
|
|
62
120
|
}
|
|
63
121
|
|
|
64
122
|
/**
|
|
65
123
|
* Sync all session files to the database.
|
|
66
|
-
*
|
|
124
|
+
*
|
|
125
|
+
* Parsing fans out across a worker pool (one in-flight job per worker)
|
|
126
|
+
* while DB writes and offset bookkeeping stay on the calling thread so the
|
|
127
|
+
* single SQLite handle stays uncontended. `onProgress` fires once per
|
|
128
|
+
* completed file (skipped files included so the bar walks at a steady
|
|
129
|
+
* rate).
|
|
67
130
|
*/
|
|
68
|
-
export async function syncAllSessions(): Promise<{ processed: number; files: number }> {
|
|
131
|
+
export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
|
|
69
132
|
await initDb();
|
|
70
133
|
|
|
71
134
|
const files = await listAllSessionFiles();
|
|
135
|
+
if (files.length === 0) return { processed: 0, files: 0 };
|
|
136
|
+
|
|
72
137
|
let totalProcessed = 0;
|
|
73
138
|
let filesProcessed = 0;
|
|
139
|
+
let completed = 0;
|
|
140
|
+
let cursor = 0;
|
|
141
|
+
|
|
142
|
+
const poolSize = Math.max(1, Math.min(files.length, opts?.workers ?? defaultWorkerCount()));
|
|
143
|
+
const handles: WorkerHandle[] = [];
|
|
144
|
+
for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
|
|
145
|
+
|
|
146
|
+
const report = (sessionFile: string) => {
|
|
147
|
+
completed++;
|
|
148
|
+
opts?.onProgress?.({
|
|
149
|
+
current: completed,
|
|
150
|
+
total: files.length,
|
|
151
|
+
processed: totalProcessed,
|
|
152
|
+
sessionFile,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
74
155
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
156
|
+
async function drain(handle: WorkerHandle): Promise<void> {
|
|
157
|
+
while (true) {
|
|
158
|
+
const idx = cursor++;
|
|
159
|
+
if (idx >= files.length) return;
|
|
160
|
+
const sessionFile = files[idx];
|
|
161
|
+
|
|
162
|
+
let fileStats: fs.Stats;
|
|
163
|
+
try {
|
|
164
|
+
fileStats = await fs.promises.stat(sessionFile);
|
|
165
|
+
} catch {
|
|
166
|
+
report(sessionFile);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const lastModified = fileStats.mtimeMs;
|
|
170
|
+
const stored = getFileOffset(sessionFile);
|
|
171
|
+
if (stored && stored.lastModified >= lastModified) {
|
|
172
|
+
report(sessionFile);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const fromOffset = stored?.offset ?? 0;
|
|
177
|
+
const result = await dispatch(handle, { sessionFile, fromOffset });
|
|
178
|
+
const inserted = applyParseResult(sessionFile, lastModified, result);
|
|
179
|
+
if (inserted > 0) {
|
|
180
|
+
totalProcessed += inserted;
|
|
181
|
+
filesProcessed++;
|
|
182
|
+
}
|
|
183
|
+
report(sessionFile);
|
|
80
184
|
}
|
|
81
185
|
}
|
|
82
186
|
|
|
187
|
+
try {
|
|
188
|
+
await Promise.all(handles.map(drain));
|
|
189
|
+
} finally {
|
|
190
|
+
for (const handle of handles) handle.worker.terminate();
|
|
191
|
+
}
|
|
192
|
+
|
|
83
193
|
return { processed: totalProcessed, files: filesProcessed };
|
|
84
194
|
}
|
|
85
195
|
|
|
@@ -51,10 +51,14 @@ const CHART_THEMES = {
|
|
|
51
51
|
} as const;
|
|
52
52
|
|
|
53
53
|
const METRIC_OPTIONS = [
|
|
54
|
-
{ value: "
|
|
54
|
+
{ value: "yelling", label: "Yelling" },
|
|
55
55
|
{ value: "profanity", label: "Profanity" },
|
|
56
|
-
{ value: "
|
|
57
|
-
{ value: "
|
|
56
|
+
{ value: "anguish", label: "Anguish (!!!, nooo, dude, ..)" },
|
|
57
|
+
{ value: "negation", label: "Negation (no/nope/wrong)" },
|
|
58
|
+
{ value: "repetition", label: "Repetition (i meant, still doesnt)" },
|
|
59
|
+
{ value: "blame", label: "Blame (you didnt, stop X-ing)" },
|
|
60
|
+
{ value: "frustration", label: "Frustration (neg + rep + blame)" },
|
|
61
|
+
{ value: "total", label: "All signals combined" },
|
|
58
62
|
] as const;
|
|
59
63
|
type Metric = (typeof METRIC_OPTIONS)[number]["value"];
|
|
60
64
|
|
|
@@ -70,7 +74,10 @@ interface BehaviorChartProps {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number {
|
|
73
|
-
if (metric === "
|
|
77
|
+
if (metric === "frustration") return point.negation + point.repetition + point.blame;
|
|
78
|
+
if (metric === "total") {
|
|
79
|
+
return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
80
|
+
}
|
|
74
81
|
return point[metric];
|
|
75
82
|
}
|
|
76
83
|
|
|
@@ -30,7 +30,8 @@ const MODEL_COLORS = [
|
|
|
30
30
|
const SERIES_COLORS = {
|
|
31
31
|
yelling: "#fbbf24", // amber
|
|
32
32
|
profanity: "#f87171", // red
|
|
33
|
-
|
|
33
|
+
anguish: "#a78bfa", // violet
|
|
34
|
+
frustration: "#22d3ee", // cyan - new semantic signals
|
|
34
35
|
} as const;
|
|
35
36
|
|
|
36
37
|
const CHART_THEMES = {
|
|
@@ -65,7 +66,8 @@ interface DailyPoint {
|
|
|
65
66
|
timestamp: number;
|
|
66
67
|
yelling: number;
|
|
67
68
|
profanity: number;
|
|
68
|
-
|
|
69
|
+
anguish: number;
|
|
70
|
+
frustration: number;
|
|
69
71
|
total: number;
|
|
70
72
|
}
|
|
71
73
|
|
|
@@ -73,7 +75,7 @@ interface ModelTrendSeries {
|
|
|
73
75
|
data: DailyPoint[];
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
const GRID_TEMPLATE = "2fr 0.9fr 0.
|
|
78
|
+
const GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
|
|
77
79
|
|
|
78
80
|
function formatInt(value: number): string {
|
|
79
81
|
return value.toLocaleString();
|
|
@@ -81,7 +83,13 @@ function formatInt(value: number): string {
|
|
|
81
83
|
|
|
82
84
|
function totalHitRate(model: BehaviorModelStats): number {
|
|
83
85
|
if (model.totalMessages === 0) return 0;
|
|
84
|
-
const hits =
|
|
86
|
+
const hits =
|
|
87
|
+
model.totalYelling +
|
|
88
|
+
model.totalProfanity +
|
|
89
|
+
model.totalAnguish +
|
|
90
|
+
model.totalNegation +
|
|
91
|
+
model.totalRepetition +
|
|
92
|
+
model.totalBlame;
|
|
85
93
|
return hits / model.totalMessages;
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -128,7 +136,8 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa
|
|
|
128
136
|
<div className="text-right">Messages</div>
|
|
129
137
|
<div className="text-right">CAPS %</div>
|
|
130
138
|
<div className="text-right">Profanity %</div>
|
|
131
|
-
<div className="text-right">
|
|
139
|
+
<div className="text-right">Anguish %</div>
|
|
140
|
+
<div className="text-right">Frustration %</div>
|
|
132
141
|
<div className="text-right">Hits %</div>
|
|
133
142
|
<div className="text-center">Trend</div>
|
|
134
143
|
<div />
|
|
@@ -140,7 +149,8 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa
|
|
|
140
149
|
const trend = trendByKey.get(key)?.data ?? [];
|
|
141
150
|
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
|
|
142
151
|
const isExpanded = expandedKey === key;
|
|
143
|
-
const
|
|
152
|
+
const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
|
|
153
|
+
const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
|
|
144
154
|
|
|
145
155
|
return (
|
|
146
156
|
<div key={key} className="border-t border-[var(--border-subtle)]">
|
|
@@ -158,13 +168,16 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa
|
|
|
158
168
|
{formatInt(model.totalMessages)}
|
|
159
169
|
</div>
|
|
160
170
|
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
161
|
-
{formatRate(model.
|
|
171
|
+
{formatRate(model.totalYelling, model.totalMessages)}
|
|
162
172
|
</div>
|
|
163
173
|
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
164
174
|
{formatRate(model.totalProfanity, model.totalMessages)}
|
|
165
175
|
</div>
|
|
166
176
|
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
167
|
-
{formatRate(model.
|
|
177
|
+
{formatRate(model.totalAnguish, model.totalMessages)}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
180
|
+
{formatRate(totalFrustration, model.totalMessages)}
|
|
168
181
|
</div>
|
|
169
182
|
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
170
183
|
{formatRate(totalHits, model.totalMessages)}
|
|
@@ -188,7 +201,7 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa
|
|
|
188
201
|
<div className="space-y-4 text-sm">
|
|
189
202
|
<DetailRow
|
|
190
203
|
label="Yelling (CAPS)"
|
|
191
|
-
total={model.
|
|
204
|
+
total={model.totalYelling}
|
|
192
205
|
messages={model.totalMessages}
|
|
193
206
|
valueClass="text-[var(--accent-amber,#fbbf24)]"
|
|
194
207
|
/>
|
|
@@ -199,11 +212,29 @@ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTa
|
|
|
199
212
|
valueClass="text-[var(--accent-red,#f87171)]"
|
|
200
213
|
/>
|
|
201
214
|
<DetailRow
|
|
202
|
-
label="
|
|
203
|
-
total={model.
|
|
215
|
+
label="Anguish (!!!, nooo, dude, ..)"
|
|
216
|
+
total={model.totalAnguish}
|
|
204
217
|
messages={model.totalMessages}
|
|
205
218
|
valueClass="text-[var(--accent-violet,#a78bfa)]"
|
|
206
219
|
/>
|
|
220
|
+
<DetailRow
|
|
221
|
+
label="Negation (no/nope/wrong)"
|
|
222
|
+
total={model.totalNegation}
|
|
223
|
+
messages={model.totalMessages}
|
|
224
|
+
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
225
|
+
/>
|
|
226
|
+
<DetailRow
|
|
227
|
+
label="Repetition (i meant, still doesnt)"
|
|
228
|
+
total={model.totalRepetition}
|
|
229
|
+
messages={model.totalMessages}
|
|
230
|
+
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
231
|
+
/>
|
|
232
|
+
<DetailRow
|
|
233
|
+
label="Blame (you didnt, stop X-ing)"
|
|
234
|
+
total={model.totalBlame}
|
|
235
|
+
messages={model.totalMessages}
|
|
236
|
+
valueClass="text-[var(--accent-cyan,#22d3ee)]"
|
|
237
|
+
/>
|
|
207
238
|
<DetailRow
|
|
208
239
|
label="Avg chars / msg"
|
|
209
240
|
total={model.totalChars}
|
|
@@ -322,9 +353,18 @@ function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme:
|
|
|
322
353
|
borderWidth: 2,
|
|
323
354
|
},
|
|
324
355
|
{
|
|
325
|
-
label: "
|
|
326
|
-
data: data.map(d => d.
|
|
327
|
-
borderColor: SERIES_COLORS.
|
|
356
|
+
label: "Anguish",
|
|
357
|
+
data: data.map(d => d.anguish),
|
|
358
|
+
borderColor: SERIES_COLORS.anguish,
|
|
359
|
+
backgroundColor: "transparent",
|
|
360
|
+
tension: 0.4,
|
|
361
|
+
pointRadius: 0,
|
|
362
|
+
borderWidth: 2,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
label: "Frustration",
|
|
366
|
+
data: data.map(d => d.frustration),
|
|
367
|
+
borderColor: SERIES_COLORS.frustration,
|
|
328
368
|
backgroundColor: "transparent",
|
|
329
369
|
tension: 0.4,
|
|
330
370
|
pointRadius: 0,
|
|
@@ -394,13 +434,15 @@ function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelT
|
|
|
394
434
|
timestamp: point.timestamp,
|
|
395
435
|
yelling: 0,
|
|
396
436
|
profanity: 0,
|
|
397
|
-
|
|
437
|
+
anguish: 0,
|
|
438
|
+
frustration: 0,
|
|
398
439
|
total: 0,
|
|
399
440
|
};
|
|
400
|
-
existing.yelling += point.
|
|
441
|
+
existing.yelling += point.yelling;
|
|
401
442
|
existing.profanity += point.profanity;
|
|
402
|
-
existing.
|
|
403
|
-
existing.
|
|
443
|
+
existing.anguish += point.anguish;
|
|
444
|
+
existing.frustration += point.negation + point.repetition + point.blame;
|
|
445
|
+
existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
|
|
404
446
|
dayMap.set(point.timestamp, existing);
|
|
405
447
|
}
|
|
406
448
|
|
|
@@ -412,7 +454,8 @@ function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelT
|
|
|
412
454
|
timestamp: ts,
|
|
413
455
|
yelling: 0,
|
|
414
456
|
profanity: 0,
|
|
415
|
-
|
|
457
|
+
anguish: 0,
|
|
458
|
+
frustration: 0,
|
|
416
459
|
total: 0,
|
|
417
460
|
},
|
|
418
461
|
);
|
|
@@ -10,14 +10,26 @@ function formatInt(value: number): string {
|
|
|
10
10
|
return value.toLocaleString();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Per-message rate for a signal. Uses 2 decimals so a 0.01-hits-per-msg model
|
|
15
|
+
* still distinguishes from a true zero, and never shows `NaN` or `Infinity`
|
|
16
|
+
* when there are no messages.
|
|
17
|
+
*/
|
|
18
|
+
function perMsg(total: number, messages: number): string | undefined {
|
|
19
|
+
if (messages <= 0) return undefined;
|
|
20
|
+
return `${(total / messages).toFixed(2)} / msg`;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProps) {
|
|
14
|
-
// Top "ranted-at" model: model that absorbed the most caps + profanity +
|
|
24
|
+
// Top "ranted-at" model: model that absorbed the most caps + profanity +
|
|
25
|
+
// anguish + frustration (negation/repetition/blame).
|
|
15
26
|
const topModel = useMemo(() => {
|
|
16
27
|
const totals = new Map<string, { model: string; provider: string; score: number }>();
|
|
17
28
|
for (const point of behaviorSeries) {
|
|
18
29
|
const key = `${point.model}::${point.provider}`;
|
|
19
30
|
const existing = totals.get(key);
|
|
20
|
-
const score =
|
|
31
|
+
const score =
|
|
32
|
+
point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
21
33
|
if (existing) {
|
|
22
34
|
existing.score += score;
|
|
23
35
|
} else {
|
|
@@ -31,36 +43,44 @@ export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProp
|
|
|
31
43
|
return best;
|
|
32
44
|
}, [behaviorSeries]);
|
|
33
45
|
|
|
34
|
-
const
|
|
46
|
+
const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
|
|
47
|
+
const messages = overall.totalMessages;
|
|
35
48
|
|
|
36
49
|
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
|
37
50
|
{
|
|
38
51
|
label: "Messages",
|
|
39
52
|
value: formatInt(overall.totalMessages),
|
|
53
|
+
sub: messages > 0 ? "in selected range" : undefined,
|
|
40
54
|
},
|
|
41
55
|
{
|
|
42
56
|
label: "Yelling",
|
|
43
|
-
value: formatInt(overall.
|
|
44
|
-
sub: overall.
|
|
57
|
+
value: formatInt(overall.totalYelling),
|
|
58
|
+
sub: perMsg(overall.totalYelling, messages),
|
|
45
59
|
},
|
|
46
60
|
{
|
|
47
61
|
label: "Profanity hits",
|
|
48
62
|
value: formatInt(overall.totalProfanity),
|
|
63
|
+
sub: perMsg(overall.totalProfanity, messages),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
label: "Anguish",
|
|
67
|
+
value: formatInt(overall.totalAnguish),
|
|
68
|
+
sub: perMsg(overall.totalAnguish, messages),
|
|
49
69
|
},
|
|
50
70
|
{
|
|
51
|
-
label: "
|
|
52
|
-
value: formatInt(
|
|
53
|
-
sub:
|
|
71
|
+
label: "Frustration",
|
|
72
|
+
value: formatInt(totalFrustration),
|
|
73
|
+
sub: perMsg(totalFrustration, messages),
|
|
54
74
|
},
|
|
55
75
|
{
|
|
56
76
|
label: "Most yelled-at",
|
|
57
|
-
value: topModel?.model ?? "
|
|
77
|
+
value: topModel?.model ?? "\u2014",
|
|
58
78
|
sub: topModel ? `${formatInt(topModel.score)} hits` : undefined,
|
|
59
79
|
},
|
|
60
80
|
];
|
|
61
81
|
|
|
62
82
|
return (
|
|
63
|
-
<div className="grid grid-cols-2 sm:grid-cols-
|
|
83
|
+
<div className="grid grid-cols-2 sm:grid-cols-6 gap-4">
|
|
64
84
|
{cards.map(card => (
|
|
65
85
|
<div key={card.label} className="surface px-4 py-3">
|
|
66
86
|
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
package/src/client/types.ts
CHANGED
|
@@ -135,17 +135,23 @@ export interface BehaviorTimeSeriesPoint {
|
|
|
135
135
|
model: string;
|
|
136
136
|
provider: string;
|
|
137
137
|
messages: number;
|
|
138
|
-
|
|
138
|
+
yelling: number;
|
|
139
139
|
profanity: number;
|
|
140
|
-
|
|
140
|
+
anguish: number;
|
|
141
|
+
negation: number;
|
|
142
|
+
repetition: number;
|
|
143
|
+
blame: number;
|
|
141
144
|
chars: number;
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
export interface BehaviorOverallStats {
|
|
145
148
|
totalMessages: number;
|
|
146
|
-
|
|
149
|
+
totalYelling: number;
|
|
147
150
|
totalProfanity: number;
|
|
148
|
-
|
|
151
|
+
totalAnguish: number;
|
|
152
|
+
totalNegation: number;
|
|
153
|
+
totalRepetition: number;
|
|
154
|
+
totalBlame: number;
|
|
149
155
|
totalChars: number;
|
|
150
156
|
firstTimestamp: number;
|
|
151
157
|
lastTimestamp: number;
|
|
@@ -155,9 +161,12 @@ export interface BehaviorModelStats {
|
|
|
155
161
|
model: string;
|
|
156
162
|
provider: string;
|
|
157
163
|
totalMessages: number;
|
|
158
|
-
|
|
164
|
+
totalYelling: number;
|
|
159
165
|
totalProfanity: number;
|
|
160
|
-
|
|
166
|
+
totalAnguish: number;
|
|
167
|
+
totalNegation: number;
|
|
168
|
+
totalRepetition: number;
|
|
169
|
+
totalBlame: number;
|
|
161
170
|
totalChars: number;
|
|
162
171
|
lastTimestamp: number;
|
|
163
172
|
}
|