task-summary-extractor 8.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Diff Engine — compares current compilation against previous runs
3
+ * to generate a delta report: what's new, what's resolved, what changed.
4
+ *
5
+ * This enables incremental awareness — users can see what changed between
6
+ * successive analyses of the same call (or across calls in a series).
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // ======================== PREVIOUS RUN LOADING ========================
15
+
16
+ /**
17
+ * Find and load the most recent previous compilation for comparison.
18
+ *
19
+ * @param {string} targetDir - The call folder (e.g., "call 1/")
20
+ * @param {string} [currentRunTs] - Current run timestamp to exclude
21
+ * @returns {object|null} Previous compiled analysis, or null if none found
22
+ */
23
+ function loadPreviousCompilation(targetDir, currentRunTs = null) {
24
+ const runsDir = path.join(targetDir, 'runs');
25
+ if (!fs.existsSync(runsDir)) return null;
26
+
27
+ try {
28
+ const runDirs = fs.readdirSync(runsDir)
29
+ .filter(d => {
30
+ const full = path.join(runsDir, d);
31
+ return fs.statSync(full).isDirectory() && d !== currentRunTs;
32
+ })
33
+ .sort()
34
+ .reverse(); // Most recent first
35
+
36
+ for (const dir of runDirs) {
37
+ const compilationPath = path.join(runsDir, dir, 'compilation.json');
38
+ if (fs.existsSync(compilationPath)) {
39
+ const data = JSON.parse(fs.readFileSync(compilationPath, 'utf8'));
40
+ const parsed = data.output?.parsed || data.compiled || null;
41
+ if (parsed) {
42
+ return {
43
+ timestamp: dir,
44
+ compiled: parsed,
45
+ runPath: path.join(runsDir, dir),
46
+ };
47
+ }
48
+ }
49
+
50
+ // Fallback: try results.json
51
+ const resultsPath = path.join(runsDir, dir, 'results.json');
52
+ if (fs.existsSync(resultsPath)) {
53
+ const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
54
+ if (results.compilation) {
55
+ return {
56
+ timestamp: dir,
57
+ compiled: null, // No compiled data in results.json directly
58
+ runPath: path.join(runsDir, dir),
59
+ };
60
+ }
61
+ }
62
+ }
63
+ } catch (err) {
64
+ console.warn(` ⚠ Could not load previous compilation: ${err.message}`);
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ // ======================== DIFF GENERATION ========================
71
+
72
+ /**
73
+ * Compare two compiled analyses and produce a diff.
74
+ *
75
+ * @param {object} current - Current compiled analysis
76
+ * @param {object} previous - Previous compiled analysis
77
+ * @returns {object} Diff report
78
+ */
79
+ function generateDiff(current, previous) {
80
+ if (!current || !previous) {
81
+ return { hasDiff: false, reason: !previous ? 'no_previous_run' : 'no_current_data' };
82
+ }
83
+
84
+ const diff = {
85
+ hasDiff: true,
86
+ previousTimestamp: previous.timestamp || 'unknown',
87
+ tickets: diffArray(current.tickets || [], previous.tickets || [], 'ticket_id'),
88
+ changeRequests: diffArray(current.change_requests || [], previous.change_requests || [], 'id'),
89
+ actionItems: diffArray(current.action_items || [], previous.action_items || [], 'id'),
90
+ blockers: diffArray(current.blockers || [], previous.blockers || [], 'id'),
91
+ scopeChanges: diffArray(current.scope_changes || [], previous.scope_changes || [], 'id'),
92
+ summary: {
93
+ current: current.summary || '',
94
+ previous: previous.summary || '',
95
+ changed: (current.summary || '') !== (previous.summary || ''),
96
+ },
97
+ };
98
+
99
+ // Calculate totals
100
+ diff.totals = {
101
+ newItems: (diff.tickets.added?.length || 0) +
102
+ (diff.changeRequests.added?.length || 0) +
103
+ (diff.actionItems.added?.length || 0) +
104
+ (diff.blockers.added?.length || 0) +
105
+ (diff.scopeChanges.added?.length || 0),
106
+ removedItems: (diff.tickets.removed?.length || 0) +
107
+ (diff.changeRequests.removed?.length || 0) +
108
+ (diff.actionItems.removed?.length || 0) +
109
+ (diff.blockers.removed?.length || 0) +
110
+ (diff.scopeChanges.removed?.length || 0),
111
+ changedItems: (diff.tickets.changed?.length || 0) +
112
+ (diff.changeRequests.changed?.length || 0) +
113
+ (diff.actionItems.changed?.length || 0) +
114
+ (diff.blockers.changed?.length || 0) +
115
+ (diff.scopeChanges.changed?.length || 0),
116
+ unchangedItems: (diff.tickets.unchanged?.length || 0) +
117
+ (diff.changeRequests.unchanged?.length || 0) +
118
+ (diff.actionItems.unchanged?.length || 0) +
119
+ (diff.blockers.unchanged?.length || 0) +
120
+ (diff.scopeChanges.unchanged?.length || 0),
121
+ };
122
+
123
+ return diff;
124
+ }
125
+
126
+ /**
127
+ * Diff two arrays of items by ID field.
128
+ *
129
+ * @param {Array} currentArr - Current items
130
+ * @param {Array} previousArr - Previous items
131
+ * @param {string} idField - Field name to use as ID
132
+ * @returns {{ added: Array, removed: Array, changed: Array, unchanged: Array }}
133
+ */
134
+ function diffArray(currentArr, previousArr, idField) {
135
+ const prevMap = new Map();
136
+ for (const item of previousArr) {
137
+ const id = item[idField];
138
+ if (id) prevMap.set(id, item);
139
+ }
140
+
141
+ const currMap = new Map();
142
+ for (const item of currentArr) {
143
+ const id = item[idField];
144
+ if (id) currMap.set(id, item);
145
+ }
146
+
147
+ const added = [];
148
+ const changed = [];
149
+ const unchanged = [];
150
+ const removed = [];
151
+
152
+ // Find added and changed
153
+ for (const [id, currItem] of currMap) {
154
+ if (!prevMap.has(id)) {
155
+ added.push({ id, item: currItem, _diffStatus: 'new' });
156
+ } else {
157
+ const prevItem = prevMap.get(id);
158
+ const changes = detectFieldChanges(currItem, prevItem, idField);
159
+ if (changes.length > 0) {
160
+ changed.push({ id, item: currItem, changes, _diffStatus: 'changed' });
161
+ } else {
162
+ unchanged.push({ id, _diffStatus: 'unchanged' });
163
+ }
164
+ }
165
+ }
166
+
167
+ // Find removed
168
+ for (const [id, prevItem] of prevMap) {
169
+ if (!currMap.has(id)) {
170
+ removed.push({ id, item: prevItem, _diffStatus: 'removed' });
171
+ }
172
+ }
173
+
174
+ return { added, removed, changed, unchanged };
175
+ }
176
+
177
+ /**
178
+ * Detect specific field changes between two items.
179
+ *
180
+ * @param {object} current
181
+ * @param {object} previous
182
+ * @param {string} idField - Skip the ID field itself
183
+ * @returns {Array<{field: string, from: any, to: any}>}
184
+ */
185
+ function detectFieldChanges(current, previous, idField) {
186
+ const changes = [];
187
+ const importantFields = ['status', 'priority', 'assigned_to', 'assignee', 'owner', 'confidence'];
188
+
189
+ for (const field of importantFields) {
190
+ const curr = current[field];
191
+ const prev = previous[field];
192
+ if (curr !== prev && (curr || prev)) {
193
+ changes.push({ field, from: prev || null, to: curr || null });
194
+ }
195
+ }
196
+
197
+ return changes;
198
+ }
199
+
200
+ // ======================== MARKDOWN RENDERING ========================
201
+
202
+ /**
203
+ * Render the diff as a Markdown section to append to the main report.
204
+ *
205
+ * @param {object} diff - From generateDiff()
206
+ * @returns {string} Markdown section
207
+ */
208
+ function renderDiffMarkdown(diff) {
209
+ if (!diff || !diff.hasDiff) return '';
210
+
211
+ const lines = [];
212
+ const ln = (...args) => lines.push(args.join(''));
213
+
214
+ ln('## 🔄 Changes Since Previous Run');
215
+ ln('');
216
+ ln(`> Compared against run from: \`${diff.previousTimestamp}\``);
217
+ ln('');
218
+
219
+ const t = diff.totals;
220
+ if (t.newItems === 0 && t.removedItems === 0 && t.changedItems === 0) {
221
+ ln('No changes detected since the previous run.');
222
+ ln('');
223
+ return lines.join('\n');
224
+ }
225
+
226
+ ln(`| Category | New | Removed | Changed | Unchanged |`);
227
+ ln(`| --- | --- | --- | --- | --- |`);
228
+
229
+ const categories = [
230
+ { name: 'Tickets', d: diff.tickets },
231
+ { name: 'Change Requests', d: diff.changeRequests },
232
+ { name: 'Action Items', d: diff.actionItems },
233
+ { name: 'Blockers', d: diff.blockers },
234
+ { name: 'Scope Changes', d: diff.scopeChanges },
235
+ ];
236
+
237
+ for (const { name, d } of categories) {
238
+ const a = d.added?.length || 0;
239
+ const r = d.removed?.length || 0;
240
+ const c = d.changed?.length || 0;
241
+ const u = d.unchanged?.length || 0;
242
+ if (a + r + c > 0) {
243
+ ln(`| ${name} | ${a > 0 ? `+${a}` : '-'} | ${r > 0 ? `-${r}` : '-'} | ${c > 0 ? `~${c}` : '-'} | ${u} |`);
244
+ }
245
+ }
246
+ ln('');
247
+
248
+ // Detail new items
249
+ const allNew = [
250
+ ...diff.tickets.added.map(i => ({ type: 'Ticket', ...i })),
251
+ ...diff.changeRequests.added.map(i => ({ type: 'CR', ...i })),
252
+ ...diff.actionItems.added.map(i => ({ type: 'Action', ...i })),
253
+ ...diff.blockers.added.map(i => ({ type: 'Blocker', ...i })),
254
+ ...diff.scopeChanges.added.map(i => ({ type: 'Scope', ...i })),
255
+ ];
256
+ if (allNew.length > 0) {
257
+ ln('### ➕ New Items');
258
+ ln('');
259
+ for (const n of allNew) {
260
+ const title = n.item.title || n.item.description || n.item.ticket_id || n.id;
261
+ ln(`- **[${n.type}]** ${n.id}: ${title}`);
262
+ }
263
+ ln('');
264
+ }
265
+
266
+ // Detail removed items
267
+ const allRemoved = [
268
+ ...diff.tickets.removed.map(i => ({ type: 'Ticket', ...i })),
269
+ ...diff.changeRequests.removed.map(i => ({ type: 'CR', ...i })),
270
+ ...diff.actionItems.removed.map(i => ({ type: 'Action', ...i })),
271
+ ...diff.blockers.removed.map(i => ({ type: 'Blocker', ...i })),
272
+ ...diff.scopeChanges.removed.map(i => ({ type: 'Scope', ...i })),
273
+ ];
274
+ if (allRemoved.length > 0) {
275
+ ln('### ➖ Removed Items');
276
+ ln('');
277
+ for (const r of allRemoved) {
278
+ const title = r.item.title || r.item.description || r.item.ticket_id || r.id;
279
+ ln(`- **[${r.type}]** ${r.id}: ${title}`);
280
+ }
281
+ ln('');
282
+ }
283
+
284
+ // Detail changed items
285
+ const allChanged = [
286
+ ...diff.tickets.changed.map(i => ({ type: 'Ticket', ...i })),
287
+ ...diff.changeRequests.changed.map(i => ({ type: 'CR', ...i })),
288
+ ...diff.actionItems.changed.map(i => ({ type: 'Action', ...i })),
289
+ ...diff.blockers.changed.map(i => ({ type: 'Blocker', ...i })),
290
+ ...diff.scopeChanges.changed.map(i => ({ type: 'Scope', ...i })),
291
+ ];
292
+ if (allChanged.length > 0) {
293
+ ln('### 🔀 Changed Items');
294
+ ln('');
295
+ for (const c of allChanged) {
296
+ const title = c.item.title || c.item.description || c.item.ticket_id || c.id;
297
+ ln(`- **[${c.type}]** ${c.id}: ${title}`);
298
+ for (const ch of c.changes) {
299
+ ln(` - \`${ch.field}\`: ${ch.from || '_empty_'} → **${ch.to || '_empty_'}**`);
300
+ }
301
+ }
302
+ ln('');
303
+ }
304
+
305
+ return lines.join('\n');
306
+ }
307
+
308
+ module.exports = {
309
+ loadPreviousCompilation,
310
+ generateDiff,
311
+ renderDiffMarkdown,
312
+ // Expose for testing
313
+ diffArray,
314
+ detectFieldChanges,
315
+ };