agentboss 0.1.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/README.md +34 -0
- package/bin/aboss.js +288 -0
- package/client/dist/assets/index-C1wFD_Vo.css +1 -0
- package/client/dist/assets/index-DBj1Ujlx.js +137 -0
- package/client/dist/index.html +34 -0
- package/package.json +64 -0
- package/server/analysis/daily-aggregator.js +258 -0
- package/server/analysis/difficulty.js +129 -0
- package/server/analysis/dimensions/ai-knowledge.js +172 -0
- package/server/analysis/dimensions/ai-tools.js +161 -0
- package/server/analysis/dimensions/judgement.js +107 -0
- package/server/analysis/dimensions/llm-merge.js +57 -0
- package/server/analysis/dimensions/output-quality.js +167 -0
- package/server/analysis/dimensions/problem-definition.js +104 -0
- package/server/analysis/dimensions/system-thinking.js +225 -0
- package/server/analysis/evidence-builder.js +104 -0
- package/server/analysis/job.js +273 -0
- package/server/analysis/report-builder.js +581 -0
- package/server/analysis/scoring-v2.js +72 -0
- package/server/analysis/text-signals.js +179 -0
- package/server/analysis/thresholds-v2.js +358 -0
- package/server/api/advice.js +124 -0
- package/server/api/analysis.js +141 -0
- package/server/api/execution.js +330 -0
- package/server/api/metrics.js +277 -0
- package/server/api/overview.js +308 -0
- package/server/api/project.js +255 -0
- package/server/api/reports.js +125 -0
- package/server/api/sessions.js +118 -0
- package/server/api/settings.js +119 -0
- package/server/db/connection.js +175 -0
- package/server/db/queries.js +1051 -0
- package/server/db/schema.js +487 -0
- package/server/etl/active-time.js +150 -0
- package/server/etl/backfill-subagents.js +178 -0
- package/server/etl/claude-code.js +826 -0
- package/server/etl/detect.js +341 -0
- package/server/etl/judge-filter.js +117 -0
- package/server/etl/opencode.js +606 -0
- package/server/execution/job.js +662 -0
- package/server/execution/prompt.js +227 -0
- package/server/execution/runner.js +218 -0
- package/server/index.js +94 -0
- package/server/llm/advice-prompt.js +339 -0
- package/server/llm/advice.js +384 -0
- package/server/llm/analysis-prompt.js +162 -0
- package/server/llm/cli-runner.js +249 -0
- package/server/llm/judge-prompts.js +179 -0
- package/server/llm/judge.js +118 -0
- package/server/llm/project-advice-prompt.js +332 -0
- package/server/llm/project-advice.js +491 -0
- package/server/llm/session-analyzer.js +122 -0
- package/server/utils/project.js +80 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* H3 — System Thinking (rolling-window aggregate, NOT per-session).
|
|
3
|
+
*
|
|
4
|
+
* Captures whether the operator's prompting style gets more consistent,
|
|
5
|
+
* more abstract, and less repetitive over time.
|
|
6
|
+
* • consistency — Jaccard similarity of user-prompt token sets across
|
|
7
|
+
* same-project sessions
|
|
8
|
+
* • dedup — share of sessions whose first prompt is highly
|
|
9
|
+
* similar (>=0.6 Jaccard) to a previous one
|
|
10
|
+
* • refactor — refactor-vocabulary occurrences normalised per 100 sessions
|
|
11
|
+
* • abstraction — abstraction-vocabulary token share in user messages
|
|
12
|
+
*
|
|
13
|
+
* Result is keyed by (period, end_date, window_days) and stored in
|
|
14
|
+
* capability_rollup_v2. Caller decides the window (7d weekly, 30d
|
|
15
|
+
* monthly).
|
|
16
|
+
*
|
|
17
|
+
* See spec §4.3.
|
|
18
|
+
*
|
|
19
|
+
* @author Felix
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { queryAll } = require('../../db/queries');
|
|
25
|
+
const {
|
|
26
|
+
matchesAny,
|
|
27
|
+
termOccurrences,
|
|
28
|
+
REFACTOR_PATTERNS,
|
|
29
|
+
ABSTRACTION_TERMS,
|
|
30
|
+
} = require('../text-signals');
|
|
31
|
+
const { evalIndicator, rollupDimension, scoreToLevel, H3 } = require('../thresholds-v2');
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const STOP = new Set([
|
|
38
|
+
'the','a','an','is','are','to','of','in','on','for','and','or','but','if','it',
|
|
39
|
+
'我','你','他','她','它','的','了','在','是','和','与','也','就','都','或',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Tokenise a string into a Set of lowercased word-like tokens. */
|
|
43
|
+
function tokenSet(text) {
|
|
44
|
+
if (!text) return new Set();
|
|
45
|
+
const tokens = String(text)
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.split(/[^\p{L}\p{N}_]+/u)
|
|
48
|
+
.filter((t) => t && t.length >= 2 && !STOP.has(t));
|
|
49
|
+
return new Set(tokens);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function jaccard(a, b) {
|
|
53
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
54
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
55
|
+
let inter = 0;
|
|
56
|
+
for (const t of a) if (b.has(t)) inter++;
|
|
57
|
+
const uni = a.size + b.size - inter;
|
|
58
|
+
return uni === 0 ? 0 : inter / uni;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Aggregator
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} db
|
|
67
|
+
* @param {string} fromDate YYYY-MM-DD inclusive
|
|
68
|
+
* @param {string} toDate YYYY-MM-DD inclusive
|
|
69
|
+
* @returns {{
|
|
70
|
+
* subScores: object,
|
|
71
|
+
* subLevels: object,
|
|
72
|
+
* raw: object,
|
|
73
|
+
* score: number|null,
|
|
74
|
+
* level: number|null,
|
|
75
|
+
* sessionCount: number
|
|
76
|
+
* }}
|
|
77
|
+
*/
|
|
78
|
+
function analyzeRange(db, fromDate, toDate) {
|
|
79
|
+
// Pull all sessions in window
|
|
80
|
+
const sessions = queryAll(
|
|
81
|
+
db,
|
|
82
|
+
`SELECT id, project, title, date
|
|
83
|
+
FROM unified_session
|
|
84
|
+
WHERE date >= ? AND date <= ?
|
|
85
|
+
ORDER BY started_at ASC`,
|
|
86
|
+
[fromDate, toDate]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (sessions.length === 0) {
|
|
90
|
+
return emptyResult(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pull all user messages in window (one query is cheaper than N)
|
|
94
|
+
const userMsgs = queryAll(
|
|
95
|
+
db,
|
|
96
|
+
`SELECT m.session_id, m.timestamp, m.text
|
|
97
|
+
FROM unified_message m
|
|
98
|
+
JOIN unified_session s ON s.id = m.session_id
|
|
99
|
+
WHERE m.role = 'user'
|
|
100
|
+
AND s.date >= ? AND s.date <= ?
|
|
101
|
+
AND m.text IS NOT NULL
|
|
102
|
+
ORDER BY m.timestamp ASC`,
|
|
103
|
+
[fromDate, toDate]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Group user messages by session
|
|
107
|
+
const bySession = Object.create(null);
|
|
108
|
+
for (const m of userMsgs) {
|
|
109
|
+
(bySession[m.session_id] || (bySession[m.session_id] = [])).push(m);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- consistency: average pairwise Jaccard between session FIRST user msgs ----
|
|
113
|
+
const firstPrompts = sessions.map((s) => {
|
|
114
|
+
const msgs = bySession[s.id] || [];
|
|
115
|
+
return { session: s, text: msgs[0]?.text || '', tokens: tokenSet(msgs[0]?.text) };
|
|
116
|
+
}).filter((p) => p.tokens.size > 0);
|
|
117
|
+
|
|
118
|
+
let consistency = null;
|
|
119
|
+
if (firstPrompts.length >= 2) {
|
|
120
|
+
// Pairwise within the same project; if no project repeats, fall back
|
|
121
|
+
// to global pairwise so we still produce a number for solo projects.
|
|
122
|
+
const byProj = Object.create(null);
|
|
123
|
+
for (const p of firstPrompts) {
|
|
124
|
+
const k = p.session.project || '__none__';
|
|
125
|
+
(byProj[k] || (byProj[k] = [])).push(p);
|
|
126
|
+
}
|
|
127
|
+
const sims = [];
|
|
128
|
+
for (const group of Object.values(byProj)) {
|
|
129
|
+
if (group.length < 2) continue;
|
|
130
|
+
for (let i = 0; i < group.length; i++) {
|
|
131
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
132
|
+
sims.push(jaccard(group[i].tokens, group[j].tokens));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (sims.length === 0) {
|
|
137
|
+
// global fallback
|
|
138
|
+
for (let i = 0; i < firstPrompts.length; i++) {
|
|
139
|
+
for (let j = i + 1; j < firstPrompts.length; j++) {
|
|
140
|
+
sims.push(jaccard(firstPrompts[i].tokens, firstPrompts[j].tokens));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (sims.length > 0) {
|
|
145
|
+
consistency = sims.reduce((a, b) => a + b, 0) / sims.length;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- dedup: % of sessions whose first prompt is highly similar (>=0.6) to a previous session ----
|
|
150
|
+
let dedup = null;
|
|
151
|
+
if (firstPrompts.length >= 2) {
|
|
152
|
+
let dupes = 0;
|
|
153
|
+
for (let i = 1; i < firstPrompts.length; i++) {
|
|
154
|
+
for (let j = 0; j < i; j++) {
|
|
155
|
+
if (jaccard(firstPrompts[i].tokens, firstPrompts[j].tokens) >= 0.6) {
|
|
156
|
+
dupes++;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
dedup = dupes / firstPrompts.length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- refactor: occurrences per 100 sessions ----
|
|
165
|
+
let refactorHits = 0;
|
|
166
|
+
for (const m of userMsgs) {
|
|
167
|
+
if (m.text && matchesAny(m.text, REFACTOR_PATTERNS)) refactorHits++;
|
|
168
|
+
}
|
|
169
|
+
const refactor = sessions.length > 0 ? (refactorHits / sessions.length) * 100 : null;
|
|
170
|
+
|
|
171
|
+
// ---- abstraction: vocab share in user messages ----
|
|
172
|
+
let totalTokens = 0;
|
|
173
|
+
let abstractTokens = 0;
|
|
174
|
+
for (const m of userMsgs) {
|
|
175
|
+
if (!m.text) continue;
|
|
176
|
+
const toks = m.text.split(/[^\p{L}\p{N}_]+/u).filter(Boolean);
|
|
177
|
+
totalTokens += toks.length;
|
|
178
|
+
abstractTokens += termOccurrences(m.text, ABSTRACTION_TERMS);
|
|
179
|
+
}
|
|
180
|
+
const abstraction = totalTokens > 0 ? abstractTokens / totalTokens : null;
|
|
181
|
+
|
|
182
|
+
// ---- evaluate against thresholds (difficulty-agnostic — 'all') ----
|
|
183
|
+
const consE = evalIndicator(H3.consistency, consistency, 2);
|
|
184
|
+
const dedupE = evalIndicator(H3.dedup, dedup, 2);
|
|
185
|
+
const refE = evalIndicator(H3.refactor, refactor, 2);
|
|
186
|
+
const absE = evalIndicator(H3.abstraction, abstraction, 2);
|
|
187
|
+
|
|
188
|
+
const subScores = {
|
|
189
|
+
consistency: consE.score,
|
|
190
|
+
dedup: dedupE.score,
|
|
191
|
+
refactor: refE.score,
|
|
192
|
+
abstraction: absE.score,
|
|
193
|
+
};
|
|
194
|
+
const subLevels = {
|
|
195
|
+
consistency: consE.level,
|
|
196
|
+
dedup: dedupE.level,
|
|
197
|
+
refactor: refE.level,
|
|
198
|
+
abstraction: absE.level,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const score = rollupDimension('H3', subScores);
|
|
202
|
+
const level = scoreToLevel(score);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
subScores,
|
|
206
|
+
subLevels,
|
|
207
|
+
raw: { consistency, dedup, refactor, abstraction, refactorHits, abstractTokens, totalTokens },
|
|
208
|
+
score,
|
|
209
|
+
level,
|
|
210
|
+
sessionCount: sessions.length,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function emptyResult(n) {
|
|
215
|
+
return {
|
|
216
|
+
subScores: { consistency: null, dedup: null, refactor: null, abstraction: null },
|
|
217
|
+
subLevels: { consistency: null, dedup: null, refactor: null, abstraction: null },
|
|
218
|
+
raw: {},
|
|
219
|
+
score: null,
|
|
220
|
+
level: null,
|
|
221
|
+
sessionCount: n,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { analyzeRange };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared evidence-builder for dimension scorers.
|
|
3
|
+
*
|
|
4
|
+
* Produces a structured "why" object the UI tooltip renders without
|
|
5
|
+
* needing to re-implement any threshold tables on the client. Each
|
|
6
|
+
* dimension scorer feeds in:
|
|
7
|
+
* - key / label / what : indicator identity + 1-sentence "how it's measured"
|
|
8
|
+
* - expl : result of thresholds-v2.explainIndicator()
|
|
9
|
+
* - unit : '次' / '%' / '轮' / ratio etc. Formats the
|
|
10
|
+
* observed value the same way thresholds are
|
|
11
|
+
* formatted, so band text reads naturally.
|
|
12
|
+
*
|
|
13
|
+
* @author Felix
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const DIFFICULTY_LABEL = { 1: '琐碎', 2: '常规', 3: '复杂', 4: '重型' };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Format a raw scalar according to the indicator's `unit`.
|
|
22
|
+
* '%' → 12.3%
|
|
23
|
+
* 'x' → 1.45x (multiplier / ratio)
|
|
24
|
+
* else → integer if integer else 2-decimal
|
|
25
|
+
*/
|
|
26
|
+
function fmtNum(v, unit) {
|
|
27
|
+
if (v == null || Number.isNaN(v)) return '-';
|
|
28
|
+
if (unit === '%') return (v * 100).toFixed(1) + '%';
|
|
29
|
+
if (unit === 'x') return v.toFixed(2) + '×';
|
|
30
|
+
if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(2);
|
|
31
|
+
return String(v);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmtObserved(v, unit) {
|
|
35
|
+
const num = fmtNum(v, unit);
|
|
36
|
+
if (!unit || unit === '%' || unit === 'x') return num;
|
|
37
|
+
return num + ' ' + unit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Describe in plain Chinese which band the value fell into.
|
|
42
|
+
*/
|
|
43
|
+
function describeBand(direction, bounds, level, unit) {
|
|
44
|
+
if (!bounds || level == null) return '';
|
|
45
|
+
if (direction === 'lower') {
|
|
46
|
+
if (level === 4) return `≤ ${fmtNum(bounds.L4, unit)}(L4 专家档)`;
|
|
47
|
+
if (level === 3) return `> ${fmtNum(bounds.L4, unit)} 且 ≤ ${fmtNum(bounds.L3, unit)}(L3 精通档)`;
|
|
48
|
+
if (level === 2) return `> ${fmtNum(bounds.L3, unit)} 且 ≤ ${fmtNum(bounds.L2, unit)}(L2 熟练档)`;
|
|
49
|
+
return `> ${fmtNum(bounds.L2, unit)}(L1 新手档)`;
|
|
50
|
+
}
|
|
51
|
+
if (direction === 'higher') {
|
|
52
|
+
if (level === 4) return `≥ ${fmtNum(bounds.L4, unit)}(L4 专家档)`;
|
|
53
|
+
if (level === 3) return `≥ ${fmtNum(bounds.L3, unit)} 且 < ${fmtNum(bounds.L4, unit)}(L3 精通档)`;
|
|
54
|
+
if (level === 2) return `≥ ${fmtNum(bounds.L2, unit)} 且 < ${fmtNum(bounds.L3, unit)}(L2 熟练档)`;
|
|
55
|
+
return `< ${fmtNum(bounds.L2, unit)}(L1 新手档)`;
|
|
56
|
+
}
|
|
57
|
+
if (direction === 'band') {
|
|
58
|
+
const b = bounds[`L${level}`];
|
|
59
|
+
if (Array.isArray(b)) {
|
|
60
|
+
return `落在 [${fmtNum(b[0], unit)}, ${fmtNum(b[1], unit)}] 区间(L${level} 档)`;
|
|
61
|
+
}
|
|
62
|
+
return `落在 L${level} 档`;
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the evidence object the API ships to the UI tooltip.
|
|
69
|
+
*/
|
|
70
|
+
function makeEvidence({ key, label, what, expl, unit, difficulty }) {
|
|
71
|
+
const { value, level, score, direction, bounds } = expl || {};
|
|
72
|
+
const diffLabel = `${difficulty} ${DIFFICULTY_LABEL[difficulty] || ''}`.trim();
|
|
73
|
+
|
|
74
|
+
if (value == null || level == null) {
|
|
75
|
+
return {
|
|
76
|
+
key, label, what,
|
|
77
|
+
observed: null,
|
|
78
|
+
level: null,
|
|
79
|
+
score: null,
|
|
80
|
+
direction,
|
|
81
|
+
bounds,
|
|
82
|
+
difficulty,
|
|
83
|
+
reason: '数据不足,未计分(信号缺失或会话过短)',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const observed = fmtObserved(value, unit);
|
|
88
|
+
const bandText = describeBand(direction, bounds, level, unit);
|
|
89
|
+
const reason = `难度档位 ${diffLabel},观测到 ${observed},${bandText} → 得分 ${score}`;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
key, label, what,
|
|
93
|
+
observed,
|
|
94
|
+
rawValue: value,
|
|
95
|
+
level,
|
|
96
|
+
score,
|
|
97
|
+
direction,
|
|
98
|
+
bounds,
|
|
99
|
+
difficulty,
|
|
100
|
+
reason,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { makeEvidence, fmtNum, fmtObserved, describeBand, DIFFICULTY_LABEL };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analysis Job Scheduler for Agent Boss
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates session analysis: walks unanalyzed sessions in reverse
|
|
5
|
+
* chronological order (most-recent date first), scores each one, then
|
|
6
|
+
* aggregates daily summaries. See design doc §6.6.
|
|
7
|
+
*
|
|
8
|
+
* @author Felix
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
getUnanalyzedSessions,
|
|
13
|
+
upsertSessionAnalysis,
|
|
14
|
+
getAnalysisState,
|
|
15
|
+
updateAnalysisState,
|
|
16
|
+
getSessionsByDate,
|
|
17
|
+
} = require('../db/queries');
|
|
18
|
+
|
|
19
|
+
const { analyzeSessionV2 } = require('./scoring-v2');
|
|
20
|
+
const { normaliseAdvicePayload } = require('../llm/advice');
|
|
21
|
+
const { aggregateDailySummary } = require('./daily-aggregator');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a list of YYYY-MM-DD strings starting from today going back
|
|
29
|
+
* `days` days, ordered most-recent first. Today is included so the
|
|
30
|
+
* current day's sessions get scored too; sessions that keep growing
|
|
31
|
+
* after being scored are re-picked by getUnanalyzedSessions (ended_at
|
|
32
|
+
* newer than analyzed_at).
|
|
33
|
+
*
|
|
34
|
+
* @param {number} days
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
function buildDateList(days) {
|
|
38
|
+
const dates = [];
|
|
39
|
+
const now = new Date();
|
|
40
|
+
for (let i = 0; i <= days; i++) {
|
|
41
|
+
const d = new Date(now);
|
|
42
|
+
d.setDate(d.getDate() - i);
|
|
43
|
+
const yyyy = d.getFullYear();
|
|
44
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
45
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
46
|
+
dates.push(`${yyyy}-${mm}-${dd}`);
|
|
47
|
+
}
|
|
48
|
+
return dates; // already most-recent first
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Per-session analyze + persist (shared by the job loop and the
|
|
53
|
+
// per-session reanalyze endpoint)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run v2 analysis for ONE session and persist everything:
|
|
58
|
+
* - session_analysis v2 columns (via upsertSessionAnalysis)
|
|
59
|
+
* - llm_advice (the advice half of the combined v2 LLM call)
|
|
60
|
+
*
|
|
61
|
+
* @param {object} db
|
|
62
|
+
* @param {object} session unified_session row
|
|
63
|
+
* @param {object} [opts] { force?: boolean } bypass the analyzer cache
|
|
64
|
+
* @returns {Promise<object|null>} the v2 result (scores/levels/advice) or null
|
|
65
|
+
*/
|
|
66
|
+
async function analyzeAndStoreSession(db, session, opts = {}) {
|
|
67
|
+
const analysis = {
|
|
68
|
+
session_id: session.id,
|
|
69
|
+
source: session.source,
|
|
70
|
+
analyzed_at: new Date().toISOString(),
|
|
71
|
+
status: 'done',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let v2 = null;
|
|
75
|
+
try { v2 = await analyzeSessionV2(db, session, { force: opts.force === true }); }
|
|
76
|
+
catch (_) { /* fall through with status=done but empty v2 fields */ }
|
|
77
|
+
|
|
78
|
+
if (v2) {
|
|
79
|
+
analysis.difficulty = v2.difficulty.bucket;
|
|
80
|
+
analysis.score_h1 = v2.scores.H1; analysis.level_h1 = v2.levels.H1;
|
|
81
|
+
analysis.score_h2 = v2.scores.H2; analysis.level_h2 = v2.levels.H2;
|
|
82
|
+
analysis.score_h3 = v2.scores.H3; analysis.level_h3 = v2.levels.H3;
|
|
83
|
+
analysis.score_e1 = v2.scores.E1; analysis.level_e1 = v2.levels.E1;
|
|
84
|
+
analysis.score_e2 = v2.scores.E2; analysis.level_e2 = v2.levels.E2;
|
|
85
|
+
analysis.score_o1 = v2.scores.O1; analysis.level_o1 = v2.levels.O1;
|
|
86
|
+
analysis.sub_scores_v2 = JSON.stringify({
|
|
87
|
+
subScores: v2.subScores,
|
|
88
|
+
subLevels: v2.subLevels,
|
|
89
|
+
subEvidence: v2.subEvidence,
|
|
90
|
+
});
|
|
91
|
+
analysis.llm_judge_v2 = v2.llmJudge ? JSON.stringify(v2.llmJudge) : null;
|
|
92
|
+
analysis.judge_source = v2.judgeSource;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
upsertSessionAnalysis(db, analysis);
|
|
96
|
+
|
|
97
|
+
// Persist the advice half to llm_advice (separate column; the upsert
|
|
98
|
+
// above doesn't touch it).
|
|
99
|
+
if (v2 && v2.llmAdvice) {
|
|
100
|
+
try {
|
|
101
|
+
const m = v2.llmAdviceMeta || {};
|
|
102
|
+
const norm = normaliseAdvicePayload(v2.llmAdvice, {
|
|
103
|
+
msgCount: m.msgCount || session.message_count || 0,
|
|
104
|
+
cli: m.cli || null,
|
|
105
|
+
truncated: false,
|
|
106
|
+
omittedMessages: 0,
|
|
107
|
+
});
|
|
108
|
+
db.run('UPDATE session_analysis SET llm_advice = ? WHERE session_id = ?',
|
|
109
|
+
[JSON.stringify(norm), session.id]);
|
|
110
|
+
} catch (_) { /* advice persistence is best-effort */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return v2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Main job
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run analysis job: analyze unanalyzed sessions in reverse chronological order.
|
|
122
|
+
* Default: last 7 days. Processes one date at a time, most recent first.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} db - sql.js boss.db instance
|
|
125
|
+
* @param {object} options - {
|
|
126
|
+
* days: 7,
|
|
127
|
+
* onProgress: fn,
|
|
128
|
+
* forceReanalyze: false,
|
|
129
|
+
* dates: string[] // optional explicit YYYY-MM-DD list; overrides `days`
|
|
130
|
+
* }
|
|
131
|
+
* @returns {Promise<{analyzed: number, errors: number, skipped: number}>}
|
|
132
|
+
*/
|
|
133
|
+
async function runAnalysisJob(db, options = {}) {
|
|
134
|
+
const {
|
|
135
|
+
days = 7,
|
|
136
|
+
onProgress = null,
|
|
137
|
+
forceReanalyze = false,
|
|
138
|
+
dates: explicitDates = null,
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
const result = { analyzed: 0, errors: 0, skipped: 0 };
|
|
142
|
+
|
|
143
|
+
// 1. Mark analysis as running
|
|
144
|
+
updateAnalysisState(db, {
|
|
145
|
+
status: 'running',
|
|
146
|
+
current_date: null,
|
|
147
|
+
analyzed_count: 0,
|
|
148
|
+
total_count: 0,
|
|
149
|
+
last_analyzed_at: null,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const dates = Array.isArray(explicitDates) && explicitDates.length > 0
|
|
153
|
+
? explicitDates.slice().sort().reverse() // most-recent first, matching buildDateList
|
|
154
|
+
: buildDateList(days);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Pre-calculate total count for progress reporting
|
|
158
|
+
let totalSessions = 0;
|
|
159
|
+
for (const date of dates) {
|
|
160
|
+
const sessions = forceReanalyze
|
|
161
|
+
? getSessionsByDate(db, date)
|
|
162
|
+
: getUnanalyzedSessions(db, date);
|
|
163
|
+
totalSessions += sessions.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
updateAnalysisState(db, {
|
|
167
|
+
status: 'running',
|
|
168
|
+
current_date: dates[0] || null,
|
|
169
|
+
analyzed_count: 0,
|
|
170
|
+
total_count: totalSessions,
|
|
171
|
+
last_analyzed_at: null,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 2. Process each date (most recent first)
|
|
175
|
+
for (const date of dates) {
|
|
176
|
+
updateAnalysisState(db, {
|
|
177
|
+
status: 'running',
|
|
178
|
+
current_date: date,
|
|
179
|
+
analyzed_count: result.analyzed,
|
|
180
|
+
total_count: totalSessions,
|
|
181
|
+
last_analyzed_at: null,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// 2a. Get unanalyzed sessions for this date
|
|
185
|
+
const sessions = forceReanalyze
|
|
186
|
+
? getSessionsByDate(db, date)
|
|
187
|
+
: getUnanalyzedSessions(db, date);
|
|
188
|
+
|
|
189
|
+
if (sessions.length === 0) {
|
|
190
|
+
result.skipped++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2b. Analyze each session
|
|
195
|
+
for (const session of sessions) {
|
|
196
|
+
try {
|
|
197
|
+
// One combined LLM call (scores + advice); persisted to the v2
|
|
198
|
+
// columns + llm_advice. forceReanalyze bypasses the analyzer cache.
|
|
199
|
+
await analyzeAndStoreSession(db, session, { force: forceReanalyze });
|
|
200
|
+
|
|
201
|
+
result.analyzed++;
|
|
202
|
+
|
|
203
|
+
// Update progress
|
|
204
|
+
updateAnalysisState(db, {
|
|
205
|
+
status: 'running',
|
|
206
|
+
current_date: date,
|
|
207
|
+
analyzed_count: result.analyzed,
|
|
208
|
+
total_count: totalSessions,
|
|
209
|
+
last_analyzed_at: new Date().toISOString(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (onProgress) {
|
|
213
|
+
onProgress({
|
|
214
|
+
date,
|
|
215
|
+
sessionId: session.id,
|
|
216
|
+
analyzed: result.analyzed,
|
|
217
|
+
total: totalSessions,
|
|
218
|
+
errors: result.errors,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Mark session analysis as error, continue with next
|
|
223
|
+
result.errors++;
|
|
224
|
+
|
|
225
|
+
upsertSessionAnalysis(db, {
|
|
226
|
+
session_id: session.id,
|
|
227
|
+
source: session.source,
|
|
228
|
+
analyzed_at: new Date().toISOString(),
|
|
229
|
+
status: 'error',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (onProgress) {
|
|
233
|
+
onProgress({
|
|
234
|
+
date,
|
|
235
|
+
sessionId: session.id,
|
|
236
|
+
analyzed: result.analyzed,
|
|
237
|
+
total: totalSessions,
|
|
238
|
+
errors: result.errors,
|
|
239
|
+
error: err.message,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2d. Aggregate daily summary after processing all sessions for this date
|
|
246
|
+
try {
|
|
247
|
+
aggregateDailySummary(db, date);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// Non-fatal: log but continue with next date
|
|
250
|
+
if (onProgress) {
|
|
251
|
+
onProgress({
|
|
252
|
+
date,
|
|
253
|
+
aggregationError: err.message,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
} finally {
|
|
260
|
+
// 5. Always reset analysis state to idle when done
|
|
261
|
+
updateAnalysisState(db, {
|
|
262
|
+
status: 'idle',
|
|
263
|
+
current_date: null,
|
|
264
|
+
analyzed_count: result.analyzed,
|
|
265
|
+
total_count: result.analyzed + result.errors + result.skipped,
|
|
266
|
+
last_analyzed_at: new Date().toISOString(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { runAnalysisJob, buildDateList, analyzeAndStoreSession };
|