@oh-my-pi/omp-stats 14.9.2 → 14.9.5
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 +6 -6
- package/src/aggregator.ts +138 -11
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +367 -0
- package/src/client/components/BehaviorModelsTable.tsx +422 -0
- package/src/client/components/BehaviorSummary.tsx +75 -0
- package/src/client/components/CostChart.tsx +5 -38
- package/src/client/components/CostSummary.tsx +8 -47
- package/src/client/components/Header.tsx +28 -4
- package/src/client/components/StatsGrid.tsx +10 -1
- package/src/client/types.ts +54 -0
- package/src/db.ts +307 -26
- package/src/parser.ts +75 -4
- package/src/server.ts +30 -6
- package/src/types.ts +81 -0
- package/src/user-metrics.ts +486 -0
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { Activity, RefreshCw } from "lucide-react";
|
|
2
|
+
import type { TimeRange } from "../types";
|
|
2
3
|
|
|
3
|
-
type Tab = "overview" | "requests" | "errors" | "models" | "costs";
|
|
4
|
+
type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
|
|
5
|
+
|
|
6
|
+
const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs", "behavior"];
|
|
7
|
+
const timeRanges: { label: string; value: TimeRange }[] = [
|
|
8
|
+
{ label: "1h", value: "1h" },
|
|
9
|
+
{ label: "24h", value: "24h" },
|
|
10
|
+
{ label: "7d", value: "7d" },
|
|
11
|
+
{ label: "30d", value: "30d" },
|
|
12
|
+
{ label: "90d", value: "90d" },
|
|
13
|
+
{ label: "All", value: "all" },
|
|
14
|
+
];
|
|
4
15
|
|
|
5
16
|
interface HeaderProps {
|
|
6
17
|
activeTab: Tab;
|
|
7
18
|
onTabChange: (tab: Tab) => void;
|
|
8
19
|
onSync: () => void;
|
|
9
20
|
syncing: boolean;
|
|
21
|
+
timeRange: TimeRange;
|
|
22
|
+
onTimeRangeChange: (timeRange: TimeRange) => void;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
|
|
25
|
+
export function Header({ activeTab, onTabChange, onSync, syncing, timeRange, onTimeRangeChange }: HeaderProps) {
|
|
15
26
|
return (
|
|
16
27
|
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
|
|
17
28
|
<div className="flex items-center gap-3">
|
|
@@ -37,6 +48,19 @@ export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps)
|
|
|
37
48
|
</button>
|
|
38
49
|
))}
|
|
39
50
|
</div>
|
|
51
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
|
|
52
|
+
{timeRanges.map(range => (
|
|
53
|
+
<button
|
|
54
|
+
key={range.value}
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => onTimeRangeChange(range.value)}
|
|
57
|
+
className={`tab-btn ${timeRange === range.value ? "active" : ""}`}
|
|
58
|
+
title={range.value === "all" ? "All time" : `Last ${range.label}`}
|
|
59
|
+
>
|
|
60
|
+
{range.label}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
40
64
|
|
|
41
65
|
<button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
|
|
42
66
|
<RefreshCw size={16} className={syncing ? "spin" : ""} />
|
|
@@ -5,6 +5,15 @@ interface StatsGridProps {
|
|
|
5
5
|
stats: AggregatedStats;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
|
9
|
+
notation: "compact",
|
|
10
|
+
maximumFractionDigits: 1,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function formatCompactNumber(value: number): string {
|
|
14
|
+
return compactNumberFormatter.format(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
const statConfig = [
|
|
9
18
|
{
|
|
10
19
|
key: "requests",
|
|
@@ -39,7 +48,7 @@ const statConfig = [
|
|
|
39
48
|
icon: Database,
|
|
40
49
|
color: "var(--accent-cyan)",
|
|
41
50
|
getValue: (s: AggregatedStats) => `${(s.cacheRate * 100).toFixed(1)}%`,
|
|
42
|
-
getDetail: (s: AggregatedStats) => `${(s.totalCacheReadTokens
|
|
51
|
+
getDetail: (s: AggregatedStats) => `${formatCompactNumber(s.totalCacheReadTokens)} cached tokens`,
|
|
43
52
|
},
|
|
44
53
|
{
|
|
45
54
|
key: "errors",
|
package/src/client/types.ts
CHANGED
|
@@ -59,6 +59,7 @@ export interface AggregatedStats {
|
|
|
59
59
|
lastTimestamp: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
|
|
62
63
|
export interface ModelStats extends AggregatedStats {
|
|
63
64
|
model: string;
|
|
64
65
|
provider: string;
|
|
@@ -113,3 +114,56 @@ export interface DashboardStats {
|
|
|
113
114
|
modelPerformanceSeries: ModelPerformancePoint[];
|
|
114
115
|
costSeries: CostTimeSeriesPoint[];
|
|
115
116
|
}
|
|
117
|
+
|
|
118
|
+
export interface OverviewStats {
|
|
119
|
+
overall: AggregatedStats;
|
|
120
|
+
timeSeries: TimeSeriesPoint[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ModelDashboardStats {
|
|
124
|
+
byModel: ModelStats[];
|
|
125
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
126
|
+
modelPerformanceSeries: ModelPerformancePoint[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CostDashboardStats {
|
|
130
|
+
costSeries: CostTimeSeriesPoint[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface BehaviorTimeSeriesPoint {
|
|
134
|
+
timestamp: number;
|
|
135
|
+
model: string;
|
|
136
|
+
provider: string;
|
|
137
|
+
messages: number;
|
|
138
|
+
yellingSentences: number;
|
|
139
|
+
profanity: number;
|
|
140
|
+
dramaRuns: number;
|
|
141
|
+
chars: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface BehaviorOverallStats {
|
|
145
|
+
totalMessages: number;
|
|
146
|
+
totalYellingSentences: number;
|
|
147
|
+
totalProfanity: number;
|
|
148
|
+
totalDramaRuns: number;
|
|
149
|
+
totalChars: number;
|
|
150
|
+
firstTimestamp: number;
|
|
151
|
+
lastTimestamp: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface BehaviorModelStats {
|
|
155
|
+
model: string;
|
|
156
|
+
provider: string;
|
|
157
|
+
totalMessages: number;
|
|
158
|
+
totalYellingSentences: number;
|
|
159
|
+
totalProfanity: number;
|
|
160
|
+
totalDramaRuns: number;
|
|
161
|
+
totalChars: number;
|
|
162
|
+
lastTimestamp: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface BehaviorDashboardStats {
|
|
166
|
+
overall: BehaviorOverallStats;
|
|
167
|
+
byModel: BehaviorModelStats[];
|
|
168
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
169
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { type GeneratedProvider, getBundledModel, type Usage } from "@oh-my-pi/p
|
|
|
4
4
|
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import type {
|
|
6
6
|
AggregatedStats,
|
|
7
|
+
BehaviorModelStats,
|
|
8
|
+
BehaviorOverallStats,
|
|
9
|
+
BehaviorTimeSeriesPoint,
|
|
7
10
|
CostTimeSeriesPoint,
|
|
8
11
|
FolderStats,
|
|
9
12
|
MessageStats,
|
|
@@ -11,6 +14,7 @@ import type {
|
|
|
11
14
|
ModelStats,
|
|
12
15
|
ModelTimeSeriesPoint,
|
|
13
16
|
TimeSeriesPoint,
|
|
17
|
+
UserMessageStats,
|
|
14
18
|
} from "./types";
|
|
15
19
|
|
|
16
20
|
type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
@@ -74,12 +78,39 @@ export async function initDb(): Promise<Database> {
|
|
|
74
78
|
CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
|
|
75
79
|
CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
|
|
76
80
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp_model_provider ON messages(timestamp, model, provider);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp_folder ON messages(timestamp, folder);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_messages_stop_reason_timestamp ON messages(stop_reason, timestamp);
|
|
77
84
|
|
|
78
85
|
CREATE TABLE IF NOT EXISTS file_offsets (
|
|
79
86
|
session_file TEXT PRIMARY KEY,
|
|
80
87
|
offset INTEGER NOT NULL,
|
|
81
88
|
last_modified INTEGER NOT NULL
|
|
82
89
|
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS user_messages (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
session_file TEXT NOT NULL,
|
|
94
|
+
entry_id TEXT NOT NULL,
|
|
95
|
+
folder TEXT NOT NULL,
|
|
96
|
+
timestamp INTEGER NOT NULL,
|
|
97
|
+
model TEXT,
|
|
98
|
+
provider TEXT,
|
|
99
|
+
chars INTEGER NOT NULL,
|
|
100
|
+
words INTEGER NOT NULL,
|
|
101
|
+
yelling_sentences INTEGER NOT NULL,
|
|
102
|
+
profanity INTEGER NOT NULL,
|
|
103
|
+
drama_runs INTEGER NOT NULL,
|
|
104
|
+
UNIQUE(session_file, entry_id)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
109
|
+
|
|
110
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
111
|
+
key TEXT PRIMARY KEY,
|
|
112
|
+
value TEXT NOT NULL
|
|
113
|
+
);
|
|
83
114
|
`);
|
|
84
115
|
|
|
85
116
|
const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
|
|
@@ -87,6 +118,38 @@ export async function initDb(): Promise<Database> {
|
|
|
87
118
|
db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
|
|
88
119
|
}
|
|
89
120
|
db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
|
|
121
|
+
// Bumping the metric definition (yelling sentences vs caps words) invalidates
|
|
122
|
+
// previously-ingested rows. If the legacy column is present we drop the table
|
|
123
|
+
// outright; the `IF NOT EXISTS` create above already gave us the new schema
|
|
124
|
+
// in parallel, but we want a clean wipe + re-ingest. The accompanying
|
|
125
|
+
// `backfillUserMessages` bump (v2) clears `file_offsets` so the next sync
|
|
126
|
+
// re-parses every session.
|
|
127
|
+
const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
|
|
128
|
+
name: string;
|
|
129
|
+
}[];
|
|
130
|
+
if (userMessageColumns.some(column => column.name === "caps_words")) {
|
|
131
|
+
db.exec("DROP TABLE user_messages");
|
|
132
|
+
db.exec(`
|
|
133
|
+
CREATE TABLE user_messages (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
session_file TEXT NOT NULL,
|
|
136
|
+
entry_id TEXT NOT NULL,
|
|
137
|
+
folder TEXT NOT NULL,
|
|
138
|
+
timestamp INTEGER NOT NULL,
|
|
139
|
+
model TEXT,
|
|
140
|
+
provider TEXT,
|
|
141
|
+
chars INTEGER NOT NULL,
|
|
142
|
+
words INTEGER NOT NULL,
|
|
143
|
+
yelling_sentences INTEGER NOT NULL,
|
|
144
|
+
profanity INTEGER NOT NULL,
|
|
145
|
+
drama_runs INTEGER NOT NULL,
|
|
146
|
+
UNIQUE(session_file, entry_id)
|
|
147
|
+
);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
|
|
149
|
+
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
backfillUserMessages(db);
|
|
90
153
|
backfillMissingCatalogCosts(db);
|
|
91
154
|
return db;
|
|
92
155
|
}
|
|
@@ -312,9 +375,10 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
|
|
|
312
375
|
/**
|
|
313
376
|
* Get overall aggregated stats.
|
|
314
377
|
*/
|
|
315
|
-
export function getOverallStats(): AggregatedStats {
|
|
378
|
+
export function getOverallStats(cutoff?: number): AggregatedStats {
|
|
316
379
|
if (!db) return buildAggregatedStats([]);
|
|
317
380
|
|
|
381
|
+
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
318
382
|
const stmt = db.prepare(`
|
|
319
383
|
SELECT
|
|
320
384
|
COUNT(*) as total_requests,
|
|
@@ -331,18 +395,19 @@ export function getOverallStats(): AggregatedStats {
|
|
|
331
395
|
MIN(timestamp) as first_timestamp,
|
|
332
396
|
MAX(timestamp) as last_timestamp
|
|
333
397
|
FROM messages
|
|
398
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
334
399
|
`);
|
|
335
400
|
|
|
336
|
-
const rows = stmt.all();
|
|
337
|
-
return buildAggregatedStats(rows);
|
|
401
|
+
const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
|
|
402
|
+
return buildAggregatedStats(rows as any[]);
|
|
338
403
|
}
|
|
339
|
-
|
|
340
404
|
/**
|
|
341
405
|
* Get stats grouped by model.
|
|
342
406
|
*/
|
|
343
|
-
export function getStatsByModel(): ModelStats[] {
|
|
407
|
+
export function getStatsByModel(cutoff?: number): ModelStats[] {
|
|
344
408
|
if (!db) return [];
|
|
345
409
|
|
|
410
|
+
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
346
411
|
const stmt = db.prepare(`
|
|
347
412
|
SELECT
|
|
348
413
|
model,
|
|
@@ -361,11 +426,12 @@ export function getStatsByModel(): ModelStats[] {
|
|
|
361
426
|
MIN(timestamp) as first_timestamp,
|
|
362
427
|
MAX(timestamp) as last_timestamp
|
|
363
428
|
FROM messages
|
|
429
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
364
430
|
GROUP BY model, provider
|
|
365
431
|
ORDER BY total_requests DESC
|
|
366
432
|
`);
|
|
367
433
|
|
|
368
|
-
const rows = stmt.all() as any[];
|
|
434
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
|
|
369
435
|
return rows.map(row => ({
|
|
370
436
|
model: row.model,
|
|
371
437
|
provider: row.provider,
|
|
@@ -376,9 +442,10 @@ export function getStatsByModel(): ModelStats[] {
|
|
|
376
442
|
/**
|
|
377
443
|
* Get stats grouped by folder.
|
|
378
444
|
*/
|
|
379
|
-
export function getStatsByFolder(): FolderStats[] {
|
|
445
|
+
export function getStatsByFolder(cutoff?: number): FolderStats[] {
|
|
380
446
|
if (!db) return [];
|
|
381
447
|
|
|
448
|
+
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
382
449
|
const stmt = db.prepare(`
|
|
383
450
|
SELECT
|
|
384
451
|
folder,
|
|
@@ -396,11 +463,12 @@ export function getStatsByFolder(): FolderStats[] {
|
|
|
396
463
|
MIN(timestamp) as first_timestamp,
|
|
397
464
|
MAX(timestamp) as last_timestamp
|
|
398
465
|
FROM messages
|
|
466
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
399
467
|
GROUP BY folder
|
|
400
468
|
ORDER BY total_requests DESC
|
|
401
469
|
`);
|
|
402
470
|
|
|
403
|
-
const rows = stmt.all() as any[];
|
|
471
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
|
|
404
472
|
return rows.map(row => ({
|
|
405
473
|
folder: row.folder,
|
|
406
474
|
...buildAggregatedStats([row]),
|
|
@@ -408,27 +476,30 @@ export function getStatsByFolder(): FolderStats[] {
|
|
|
408
476
|
}
|
|
409
477
|
|
|
410
478
|
/**
|
|
411
|
-
* Get
|
|
479
|
+
* Get time series data.
|
|
412
480
|
*/
|
|
413
|
-
export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
|
|
481
|
+
export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
|
|
414
482
|
if (!db) return [];
|
|
415
483
|
|
|
416
|
-
const
|
|
484
|
+
const hasCutoff = cutoff !== null;
|
|
485
|
+
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
|
|
417
486
|
|
|
418
487
|
const stmt = db.prepare(`
|
|
419
488
|
SELECT
|
|
420
|
-
(timestamp /
|
|
489
|
+
(timestamp / ?) * ? as bucket,
|
|
421
490
|
COUNT(*) as requests,
|
|
422
491
|
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
|
|
423
492
|
SUM(total_tokens) as tokens,
|
|
424
493
|
SUM(cost_total) as cost
|
|
425
494
|
FROM messages
|
|
426
|
-
WHERE timestamp >= ?
|
|
495
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
427
496
|
GROUP BY bucket
|
|
428
497
|
ORDER BY bucket ASC
|
|
429
498
|
`);
|
|
430
499
|
|
|
431
|
-
const rows =
|
|
500
|
+
const rows = hasCutoff
|
|
501
|
+
? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
|
|
502
|
+
: (stmt.all(bucketMs, bucketMs) as any[]);
|
|
432
503
|
return rows.map(row => ({
|
|
433
504
|
timestamp: row.bucket,
|
|
434
505
|
requests: row.requests,
|
|
@@ -444,10 +515,11 @@ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
|
|
|
444
515
|
/**
|
|
445
516
|
* Get daily model usage time series data for the last N days.
|
|
446
517
|
*/
|
|
447
|
-
export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
|
|
518
|
+
export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTimeSeriesPoint[] {
|
|
448
519
|
if (!db) return [];
|
|
449
520
|
|
|
450
|
-
const
|
|
521
|
+
const hasCutoff = cutoff !== null;
|
|
522
|
+
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
451
523
|
|
|
452
524
|
const stmt = db.prepare(`
|
|
453
525
|
SELECT
|
|
@@ -456,12 +528,12 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
|
|
|
456
528
|
provider,
|
|
457
529
|
COUNT(*) as requests
|
|
458
530
|
FROM messages
|
|
459
|
-
WHERE timestamp >= ?
|
|
531
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
460
532
|
GROUP BY bucket, model, provider
|
|
461
533
|
ORDER BY bucket ASC
|
|
462
534
|
`);
|
|
463
535
|
|
|
464
|
-
const rows = stmt.all(
|
|
536
|
+
const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
|
|
465
537
|
return rows.map(row => ({
|
|
466
538
|
timestamp: row.bucket,
|
|
467
539
|
model: row.model,
|
|
@@ -473,10 +545,11 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
|
|
|
473
545
|
/**
|
|
474
546
|
* Get daily model performance time series data for the last N days.
|
|
475
547
|
*/
|
|
476
|
-
export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
|
|
548
|
+
export function getModelPerformanceSeries(days = 14, cutoff?: number | null): ModelPerformancePoint[] {
|
|
477
549
|
if (!db) return [];
|
|
478
550
|
|
|
479
|
-
const
|
|
551
|
+
const hasCutoff = cutoff !== null;
|
|
552
|
+
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
480
553
|
|
|
481
554
|
const stmt = db.prepare(`
|
|
482
555
|
SELECT
|
|
@@ -487,12 +560,12 @@ export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
|
|
|
487
560
|
AVG(ttft) as avg_ttft,
|
|
488
561
|
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
489
562
|
FROM messages
|
|
490
|
-
WHERE timestamp >= ?
|
|
563
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
491
564
|
GROUP BY bucket, model, provider
|
|
492
565
|
ORDER BY bucket ASC
|
|
493
566
|
`);
|
|
494
567
|
|
|
495
|
-
const rows = stmt.all(
|
|
568
|
+
const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
|
|
496
569
|
return rows.map(row => ({
|
|
497
570
|
timestamp: row.bucket,
|
|
498
571
|
model: row.model,
|
|
@@ -586,10 +659,11 @@ export function getMessageById(id: number): MessageStats | null {
|
|
|
586
659
|
/**
|
|
587
660
|
* Get daily cost time series data for the last N days, broken down by model.
|
|
588
661
|
*/
|
|
589
|
-
export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
|
|
662
|
+
export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
|
|
590
663
|
if (!db) return [];
|
|
591
664
|
|
|
592
|
-
const
|
|
665
|
+
const hasCutoff = cutoff !== null;
|
|
666
|
+
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
593
667
|
|
|
594
668
|
const stmt = db.prepare(`
|
|
595
669
|
SELECT
|
|
@@ -603,12 +677,12 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
|
|
|
603
677
|
SUM(cost_cache_write) as cost_cache_write,
|
|
604
678
|
COUNT(*) as requests
|
|
605
679
|
FROM messages
|
|
606
|
-
WHERE timestamp >= ?
|
|
680
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
607
681
|
GROUP BY bucket, model, provider
|
|
608
682
|
ORDER BY bucket ASC
|
|
609
683
|
`);
|
|
610
684
|
|
|
611
|
-
const rows = stmt.all(
|
|
685
|
+
const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
|
|
612
686
|
return rows.map(row => ({
|
|
613
687
|
timestamp: row.bucket,
|
|
614
688
|
model: row.model,
|
|
@@ -621,3 +695,210 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
|
|
|
621
695
|
requests: row.requests,
|
|
622
696
|
}));
|
|
623
697
|
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Reset `file_offsets` (and any existing `user_messages` rows) so the next
|
|
701
|
+
* sync re-parses every session and re-derives behavioral metrics. Run once
|
|
702
|
+
* per metric-definition bump; the meta sentinel records the version.
|
|
703
|
+
*
|
|
704
|
+
* - v1: initial introduction of `user_messages`.
|
|
705
|
+
* - v2: yelling-sentence metric replaces caps-word counts; existing rows are
|
|
706
|
+
* computed under the old definition and must be discarded.
|
|
707
|
+
*
|
|
708
|
+
* Existing `messages` rows are unaffected — `INSERT OR IGNORE` keeps them.
|
|
709
|
+
*/
|
|
710
|
+
function backfillUserMessages(database: Database): void {
|
|
711
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = 'user_messages_v2'").get() as
|
|
712
|
+
| { value: string }
|
|
713
|
+
| undefined;
|
|
714
|
+
if (row) return;
|
|
715
|
+
|
|
716
|
+
database.exec("DELETE FROM user_messages");
|
|
717
|
+
database.exec("DELETE FROM file_offsets");
|
|
718
|
+
database
|
|
719
|
+
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
720
|
+
.run("user_messages_v2", String(Date.now()));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
|
|
725
|
+
*/
|
|
726
|
+
export function insertUserMessageStats(stats: UserMessageStats[]): number {
|
|
727
|
+
if (!db || stats.length === 0) return 0;
|
|
728
|
+
|
|
729
|
+
const stmt = db.prepare(`
|
|
730
|
+
INSERT OR IGNORE INTO user_messages (
|
|
731
|
+
session_file, entry_id, folder, timestamp, model, provider,
|
|
732
|
+
chars, words, yelling_sentences, profanity, drama_runs
|
|
733
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
734
|
+
`);
|
|
735
|
+
|
|
736
|
+
let inserted = 0;
|
|
737
|
+
const insert = db.transaction(() => {
|
|
738
|
+
for (const s of stats) {
|
|
739
|
+
const result = stmt.run(
|
|
740
|
+
s.sessionFile,
|
|
741
|
+
s.entryId,
|
|
742
|
+
s.folder,
|
|
743
|
+
s.timestamp,
|
|
744
|
+
s.model,
|
|
745
|
+
s.provider,
|
|
746
|
+
s.chars,
|
|
747
|
+
s.words,
|
|
748
|
+
s.yellingSentences,
|
|
749
|
+
s.profanity,
|
|
750
|
+
s.dramaRuns,
|
|
751
|
+
);
|
|
752
|
+
if (result.changes > 0) inserted++;
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
insert();
|
|
756
|
+
return inserted;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const UNKNOWN_MODEL = "unknown";
|
|
760
|
+
|
|
761
|
+
interface BehaviorSeriesRow {
|
|
762
|
+
bucket: number;
|
|
763
|
+
model: string;
|
|
764
|
+
provider: string;
|
|
765
|
+
messages: number;
|
|
766
|
+
yelling_sentences: number | null;
|
|
767
|
+
profanity: number | null;
|
|
768
|
+
drama_runs: number | null;
|
|
769
|
+
chars: number | null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Daily behavioral time series, grouped by responding model+provider.
|
|
774
|
+
*/
|
|
775
|
+
export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
|
|
776
|
+
if (!db) return [];
|
|
777
|
+
const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
|
|
778
|
+
const stmt = db.prepare(`
|
|
779
|
+
SELECT
|
|
780
|
+
(timestamp / 86400000) * 86400000 as bucket,
|
|
781
|
+
COALESCE(model, ?) as model,
|
|
782
|
+
COALESCE(provider, ?) as provider,
|
|
783
|
+
COUNT(*) as messages,
|
|
784
|
+
SUM(yelling_sentences) as yelling_sentences,
|
|
785
|
+
SUM(profanity) as profanity,
|
|
786
|
+
SUM(drama_runs) as drama_runs,
|
|
787
|
+
SUM(chars) as chars
|
|
788
|
+
FROM user_messages
|
|
789
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
790
|
+
GROUP BY bucket, model, provider
|
|
791
|
+
ORDER BY bucket ASC
|
|
792
|
+
`);
|
|
793
|
+
const rows = (
|
|
794
|
+
hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
|
|
795
|
+
) as BehaviorSeriesRow[];
|
|
796
|
+
return rows.map(row => ({
|
|
797
|
+
timestamp: row.bucket,
|
|
798
|
+
model: row.model,
|
|
799
|
+
provider: row.provider,
|
|
800
|
+
messages: row.messages,
|
|
801
|
+
yellingSentences: row.yelling_sentences ?? 0,
|
|
802
|
+
profanity: row.profanity ?? 0,
|
|
803
|
+
dramaRuns: row.drama_runs ?? 0,
|
|
804
|
+
chars: row.chars ?? 0,
|
|
805
|
+
}));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
interface BehaviorOverallRow {
|
|
809
|
+
total_messages: number;
|
|
810
|
+
total_yelling_sentences: number | null;
|
|
811
|
+
total_profanity: number | null;
|
|
812
|
+
total_drama_runs: number | null;
|
|
813
|
+
total_chars: number | null;
|
|
814
|
+
first_timestamp: number | null;
|
|
815
|
+
last_timestamp: number | null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Overall behavioral totals across the cutoff window.
|
|
820
|
+
*/
|
|
821
|
+
export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
|
|
822
|
+
const empty: BehaviorOverallStats = {
|
|
823
|
+
totalMessages: 0,
|
|
824
|
+
totalYellingSentences: 0,
|
|
825
|
+
totalProfanity: 0,
|
|
826
|
+
totalDramaRuns: 0,
|
|
827
|
+
totalChars: 0,
|
|
828
|
+
firstTimestamp: 0,
|
|
829
|
+
lastTimestamp: 0,
|
|
830
|
+
};
|
|
831
|
+
if (!db) return empty;
|
|
832
|
+
const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
|
|
833
|
+
const stmt = db.prepare(`
|
|
834
|
+
SELECT
|
|
835
|
+
COUNT(*) as total_messages,
|
|
836
|
+
SUM(yelling_sentences) as total_yelling_sentences,
|
|
837
|
+
SUM(profanity) as total_profanity,
|
|
838
|
+
SUM(drama_runs) as total_drama_runs,
|
|
839
|
+
SUM(chars) as total_chars,
|
|
840
|
+
MIN(timestamp) as first_timestamp,
|
|
841
|
+
MAX(timestamp) as last_timestamp
|
|
842
|
+
FROM user_messages
|
|
843
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
844
|
+
`);
|
|
845
|
+
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
|
|
846
|
+
if (!row?.total_messages) return empty;
|
|
847
|
+
return {
|
|
848
|
+
totalMessages: row.total_messages,
|
|
849
|
+
totalYellingSentences: row.total_yelling_sentences ?? 0,
|
|
850
|
+
totalProfanity: row.total_profanity ?? 0,
|
|
851
|
+
totalDramaRuns: row.total_drama_runs ?? 0,
|
|
852
|
+
totalChars: row.total_chars ?? 0,
|
|
853
|
+
firstTimestamp: row.first_timestamp ?? 0,
|
|
854
|
+
lastTimestamp: row.last_timestamp ?? 0,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
interface BehaviorByModelRow {
|
|
859
|
+
model: string;
|
|
860
|
+
provider: string;
|
|
861
|
+
total_messages: number;
|
|
862
|
+
total_yelling_sentences: number | null;
|
|
863
|
+
total_profanity: number | null;
|
|
864
|
+
total_drama_runs: number | null;
|
|
865
|
+
total_chars: number | null;
|
|
866
|
+
last_timestamp: number | null;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Per-model behavioral totals over the cutoff window. "Unknown" represents
|
|
871
|
+
* user messages that never received an assistant reply.
|
|
872
|
+
*/
|
|
873
|
+
export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
|
|
874
|
+
if (!db) return [];
|
|
875
|
+
const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
|
|
876
|
+
const stmt = db.prepare(`
|
|
877
|
+
SELECT
|
|
878
|
+
COALESCE(model, ?) as model,
|
|
879
|
+
COALESCE(provider, ?) as provider,
|
|
880
|
+
COUNT(*) as total_messages,
|
|
881
|
+
SUM(yelling_sentences) as total_yelling_sentences,
|
|
882
|
+
SUM(profanity) as total_profanity,
|
|
883
|
+
SUM(drama_runs) as total_drama_runs,
|
|
884
|
+
SUM(chars) as total_chars,
|
|
885
|
+
MAX(timestamp) as last_timestamp
|
|
886
|
+
FROM user_messages
|
|
887
|
+
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
888
|
+
GROUP BY model, provider
|
|
889
|
+
ORDER BY total_messages DESC
|
|
890
|
+
`);
|
|
891
|
+
const rows = (
|
|
892
|
+
hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
|
|
893
|
+
) as BehaviorByModelRow[];
|
|
894
|
+
return rows.map(row => ({
|
|
895
|
+
model: row.model,
|
|
896
|
+
provider: row.provider,
|
|
897
|
+
totalMessages: row.total_messages,
|
|
898
|
+
totalYellingSentences: row.total_yelling_sentences ?? 0,
|
|
899
|
+
totalProfanity: row.total_profanity ?? 0,
|
|
900
|
+
totalDramaRuns: row.total_drama_runs ?? 0,
|
|
901
|
+
totalChars: row.total_chars ?? 0,
|
|
902
|
+
lastTimestamp: row.last_timestamp ?? 0,
|
|
903
|
+
}));
|
|
904
|
+
}
|