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.
Files changed (53) hide show
  1. package/README.md +34 -0
  2. package/bin/aboss.js +288 -0
  3. package/client/dist/assets/index-C1wFD_Vo.css +1 -0
  4. package/client/dist/assets/index-DBj1Ujlx.js +137 -0
  5. package/client/dist/index.html +34 -0
  6. package/package.json +64 -0
  7. package/server/analysis/daily-aggregator.js +258 -0
  8. package/server/analysis/difficulty.js +129 -0
  9. package/server/analysis/dimensions/ai-knowledge.js +172 -0
  10. package/server/analysis/dimensions/ai-tools.js +161 -0
  11. package/server/analysis/dimensions/judgement.js +107 -0
  12. package/server/analysis/dimensions/llm-merge.js +57 -0
  13. package/server/analysis/dimensions/output-quality.js +167 -0
  14. package/server/analysis/dimensions/problem-definition.js +104 -0
  15. package/server/analysis/dimensions/system-thinking.js +225 -0
  16. package/server/analysis/evidence-builder.js +104 -0
  17. package/server/analysis/job.js +273 -0
  18. package/server/analysis/report-builder.js +581 -0
  19. package/server/analysis/scoring-v2.js +72 -0
  20. package/server/analysis/text-signals.js +179 -0
  21. package/server/analysis/thresholds-v2.js +358 -0
  22. package/server/api/advice.js +124 -0
  23. package/server/api/analysis.js +141 -0
  24. package/server/api/execution.js +330 -0
  25. package/server/api/metrics.js +277 -0
  26. package/server/api/overview.js +308 -0
  27. package/server/api/project.js +255 -0
  28. package/server/api/reports.js +125 -0
  29. package/server/api/sessions.js +118 -0
  30. package/server/api/settings.js +119 -0
  31. package/server/db/connection.js +175 -0
  32. package/server/db/queries.js +1051 -0
  33. package/server/db/schema.js +487 -0
  34. package/server/etl/active-time.js +150 -0
  35. package/server/etl/backfill-subagents.js +178 -0
  36. package/server/etl/claude-code.js +826 -0
  37. package/server/etl/detect.js +341 -0
  38. package/server/etl/judge-filter.js +117 -0
  39. package/server/etl/opencode.js +606 -0
  40. package/server/execution/job.js +662 -0
  41. package/server/execution/prompt.js +227 -0
  42. package/server/execution/runner.js +218 -0
  43. package/server/index.js +94 -0
  44. package/server/llm/advice-prompt.js +339 -0
  45. package/server/llm/advice.js +384 -0
  46. package/server/llm/analysis-prompt.js +162 -0
  47. package/server/llm/cli-runner.js +249 -0
  48. package/server/llm/judge-prompts.js +179 -0
  49. package/server/llm/judge.js +118 -0
  50. package/server/llm/project-advice-prompt.js +332 -0
  51. package/server/llm/project-advice.js +491 -0
  52. package/server/llm/session-analyzer.js +122 -0
  53. 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 };