@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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +88 -88
- package/dist/types/client/i18n/messages/en.d.ts +72 -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/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/i18n/messages/frustration.ts +77 -0
- package/src/client/routes/FrustrationRoute.tsx +439 -4
- package/src/client/routes/frustration.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
package/src/db.ts
CHANGED
|
@@ -15,10 +15,13 @@ import { classifyAgentType, type ParseSessionResult, type SessionParserState } f
|
|
|
15
15
|
import { ensureRollupSchema, getDailyActivityFromRollup } from "./rollup";
|
|
16
16
|
import type {
|
|
17
17
|
AgentType,
|
|
18
|
+
AssistantTextStats,
|
|
18
19
|
DailyActivityPoint,
|
|
19
20
|
FrustrationCounts,
|
|
20
21
|
MessageStats,
|
|
21
22
|
MessageStatsInput,
|
|
23
|
+
ModelSideCounts,
|
|
24
|
+
ModelSideTimeSeriesPoint,
|
|
22
25
|
ToolCallStats,
|
|
23
26
|
ToolResultLink,
|
|
24
27
|
UserMessageLink,
|
|
@@ -89,6 +92,11 @@ export function currentDb(): Database | null {
|
|
|
89
92
|
const BACKFILL_COMPLETE = "complete";
|
|
90
93
|
const BACKFILL_PENDING = "pending";
|
|
91
94
|
const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
|
|
95
|
+
// Model-side analysis (`assistant_text`) was introduced after every existing
|
|
96
|
+
// database had already burned its session backfills, so it needs its own
|
|
97
|
+
// sentinel: a completed user-message backfill must never leave old sessions
|
|
98
|
+
// without assistant prose forever.
|
|
99
|
+
const ASSISTANT_TEXT_BACKFILL_KEY = "assistant_text_v1";
|
|
92
100
|
const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
93
101
|
// v2: the parser also records the served service tier per message, so a full
|
|
94
102
|
// re-parse fills `service_tier` and re-derives ultrafast premium counts that the
|
|
@@ -217,6 +225,38 @@ export async function initDb(): Promise<Database> {
|
|
|
217
225
|
CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
|
|
218
226
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
219
227
|
|
|
228
|
+
-- Model-side analysis: one row per assistant turn that produced prose.
|
|
229
|
+
-- Companion to messages (usage, no text) and keyed like user_messages
|
|
230
|
+
-- so a fork's copied entry is deduplicated the same way. prose is the
|
|
231
|
+
-- structured-content-stripped body capped at PROSE_MAX_CHARS; thinking
|
|
232
|
+
-- blocks never reach this table.
|
|
233
|
+
CREATE TABLE IF NOT EXISTS assistant_text (
|
|
234
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
235
|
+
session_file TEXT NOT NULL,
|
|
236
|
+
entry_id TEXT NOT NULL,
|
|
237
|
+
folder TEXT NOT NULL,
|
|
238
|
+
timestamp INTEGER NOT NULL,
|
|
239
|
+
model TEXT NOT NULL,
|
|
240
|
+
provider TEXT NOT NULL,
|
|
241
|
+
agent_type TEXT NOT NULL DEFAULT 'main',
|
|
242
|
+
chars INTEGER NOT NULL,
|
|
243
|
+
words INTEGER NOT NULL,
|
|
244
|
+
self_blame INTEGER NOT NULL DEFAULT 0,
|
|
245
|
+
restatement INTEGER NOT NULL DEFAULT 0,
|
|
246
|
+
hedge INTEGER NOT NULL DEFAULT 0,
|
|
247
|
+
verbose INTEGER NOT NULL DEFAULT 0,
|
|
248
|
+
tool_errors INTEGER NOT NULL DEFAULT 0,
|
|
249
|
+
tool_spiral INTEGER NOT NULL DEFAULT 0,
|
|
250
|
+
collapse INTEGER NOT NULL DEFAULT 0,
|
|
251
|
+
unsupported_script INTEGER NOT NULL DEFAULT 0,
|
|
252
|
+
prose TEXT NOT NULL,
|
|
253
|
+
UNIQUE(session_file, entry_id)
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_assistant_text_timestamp ON assistant_text(timestamp);
|
|
257
|
+
CREATE INDEX IF NOT EXISTS idx_assistant_text_timestamp_model ON assistant_text(timestamp, model, provider);
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_assistant_text_session ON assistant_text(session_file, timestamp);
|
|
259
|
+
|
|
220
260
|
-- Judge verdicts keyed by prose hash: identical messages share one verdict,
|
|
221
261
|
-- and verdicts outlive user_messages re-parses. No backfill wipes this table.
|
|
222
262
|
CREATE TABLE IF NOT EXISTS frustration_verdicts (
|
|
@@ -369,6 +409,7 @@ export async function initDb(): Promise<Database> {
|
|
|
369
409
|
}
|
|
370
410
|
db.run("CREATE INDEX IF NOT EXISTS idx_user_messages_prose_hash ON user_messages(prose_hash)");
|
|
371
411
|
backfillUserMessages(db);
|
|
412
|
+
backfillAssistantText(db);
|
|
372
413
|
backfillToolCalls(db);
|
|
373
414
|
backfillReingestCosts(db);
|
|
374
415
|
backfillUnpricedCosts(db);
|
|
@@ -734,6 +775,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
734
775
|
"SELECT entry_id, timestamp, model FROM user_messages WHERE session_file = ?",
|
|
735
776
|
)
|
|
736
777
|
.all(sessionFile);
|
|
778
|
+
const texts = database
|
|
779
|
+
.query<{ entry_id: string; timestamp: number }, [string]>(
|
|
780
|
+
"SELECT entry_id, timestamp FROM assistant_text WHERE session_file = ?",
|
|
781
|
+
)
|
|
782
|
+
.all(sessionFile);
|
|
737
783
|
const tools = database
|
|
738
784
|
.query<
|
|
739
785
|
{ entry_id: string; timestamp: number; tool_call_id: string; result_chars: number | null },
|
|
@@ -743,19 +789,23 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
743
789
|
const replace = result.reset || rebuild;
|
|
744
790
|
if (replace) {
|
|
745
791
|
// Only removed owners require another full replay, not an identity-only file replacement.
|
|
746
|
-
if (!reconcile && (messages.length > 0 || users.length > 0 || tools.length > 0)) {
|
|
792
|
+
if (!reconcile && (messages.length > 0 || users.length > 0 || texts.length > 0 || tools.length > 0)) {
|
|
747
793
|
const retainedMessages = new Set(
|
|
748
794
|
result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])),
|
|
749
795
|
);
|
|
750
796
|
const retainedUsers = new Set(
|
|
751
797
|
result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])),
|
|
752
798
|
);
|
|
799
|
+
const retainedTexts = new Set(
|
|
800
|
+
result.assistantTexts.map(row => JSON.stringify([row.entryId, row.timestamp])),
|
|
801
|
+
);
|
|
753
802
|
const retainedTools = new Set(
|
|
754
803
|
result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
|
|
755
804
|
);
|
|
756
805
|
reconcile =
|
|
757
806
|
messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
758
807
|
users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
808
|
+
texts.some(row => !retainedTexts.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
759
809
|
tools.some(
|
|
760
810
|
row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])),
|
|
761
811
|
);
|
|
@@ -765,9 +815,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
765
815
|
// IDs and avoid rewriting every table/index for transcripts whose contents did not change.
|
|
766
816
|
const messageById = new Map(messages.map(row => [row.entry_id, row.timestamp]));
|
|
767
817
|
const userById = new Map(users.map(row => [row.entry_id, row]));
|
|
818
|
+
const textById = new Map(texts.map(row => [row.entry_id, row]));
|
|
768
819
|
const toolById = new Map(tools.map(row => [row.tool_call_id, row]));
|
|
769
820
|
const absentMessages = new Set(messageById.keys());
|
|
770
821
|
const absentUsers = new Set(userById.keys());
|
|
822
|
+
const absentTexts = new Set(textById.keys());
|
|
771
823
|
const absentTools = new Set(toolById.keys());
|
|
772
824
|
const stats = result.stats.filter(row => {
|
|
773
825
|
if (messageById.get(row.entryId) !== row.timestamp) return true;
|
|
@@ -779,6 +831,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
779
831
|
absentUsers.delete(row.entryId);
|
|
780
832
|
return false;
|
|
781
833
|
});
|
|
834
|
+
const assistantTexts = result.assistantTexts.filter(row => {
|
|
835
|
+
if (textById.get(row.entryId)?.timestamp !== row.timestamp) return true;
|
|
836
|
+
absentTexts.delete(row.entryId);
|
|
837
|
+
return false;
|
|
838
|
+
});
|
|
782
839
|
const toolCalls = result.toolCalls.filter(row => {
|
|
783
840
|
const stored = toolById.get(row.toolCallId);
|
|
784
841
|
if (stored?.entry_id !== row.entryId || stored.timestamp !== row.timestamp) return true;
|
|
@@ -789,6 +846,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
789
846
|
...result,
|
|
790
847
|
stats,
|
|
791
848
|
userStats,
|
|
849
|
+
assistantTexts,
|
|
792
850
|
toolCalls,
|
|
793
851
|
// A reused ID needs fresh linkage after its old identity is removed.
|
|
794
852
|
userLinks: result.userLinks.filter(
|
|
@@ -799,7 +857,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
799
857
|
),
|
|
800
858
|
};
|
|
801
859
|
// Repair stale owners without invalidating IDs of records still present in the transcript.
|
|
802
|
-
if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
|
|
860
|
+
if (absentMessages.size > 0 || absentUsers.size > 0 || absentTexts.size > 0 || absentTools.size > 0) {
|
|
803
861
|
reconcile = true;
|
|
804
862
|
for (const id of absentMessages) {
|
|
805
863
|
changes += database
|
|
@@ -811,6 +869,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
811
869
|
.query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
|
|
812
870
|
.run(sessionFile, id).changes;
|
|
813
871
|
}
|
|
872
|
+
for (const id of absentTexts) {
|
|
873
|
+
changes += database
|
|
874
|
+
.query("DELETE FROM assistant_text WHERE session_file = ? AND entry_id = ?")
|
|
875
|
+
.run(sessionFile, id).changes;
|
|
876
|
+
}
|
|
814
877
|
for (const id of absentTools) {
|
|
815
878
|
changes += database
|
|
816
879
|
.query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
|
|
@@ -827,12 +890,19 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
827
890
|
.query("DELETE FROM user_messages WHERE session_file = ?")
|
|
828
891
|
.run(sessionFile).changes;
|
|
829
892
|
}
|
|
893
|
+
if (texts.length > 0) {
|
|
894
|
+
changes += database
|
|
895
|
+
.query("DELETE FROM assistant_text WHERE session_file = ?")
|
|
896
|
+
.run(sessionFile).changes;
|
|
897
|
+
}
|
|
830
898
|
if (tools.length > 0) {
|
|
831
899
|
changes += database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile).changes;
|
|
832
900
|
}
|
|
833
901
|
}
|
|
834
902
|
}
|
|
835
903
|
writes.push(rows);
|
|
904
|
+
// `processed` keeps its historical meaning (usage + user rows); the
|
|
905
|
+
// model-side rows are visible through `changes` and batch sizing.
|
|
836
906
|
const count = rows.stats.length + rows.userStats.length;
|
|
837
907
|
processed += count;
|
|
838
908
|
if (count > 0) files++;
|
|
@@ -841,6 +911,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
841
911
|
changes +=
|
|
842
912
|
insertMessageStats(parsedRows(writes, result => result.stats)) +
|
|
843
913
|
insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
|
|
914
|
+
insertAssistantText(parsedRows(writes, result => result.assistantTexts)) +
|
|
844
915
|
updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
|
|
845
916
|
insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
|
|
846
917
|
updateToolResults(parsedRows(writes, result => result.toolResults));
|
|
@@ -1148,6 +1219,31 @@ function backfillUserMessages(database: Database): void {
|
|
|
1148
1219
|
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1149
1220
|
}
|
|
1150
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* One-shot wipe of `assistant_text` + `file_offsets` when the model-side
|
|
1224
|
+
* analysis table is introduced (or its signal definitions bump), so the next
|
|
1225
|
+
* sync re-parses every session and ingests historical assistant text.
|
|
1226
|
+
*
|
|
1227
|
+
* Bounded by construction: the wipe runs exactly once per schema version
|
|
1228
|
+
* (`INSERT OR REPLACE` writes PENDING before the sync starts, and
|
|
1229
|
+
* {@link markSessionBackfillsComplete} writes COMPLETE after it), and the
|
|
1230
|
+
* re-parse reads each transcript once, in the existing worker pool, at the
|
|
1231
|
+
* same cost as any other full ingest. The only lasting cost is the stored
|
|
1232
|
+
* prose itself - one capped row per prose-bearing assistant turn.
|
|
1233
|
+
*/
|
|
1234
|
+
function backfillAssistantText(database: Database): void {
|
|
1235
|
+
const row = database
|
|
1236
|
+
.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?")
|
|
1237
|
+
.get(ASSISTANT_TEXT_BACKFILL_KEY);
|
|
1238
|
+
if (!shouldResetBackfill(row?.value)) return;
|
|
1239
|
+
|
|
1240
|
+
database.run("DELETE FROM assistant_text");
|
|
1241
|
+
database.run("DELETE FROM file_offsets");
|
|
1242
|
+
database
|
|
1243
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1244
|
+
.run(ASSISTANT_TEXT_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1151
1247
|
/**
|
|
1152
1248
|
* One-shot wipe of `tool_calls` + `file_offsets` when the `tool_calls` table
|
|
1153
1249
|
* is introduced (or its schema version bumps), so the next sync re-parses
|
|
@@ -1335,6 +1431,7 @@ export function markSessionBackfillsComplete(): void {
|
|
|
1335
1431
|
const apply = db.transaction(() => {
|
|
1336
1432
|
for (const key of [
|
|
1337
1433
|
USER_MESSAGES_BACKFILL_KEY,
|
|
1434
|
+
ASSISTANT_TEXT_BACKFILL_KEY,
|
|
1338
1435
|
TOOL_CALLS_BACKFILL_KEY,
|
|
1339
1436
|
USER_MESSAGE_LINKS_REPAIR_KEY,
|
|
1340
1437
|
PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
|
|
@@ -1405,6 +1502,63 @@ export function insertUserMessageStats(stats: Iterable<UserMessageStats>): numbe
|
|
|
1405
1502
|
return inserted;
|
|
1406
1503
|
}
|
|
1407
1504
|
|
|
1505
|
+
/**
|
|
1506
|
+
* Insert model-side assistant turns. Idempotent via UNIQUE(session_file,
|
|
1507
|
+
* entry_id); the `WHERE NOT EXISTS` guard mirrors {@link insertUserMessageStats}
|
|
1508
|
+
* because a fork deep-copies assistant entries into the child transcript under
|
|
1509
|
+
* the same entry id and timestamp.
|
|
1510
|
+
*/
|
|
1511
|
+
export function insertAssistantText(stats: Iterable<AssistantTextStats>): number {
|
|
1512
|
+
if (!db) return 0;
|
|
1513
|
+
|
|
1514
|
+
const stmt = db.query(`
|
|
1515
|
+
INSERT OR IGNORE INTO assistant_text (
|
|
1516
|
+
session_file, entry_id, folder, timestamp, model, provider, agent_type,
|
|
1517
|
+
chars, words, self_blame, restatement, hedge, verbose,
|
|
1518
|
+
tool_errors, tool_spiral, collapse, unsupported_script, prose
|
|
1519
|
+
)
|
|
1520
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1521
|
+
WHERE NOT EXISTS (
|
|
1522
|
+
SELECT 1 FROM assistant_text
|
|
1523
|
+
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
|
|
1524
|
+
)
|
|
1525
|
+
`);
|
|
1526
|
+
|
|
1527
|
+
let inserted = 0;
|
|
1528
|
+
const insert = db.transaction(() => {
|
|
1529
|
+
for (const s of stats) {
|
|
1530
|
+
const result = stmt.run(
|
|
1531
|
+
s.sessionFile,
|
|
1532
|
+
s.entryId,
|
|
1533
|
+
s.folder,
|
|
1534
|
+
s.timestamp,
|
|
1535
|
+
s.model,
|
|
1536
|
+
s.provider,
|
|
1537
|
+
s.agentType,
|
|
1538
|
+
s.chars,
|
|
1539
|
+
s.words,
|
|
1540
|
+
s.selfBlame,
|
|
1541
|
+
s.restatement,
|
|
1542
|
+
s.hedge,
|
|
1543
|
+
s.verbose,
|
|
1544
|
+
s.toolErrors,
|
|
1545
|
+
s.toolSpiral,
|
|
1546
|
+
s.collapse,
|
|
1547
|
+
s.unsupportedScript,
|
|
1548
|
+
s.prose,
|
|
1549
|
+
// `WHERE NOT EXISTS` binds: skip when a different session_file
|
|
1550
|
+
// already holds this (entry_id, timestamp).
|
|
1551
|
+
s.entryId,
|
|
1552
|
+
s.timestamp,
|
|
1553
|
+
s.sessionFile,
|
|
1554
|
+
);
|
|
1555
|
+
if (result.changes > 0) inserted++;
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
insert();
|
|
1559
|
+
return inserted;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1408
1562
|
/**
|
|
1409
1563
|
* Backfill the responding `model`/`provider` on user-message rows that were
|
|
1410
1564
|
* persisted before their assistant reply was parsed by an incremental tail
|
|
@@ -1547,6 +1701,171 @@ export function getFrustrationByModel(cutoff?: number | null): FrustrationModelR
|
|
|
1547
1701
|
}));
|
|
1548
1702
|
}
|
|
1549
1703
|
|
|
1704
|
+
// ---------------------------------------------------------------------------
|
|
1705
|
+
// Model side (AI breakdown)
|
|
1706
|
+
// ---------------------------------------------------------------------------
|
|
1707
|
+
|
|
1708
|
+
interface ModelSideCountsRow {
|
|
1709
|
+
messages: number;
|
|
1710
|
+
blamed: number;
|
|
1711
|
+
restated: number;
|
|
1712
|
+
hedged: number;
|
|
1713
|
+
verbose: number;
|
|
1714
|
+
spiraled: number;
|
|
1715
|
+
breakdown: number;
|
|
1716
|
+
collapse: number;
|
|
1717
|
+
words: number;
|
|
1718
|
+
tool_errors: number;
|
|
1719
|
+
unscored: number;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** Model-side tallies of one responding (model, provider) pair, before identity merging. */
|
|
1723
|
+
export interface ModelSideModelRow extends ModelSideCounts {
|
|
1724
|
+
model: string;
|
|
1725
|
+
provider: string;
|
|
1726
|
+
/** Earliest analyzed turn timestamp (ms) in range. */
|
|
1727
|
+
firstSeen: number;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** Model-side tallies of one session, before the dashboard's top-N cut. */
|
|
1731
|
+
export interface ModelSideSessionRow extends ModelSideCounts {
|
|
1732
|
+
sessionFile: string;
|
|
1733
|
+
folder: string;
|
|
1734
|
+
models: string[];
|
|
1735
|
+
firstSeen: number;
|
|
1736
|
+
lastSeen: number;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Shares are per analyzed turn. `collapse`, `words` and `tool_errors` are
|
|
1740
|
+
// additive sums; the UI divides them by `messages` for means.
|
|
1741
|
+
const MODEL_SIDE_COUNTS_SQL = `
|
|
1742
|
+
COUNT(*) AS messages,
|
|
1743
|
+
COALESCE(SUM(CASE WHEN a.self_blame > 0 THEN 1 ELSE 0 END), 0) AS blamed,
|
|
1744
|
+
COALESCE(SUM(CASE WHEN a.restatement > 0 THEN 1 ELSE 0 END), 0) AS restated,
|
|
1745
|
+
COALESCE(SUM(CASE WHEN a.hedge > 0 THEN 1 ELSE 0 END), 0) AS hedged,
|
|
1746
|
+
COALESCE(SUM(CASE WHEN a.verbose > 0 THEN 1 ELSE 0 END), 0) AS verbose,
|
|
1747
|
+
COALESCE(SUM(CASE WHEN a.tool_spiral > 0 THEN 1 ELSE 0 END), 0) AS spiraled,
|
|
1748
|
+
COALESCE(SUM(CASE WHEN a.collapse > 0 THEN 1 ELSE 0 END), 0) AS breakdown,
|
|
1749
|
+
COALESCE(SUM(a.collapse), 0) AS collapse,
|
|
1750
|
+
COALESCE(SUM(a.words), 0) AS words,
|
|
1751
|
+
COALESCE(SUM(a.tool_errors), 0) AS tool_errors,
|
|
1752
|
+
COALESCE(SUM(CASE WHEN a.unsupported_script = 1 THEN 1 ELSE 0 END), 0) AS unscored
|
|
1753
|
+
`;
|
|
1754
|
+
|
|
1755
|
+
function toModelSideCounts(row: ModelSideCountsRow | undefined): ModelSideCounts {
|
|
1756
|
+
return {
|
|
1757
|
+
messages: row?.messages ?? 0,
|
|
1758
|
+
blamed: row?.blamed ?? 0,
|
|
1759
|
+
restated: row?.restated ?? 0,
|
|
1760
|
+
hedged: row?.hedged ?? 0,
|
|
1761
|
+
verbose: row?.verbose ?? 0,
|
|
1762
|
+
spiraled: row?.spiraled ?? 0,
|
|
1763
|
+
breakdown: row?.breakdown ?? 0,
|
|
1764
|
+
collapse: row?.collapse ?? 0,
|
|
1765
|
+
words: row?.words ?? 0,
|
|
1766
|
+
toolErrors: row?.tool_errors ?? 0,
|
|
1767
|
+
unscored: row?.unscored ?? 0,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Model-side tallies over every analyzed assistant turn in range. */
|
|
1772
|
+
export function getModelSideOverall(cutoff?: number | null): ModelSideCounts {
|
|
1773
|
+
if (!db) return toModelSideCounts(undefined);
|
|
1774
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1775
|
+
const stmt = db.query(
|
|
1776
|
+
`SELECT ${MODEL_SIDE_COUNTS_SQL} FROM assistant_text a${hasCutoff ? " WHERE a.timestamp >= ?" : ""}`,
|
|
1777
|
+
);
|
|
1778
|
+
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as ModelSideCountsRow | undefined;
|
|
1779
|
+
return toModelSideCounts(row);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** Model-side tallies per responding (model, provider) over the range. */
|
|
1783
|
+
export function getModelSideByModel(cutoff?: number | null): ModelSideModelRow[] {
|
|
1784
|
+
if (!db) return [];
|
|
1785
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1786
|
+
const stmt = db.query(`
|
|
1787
|
+
SELECT a.model AS model, a.provider AS provider, MIN(a.timestamp) AS first_seen, ${MODEL_SIDE_COUNTS_SQL}
|
|
1788
|
+
FROM assistant_text a
|
|
1789
|
+
${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
|
|
1790
|
+
GROUP BY a.model, a.provider
|
|
1791
|
+
`);
|
|
1792
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as (ModelSideCountsRow & {
|
|
1793
|
+
model: string;
|
|
1794
|
+
provider: string;
|
|
1795
|
+
first_seen: number;
|
|
1796
|
+
})[];
|
|
1797
|
+
return rows.map(row => ({
|
|
1798
|
+
model: row.model,
|
|
1799
|
+
provider: row.provider,
|
|
1800
|
+
firstSeen: row.first_seen,
|
|
1801
|
+
...toModelSideCounts(row),
|
|
1802
|
+
}));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/** Model-side tallies per session, busiest first. */
|
|
1806
|
+
export function getModelSideBySession(cutoff?: number | null, limit = 40): ModelSideSessionRow[] {
|
|
1807
|
+
if (!db) return [];
|
|
1808
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1809
|
+
const stmt = db.query(`
|
|
1810
|
+
SELECT a.session_file AS session_file, a.folder AS folder, GROUP_CONCAT(DISTINCT a.model) AS models,
|
|
1811
|
+
MIN(a.timestamp) AS first_seen, MAX(a.timestamp) AS last_seen, ${MODEL_SIDE_COUNTS_SQL}
|
|
1812
|
+
FROM assistant_text a
|
|
1813
|
+
${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
|
|
1814
|
+
GROUP BY a.session_file, a.folder
|
|
1815
|
+
ORDER BY messages DESC, last_seen DESC
|
|
1816
|
+
LIMIT ?
|
|
1817
|
+
`);
|
|
1818
|
+
const rows = (hasCutoff ? stmt.all(cutoff, limit) : stmt.all(limit)) as (ModelSideCountsRow & {
|
|
1819
|
+
session_file: string;
|
|
1820
|
+
folder: string;
|
|
1821
|
+
models: string | null;
|
|
1822
|
+
first_seen: number;
|
|
1823
|
+
last_seen: number;
|
|
1824
|
+
})[];
|
|
1825
|
+
return rows.map(row => ({
|
|
1826
|
+
sessionFile: row.session_file,
|
|
1827
|
+
folder: row.folder,
|
|
1828
|
+
models: (row.models ?? "").split(",").filter(Boolean),
|
|
1829
|
+
firstSeen: row.first_seen,
|
|
1830
|
+
lastSeen: row.last_seen,
|
|
1831
|
+
...toModelSideCounts(row),
|
|
1832
|
+
}));
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Model-side tallies per time bucket, ascending. */
|
|
1836
|
+
export function getModelSideTimeSeries({
|
|
1837
|
+
cutoff,
|
|
1838
|
+
bucketMs,
|
|
1839
|
+
}: {
|
|
1840
|
+
cutoff?: number | null;
|
|
1841
|
+
bucketMs: number;
|
|
1842
|
+
}): ModelSideTimeSeriesPoint[] {
|
|
1843
|
+
if (!db) return [];
|
|
1844
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1845
|
+
const stmt = db.query(`
|
|
1846
|
+
SELECT (a.timestamp / ?) * ? AS bucket, ${MODEL_SIDE_COUNTS_SQL}
|
|
1847
|
+
FROM assistant_text a
|
|
1848
|
+
${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
|
|
1849
|
+
GROUP BY 1
|
|
1850
|
+
ORDER BY 1
|
|
1851
|
+
`);
|
|
1852
|
+
const rows = (
|
|
1853
|
+
hasCutoff ? stmt.all(bucketMs, bucketMs, cutoff) : stmt.all(bucketMs, bucketMs)
|
|
1854
|
+
) as (ModelSideCountsRow & {
|
|
1855
|
+
bucket: number;
|
|
1856
|
+
})[];
|
|
1857
|
+
return rows.map(row => ({
|
|
1858
|
+
timestamp: row.bucket,
|
|
1859
|
+
messages: row.messages,
|
|
1860
|
+
blamed: row.blamed,
|
|
1861
|
+
restated: row.restated,
|
|
1862
|
+
hedged: row.hedged,
|
|
1863
|
+
spiraled: row.spiraled,
|
|
1864
|
+
breakdown: row.breakdown,
|
|
1865
|
+
collapse: row.collapse,
|
|
1866
|
+
}));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1550
1869
|
/** Unique unjudged prose in range, one row per prose hash. */
|
|
1551
1870
|
function pendingProseSql(hasCutoff: boolean): string {
|
|
1552
1871
|
return `
|
package/src/frustration.ts
CHANGED
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
* `omp-stats` has none and only shows the regex fallback.
|
|
18
18
|
*/
|
|
19
19
|
import { type Judge, judgeQuestions, type Model } from "@linxiraos/pi-ai";
|
|
20
|
-
import { compareRevision, parseRevision } from "@linxiraos/pi-catalog/compat/revision";
|
|
21
|
-
import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
|
|
22
20
|
import { getEffectiveTokenCost } from "@linxiraos/pi-catalog/models";
|
|
23
21
|
import { logger } from "@linxiraos/pi-utils";
|
|
24
22
|
import { getTimeRangeConfig } from "./aggregator";
|
|
@@ -33,6 +31,8 @@ import {
|
|
|
33
31
|
type PendingProse,
|
|
34
32
|
upsertFrustrationVerdicts,
|
|
35
33
|
} from "./db";
|
|
34
|
+
import { compareModelIdentity, modelIdentityOf } from "./model-identity";
|
|
35
|
+
import { getModelSideDashboardStats } from "./model-side";
|
|
36
36
|
import questionFile from "./prompts/judge/frustration.json" with { type: "json" };
|
|
37
37
|
import type {
|
|
38
38
|
FrustrationDashboardStats,
|
|
@@ -136,11 +136,8 @@ export function getFrustrationJobStatus(): FrustrationJobStatus {
|
|
|
136
136
|
export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): FrustrationModelStats[] {
|
|
137
137
|
const merged = new Map<string, FrustrationModelStats>();
|
|
138
138
|
for (const row of rows) {
|
|
139
|
-
const identity =
|
|
140
|
-
const
|
|
141
|
-
const family = identity.family ?? null;
|
|
142
|
-
const key = classified ? `${identity.class}/${family ?? ""}/${identity.revision}` : row.model;
|
|
143
|
-
const existing = merged.get(key);
|
|
139
|
+
const identity = modelIdentityOf(row.provider, row.model);
|
|
140
|
+
const existing = merged.get(identity.key);
|
|
144
141
|
if (existing) {
|
|
145
142
|
existing.messages += row.messages;
|
|
146
143
|
existing.judged += row.judged;
|
|
@@ -152,15 +149,8 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
|
|
|
152
149
|
if (!existing.models.includes(row.model)) existing.models.push(row.model);
|
|
153
150
|
continue;
|
|
154
151
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const shortRevision = revision?.replace(/(?:\.0)+$/, "");
|
|
158
|
-
merged.set(key, {
|
|
159
|
-
key,
|
|
160
|
-
label: shortRevision ? [family, shortRevision].filter(Boolean).join(" ") : row.model,
|
|
161
|
-
modelClass: identity.class,
|
|
162
|
-
family,
|
|
163
|
-
revision,
|
|
152
|
+
merged.set(identity.key, {
|
|
153
|
+
...identity,
|
|
164
154
|
models: [row.model],
|
|
165
155
|
firstSeen: row.firstSeen,
|
|
166
156
|
messages: row.messages,
|
|
@@ -171,23 +161,7 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
|
|
|
171
161
|
angry: row.angry,
|
|
172
162
|
});
|
|
173
163
|
}
|
|
174
|
-
return [...merged.values()].sort(
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function compareModelStats(a: FrustrationModelStats, b: FrustrationModelStats): number {
|
|
178
|
-
const aRevision = a.revision ? parseRevision(a.revision) : undefined;
|
|
179
|
-
const bRevision = b.revision ? parseRevision(b.revision) : undefined;
|
|
180
|
-
if (!aRevision || !bRevision) {
|
|
181
|
-
if (aRevision) return -1;
|
|
182
|
-
if (bRevision) return 1;
|
|
183
|
-
return a.firstSeen - b.firstSeen;
|
|
184
|
-
}
|
|
185
|
-
if (a.modelClass !== b.modelClass) return a.modelClass < b.modelClass ? -1 : 1;
|
|
186
|
-
const byRevision = compareRevision(aRevision, bRevision);
|
|
187
|
-
if (byRevision !== 0) return byRevision;
|
|
188
|
-
const aFamily = a.family ?? "";
|
|
189
|
-
const bFamily = b.family ?? "";
|
|
190
|
-
return aFamily < bFamily ? -1 : aFamily > bFamily ? 1 : 0;
|
|
164
|
+
return [...merged.values()].sort(compareModelIdentity);
|
|
191
165
|
}
|
|
192
166
|
|
|
193
167
|
/** Payload of `GET /api/stats/frustration`. */
|
|
@@ -197,6 +171,7 @@ export async function getFrustrationDashboardStats(range?: string | null): Promi
|
|
|
197
171
|
return {
|
|
198
172
|
overall: getFrustrationOverall(cutoff),
|
|
199
173
|
byModel: mergeFrustrationRows(getFrustrationByModel(cutoff)),
|
|
174
|
+
modelSide: await getModelSideDashboardStats(range),
|
|
200
175
|
judgeAvailable: judgeProvider !== undefined,
|
|
201
176
|
job: getFrustrationJobStatus(),
|
|
202
177
|
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog identity for a dashboard model row: merges provider and spelling
|
|
3
|
+
* variants (`claude-opus-4.6`, `anthropic/claude-opus-4-6`) into one display
|
|
4
|
+
* row, shared by the user-side frustration table and the model-side breakdown
|
|
5
|
+
* table so both group and order identically.
|
|
6
|
+
*/
|
|
7
|
+
import { compareRevision, parseRevision } from "@linxiraos/pi-catalog/compat/revision";
|
|
8
|
+
import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
|
|
9
|
+
|
|
10
|
+
export interface ModelIdentity {
|
|
11
|
+
/** Stable key: `class/family/revision` when classified, else the raw model id. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** Display label, e.g. `opus 4.5`; the raw model id when unclassified. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Catalog identity class, e.g. `anthropic`; `unknown` when unclassified. */
|
|
16
|
+
modelClass: string;
|
|
17
|
+
/** Catalog family within the class, e.g. `opus`. */
|
|
18
|
+
family: string | null;
|
|
19
|
+
/** Canonical `major.minor.patch`, e.g. `4.5.0`; null when unclassified. */
|
|
20
|
+
revision: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve the display identity of one persisted (provider, model) pair. */
|
|
24
|
+
export function modelIdentityOf(provider: string | null, model: string): ModelIdentity {
|
|
25
|
+
const identity = classifyModel(provider ?? "", model, { lenient: true });
|
|
26
|
+
const classified = identity.class !== "unknown" && identity.revision !== undefined;
|
|
27
|
+
const family = identity.family ?? null;
|
|
28
|
+
const revision = classified ? (identity.revision ?? null) : null;
|
|
29
|
+
// `4.5.0` → `4.5`, `5.0.0` → `5`.
|
|
30
|
+
const shortRevision = revision?.replace(/(?:\.0)+$/, "");
|
|
31
|
+
return {
|
|
32
|
+
key: classified ? `${identity.class}/${family ?? ""}/${identity.revision}` : model,
|
|
33
|
+
label: shortRevision ? [family, shortRevision].filter(Boolean).join(" ") : model,
|
|
34
|
+
modelClass: identity.class,
|
|
35
|
+
family,
|
|
36
|
+
revision,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sort order shared by every model table: classified rows by class, then
|
|
42
|
+
* revision ascending, then family; unclassified rows follow by first use.
|
|
43
|
+
*/
|
|
44
|
+
export function compareModelIdentity(
|
|
45
|
+
a: { modelClass: string; family: string | null; revision: string | null; firstSeen: number },
|
|
46
|
+
b: { modelClass: string; family: string | null; revision: string | null; firstSeen: number },
|
|
47
|
+
): number {
|
|
48
|
+
const aRevision = a.revision ? parseRevision(a.revision) : undefined;
|
|
49
|
+
const bRevision = b.revision ? parseRevision(b.revision) : undefined;
|
|
50
|
+
if (!aRevision || !bRevision) {
|
|
51
|
+
if (aRevision) return -1;
|
|
52
|
+
if (bRevision) return 1;
|
|
53
|
+
return a.firstSeen - b.firstSeen;
|
|
54
|
+
}
|
|
55
|
+
if (a.modelClass !== b.modelClass) return a.modelClass < b.modelClass ? -1 : 1;
|
|
56
|
+
const byRevision = compareRevision(aRevision, bRevision);
|
|
57
|
+
if (byRevision !== 0) return byRevision;
|
|
58
|
+
const aFamily = a.family ?? "";
|
|
59
|
+
const bFamily = b.family ?? "";
|
|
60
|
+
return aFamily < bFamily ? -1 : aFamily > bFamily ? 1 : 0;
|
|
61
|
+
}
|