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,968 @@
1
+ /**
2
+ * Markdown renderer — generates action-focused Markdown from compiled results.
3
+ *
4
+ * Improvements over v1:
5
+ * - Name clustering: merges case variants, role-suffix variants, partial matches
6
+ * - ID-based dedup: every ticket, CR, blocker, scope-change, action-item appears ONCE
7
+ * - User-first layout: the current user's section is promoted to the top
8
+ * - Cleaner formatting: owned tickets inline, concise tables, no repeated content
9
+ *
10
+ * This renderer expects the FINAL COMPILED analysis (after AI compilation pass),
11
+ * not raw per-segment data. It produces a single coherent task document.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ // ════════════════════════════════════════════════════════════
17
+ // Name Clustering Utilities
18
+ // ════════════════════════════════════════════════════════════
19
+
20
+ /**
21
+ * Strip parenthetical suffixes and normalize whitespace.
22
+ * "Mohamed Elhadi (Service Desk)" → "Mohamed Elhadi"
23
+ */
24
+ function stripParens(name) {
25
+ return (name || '').replace(/\s*\([^)]*\)\s*/g, '').trim();
26
+ }
27
+
28
+ /**
29
+ * Normalize a name to lowercase stripped form for comparison.
30
+ */
31
+ function normalizeKey(name) {
32
+ return stripParens(name).toLowerCase().replace(/\s+/g, ' ').trim();
33
+ }
34
+
35
+ /**
36
+ * Build a Map<canonicalName, Set<rawVariants>> from a list of raw name strings.
37
+ * Clustering rules applied in order:
38
+ * 1. Exact normalized match (case-insensitive, parens stripped)
39
+ * 2. Substring containment after stripping
40
+ *
41
+ * The canonical name chosen is the longest proper-cased variant (no parens).
42
+ */
43
+ function clusterNames(rawNames) {
44
+ const clusters = new Map(); // normKey → { canonical, variants: Set }
45
+ const normToCluster = new Map(); // normKey → cluster ref
46
+
47
+ for (const raw of rawNames) {
48
+ const stripped = stripParens(raw).trim();
49
+ if (!stripped) continue;
50
+ const nk = normalizeKey(raw);
51
+
52
+ // Check exact match first
53
+ if (normToCluster.has(nk)) {
54
+ const c = normToCluster.get(nk);
55
+ c.variants.add(raw);
56
+ // Upgrade canonical: prefer longest proper-cased form without parens
57
+ if (stripped.length >= c.canonical.length && stripped[0] === stripped[0].toUpperCase()) {
58
+ c.canonical = stripped;
59
+ }
60
+ continue;
61
+ }
62
+
63
+ // Check substring containment against existing clusters
64
+ let merged = false;
65
+ for (const [existNk, c] of normToCluster) {
66
+ if (existNk.includes(nk) || nk.includes(existNk)) {
67
+ c.variants.add(raw);
68
+ normToCluster.set(nk, c);
69
+ if (stripped.length >= c.canonical.length && stripped[0] === stripped[0].toUpperCase()) {
70
+ c.canonical = stripped;
71
+ }
72
+ merged = true;
73
+ break;
74
+ }
75
+ }
76
+ if (merged) continue;
77
+
78
+ // New cluster
79
+ const cluster = { canonical: stripped[0] === stripped[0].toUpperCase() ? stripped : raw, variants: new Set([raw]) };
80
+ clusters.set(nk, cluster);
81
+ normToCluster.set(nk, cluster);
82
+ }
83
+
84
+ // Build final map: canonical → Set of raw variants
85
+ const result = new Map();
86
+ for (const c of clusters.values()) {
87
+ if (!result.has(c.canonical)) result.set(c.canonical, new Set());
88
+ for (const v of c.variants) {
89
+ result.get(c.canonical).add(v);
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+
95
+ /**
96
+ * Given a raw name and a cluster map, return the canonical form.
97
+ */
98
+ function resolve(name, clusterMap) {
99
+ if (!name) return name;
100
+ const nk = normalizeKey(name);
101
+ for (const [canonical, variants] of clusterMap) {
102
+ for (const v of variants) {
103
+ if (normalizeKey(v) === nk) return canonical;
104
+ }
105
+ // substring fallback
106
+ const cnk = normalizeKey(canonical);
107
+ if (cnk.includes(nk) || nk.includes(cnk)) return canonical;
108
+ }
109
+ return stripParens(name).trim() || name;
110
+ }
111
+
112
+ // ════════════════════════════════════════════════════════════
113
+ // Dedup Utilities
114
+ // ════════════════════════════════════════════════════════════
115
+
116
+ /** Deduplicate an array by a key function. First occurrence wins (keeps richest data by default). */
117
+ function dedupBy(arr, keyFn) {
118
+ const seen = new Map();
119
+ const result = [];
120
+ for (const item of arr) {
121
+ const k = keyFn(item);
122
+ if (!k) { result.push(item); continue; }
123
+ if (seen.has(k)) {
124
+ // Merge: overwrite sparse fields from later duplicates
125
+ const existing = seen.get(k);
126
+ for (const [field, val] of Object.entries(item)) {
127
+ if (val && !existing[field]) existing[field] = val;
128
+ }
129
+ continue;
130
+ }
131
+ seen.set(k, item);
132
+ result.push(item);
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /** Normalize a description for fuzzy matching — strips file paths, parenthetical details, punctuation. */
138
+ function normalizeDesc(s) {
139
+ return (s || '')
140
+ .toLowerCase()
141
+ // Strip full file paths (keep only the last segment, e.g. "Notifications.cs")
142
+ .replace(/[\w\-./\\]+\/[\w\-./\\]+\.(cs|ts|js|json|html|resx|png|md)/g, m => {
143
+ const parts = m.replace(/\\/g, '/').split('/');
144
+ return parts[parts.length - 1];
145
+ })
146
+ // Strip parenthetical additions like "(likely code/config ...)"
147
+ .replace(/\([^)]*\)/g, '')
148
+ // Strip trailing punctuation and whitespace before punctuation
149
+ .replace(/\s+([.,;:!?])/g, '$1')
150
+ // Collapse whitespace
151
+ .replace(/\s+/g, ' ')
152
+ .trim();
153
+ }
154
+
155
+ /** Deduplicate by description text similarity (fallback when IDs are missing). */
156
+ function dedupByDesc(arr, descField = 'description') {
157
+ const seen = new Set();
158
+ return arr.filter(item => {
159
+ const key = normalizeDesc(item[descField]);
160
+ if (!key || seen.has(key)) return false;
161
+ seen.add(key);
162
+ return true;
163
+ });
164
+ }
165
+
166
+ // ════════════════════════════════════════════════════════════
167
+ // Main Renderer
168
+ // ════════════════════════════════════════════════════════════
169
+
170
+ /** Format a timestamp string for display, optionally with segment number. */
171
+ function fmtTs(ts, seg) {
172
+ if (!ts) return '';
173
+ if (seg) return `\`${ts}\` _(Seg ${seg})_`;
174
+ return `\`${ts}\``;
175
+ }
176
+
177
+ /** Make a compact priority badge */
178
+ function priBadge(p) {
179
+ if (!p) return '';
180
+ const icons = { high: '🔴', medium: '🟡', low: '🟢', critical: '🔴' };
181
+ return ` ${icons[p] || '⚪'} \`${p}\``;
182
+ }
183
+
184
+ /** Make a compact confidence badge */
185
+ function confBadge(c) {
186
+ if (!c) return '';
187
+ const icons = { HIGH: '🟢', MEDIUM: '🟡', LOW: '🔴' };
188
+ return ` ${icons[c] || '⚪'}\`${c}\``;
189
+ }
190
+
191
+ /** Make a confidence badge with reason tooltip */
192
+ function confBadgeFull(c, reason) {
193
+ if (!c) return '';
194
+ const icons = { HIGH: '🟢', MEDIUM: '🟡', LOW: '🔴' };
195
+ const badge = `${icons[c] || '⚪'}\`${c}\``;
196
+ if (reason) return ` ${badge} _(${reason})_`;
197
+ return ` ${badge}`;
198
+ }
199
+
200
+ /**
201
+ * Render the final compiled analysis into a comprehensive Markdown report.
202
+ *
203
+ * @param {object} options
204
+ * @param {object} options.compiled - The AI-compiled unified analysis
205
+ * @param {object} options.meta - Call metadata (enriched with compilation stats, segments, settings)
206
+ * @returns {string} Markdown content
207
+ */
208
+ function renderResultsMarkdown({ compiled, meta }) {
209
+ const lines = [];
210
+ const ln = (...args) => lines.push(args.join(''));
211
+ const hr = () => ln('---');
212
+
213
+ if (!compiled) {
214
+ return '# Call Analysis\n\nNo compiled result available — AI compilation may have failed.\n';
215
+ }
216
+
217
+ // ── Extract & deduplicate all data ──
218
+ const allTickets = dedupBy(compiled.tickets || [], t => t.ticket_id);
219
+ const allCRs = dedupBy(compiled.change_requests || [], cr => cr.id);
220
+ const allActions = dedupBy(compiled.action_items || [], ai => ai.id);
221
+ const allBlockers = dedupBy(compiled.blockers || [], b => b.id);
222
+ const allScope = dedupBy(compiled.scope_changes || [], sc => sc.id);
223
+ const allFiles = dedupBy(compiled.file_references || [], f => f.resolved_path || f.file_name);
224
+ const summary = compiled.summary || compiled.executive_summary || '';
225
+ const yourTasks = compiled.your_tasks || null;
226
+
227
+ // ── Discover & cluster participant names ──
228
+ const rawNames = new Set();
229
+ const addName = n => { if (n && n.trim()) rawNames.add(n.trim()); };
230
+
231
+ allActions.forEach(ai => addName(ai.assigned_to));
232
+ allCRs.forEach(cr => addName(cr.assigned_to));
233
+ allBlockers.forEach(b => addName(b.owner));
234
+ allScope.forEach(sc => addName(sc.decided_by));
235
+ allTickets.forEach(t => { addName(t.assignee); addName(t.reviewer); });
236
+ if (yourTasks?.user_name) addName(yourTasks.user_name);
237
+ if (yourTasks) {
238
+ (yourTasks.tasks_waiting_on_others || []).forEach(w => addName(w.waiting_on));
239
+ }
240
+
241
+ const clusterMap = clusterNames([...rawNames]);
242
+
243
+ // Remove generic team references
244
+ const teamKeywords = ['team', 'qa', 'dba', 'devops', 'db team', 'external'];
245
+ const people = [...clusterMap.keys()]
246
+ .filter(n => n && !teamKeywords.some(kw => n.toLowerCase() === kw))
247
+ .sort();
248
+
249
+ // Name matcher using cluster resolution
250
+ const nameMatch = (raw, canonical) => {
251
+ if (!raw || !canonical) return false;
252
+ return resolve(raw, clusterMap) === canonical;
253
+ };
254
+
255
+ // Detect current user's canonical name
256
+ const currentUserCanonical = meta.userName ? resolve(meta.userName, clusterMap) : null;
257
+
258
+ // Put current user first, others after
259
+ const orderedPeople = [];
260
+ if (currentUserCanonical && people.includes(currentUserCanonical)) {
261
+ orderedPeople.push(currentUserCanonical);
262
+ }
263
+ for (const p of people) {
264
+ if (p !== currentUserCanonical) orderedPeople.push(p);
265
+ }
266
+
267
+ // ══════════════════════════════════════════════════════
268
+ // HEADER
269
+ // ══════════════════════════════════════════════════════
270
+ ln(`# 📋 Call Analysis — ${meta.callName || 'Unknown'}`);
271
+ ln('');
272
+ ln(`> **Date**: ${meta.processedAt ? meta.processedAt.slice(0, 10) : 'N/A'} `);
273
+ ln(`> **Participants**: ${orderedPeople.join(', ') || 'Unknown'} `);
274
+ ln(`> **Segments analyzed**: ${meta.segmentCount || 'N/A'} `);
275
+ ln(`> **Model**: ${meta.geminiModel || 'N/A'} `);
276
+
277
+ // Compilation stats
278
+ const comp = meta.compilation;
279
+ if (comp) {
280
+ const tu = comp.tokenUsage || {};
281
+ const durSec = comp.durationMs ? (comp.durationMs / 1000).toFixed(1) : '?';
282
+ ln(`> **Compilation**: ${durSec}s | ${(tu.inputTokens || 0).toLocaleString()} input → ${(tu.outputTokens || 0).toLocaleString()} output tokens | thinking: ${(tu.thoughtTokens || 0).toLocaleString()} `);
283
+ }
284
+ ln(`> **Compiled**: Yes — AI-merged final result`);
285
+
286
+ // Cost summary
287
+ const cost = meta.costSummary;
288
+ if (cost && cost.totalTokens > 0) {
289
+ ln(`> **Cost estimate**: $${cost.totalCost.toFixed(4)} (${cost.totalTokens.toLocaleString()} tokens | ${(cost.totalDurationMs / 1000).toFixed(1)}s AI time) `);
290
+ }
291
+ ln('');
292
+
293
+ // Segment breakdown
294
+ const segs = meta.segments || [];
295
+ if (segs.length > 0) {
296
+ ln('<details>');
297
+ ln(`<summary>📼 Segment Details (${segs.length} segments)</summary>`);
298
+ ln('');
299
+ ln('| # | File | Duration | Size |');
300
+ ln('| --- | --- | --- | --- |');
301
+ segs.forEach((s, i) => {
302
+ ln(`| ${i + 1} | ${s.file} | ${s.duration || '?'} | ${s.sizeMB || '?'} MB |`);
303
+ });
304
+ const totalDur = segs.reduce((sum, s) => sum + (s.durationSeconds || 0), 0);
305
+ const totalSize = segs.reduce((sum, s) => sum + (parseFloat(s.sizeMB) || 0), 0);
306
+ ln(`| | **Total** | **${Math.floor(totalDur / 60)}:${String(Math.round(totalDur % 60)).padStart(2, '0')}** | **${totalSize.toFixed(2)} MB** |`);
307
+ ln('');
308
+ if (meta.settings) {
309
+ ln(`> Speed: ${meta.settings.speed}x | Preset: ${meta.settings.preset} | Segment time: ${meta.settings.segmentTimeSec}s`);
310
+ }
311
+ ln('</details>');
312
+ ln('');
313
+ }
314
+
315
+ hr();
316
+ ln('');
317
+
318
+ // ══════════════════════════════════════════════════════
319
+ // CONFIDENCE DISTRIBUTION
320
+ // ══════════════════════════════════════════════════════
321
+ const allConfItems = [
322
+ ...allTickets, ...allCRs, ...allActions, ...allBlockers, ...allScope,
323
+ ];
324
+ if (allConfItems.length > 0) {
325
+ const confHigh = allConfItems.filter(i => i.confidence === 'HIGH').length;
326
+ const confMed = allConfItems.filter(i => i.confidence === 'MEDIUM').length;
327
+ const confLow = allConfItems.filter(i => i.confidence === 'LOW').length;
328
+ const confMissing = allConfItems.length - confHigh - confMed - confLow;
329
+ const confTotal = allConfItems.length;
330
+
331
+ if (confHigh + confMed + confLow > 0) {
332
+ ln('### 📊 Confidence Distribution');
333
+ ln('');
334
+ ln(`| Level | Count | % |`);
335
+ ln(`| --- | --- | --- |`);
336
+ if (confHigh > 0) ln(`| 🟢 HIGH | ${confHigh} | ${((confHigh / confTotal) * 100).toFixed(0)}% |`);
337
+ if (confMed > 0) ln(`| 🟡 MEDIUM | ${confMed} | ${((confMed / confTotal) * 100).toFixed(0)}% |`);
338
+ if (confLow > 0) ln(`| 🔴 LOW | ${confLow} | ${((confLow / confTotal) * 100).toFixed(0)}% |`);
339
+ if (confMissing > 0) ln(`| ⚪ UNSET | ${confMissing} | ${((confMissing / confTotal) * 100).toFixed(0)}% |`);
340
+ ln('');
341
+ if (confLow > 0) {
342
+ ln('> ⚠️ **LOW confidence items** need human verification before acting on them.');
343
+ ln('');
344
+ }
345
+ }
346
+ }
347
+
348
+ // ══════════════════════════════════════════════════════
349
+ // EXECUTIVE SUMMARY
350
+ // ══════════════════════════════════════════════════════
351
+ ln('## 📝 Executive Summary');
352
+ ln('');
353
+ if (summary) ln(summary);
354
+ ln('');
355
+
356
+ // ══════════════════════════════════════════════════════
357
+ // COMPLETED IN CALL
358
+ // ══════════════════════════════════════════════════════
359
+ const completedInCall = yourTasks?.completed_in_call || [];
360
+ if (completedInCall.length > 0) {
361
+ ln('### ✅ Resolved During This Call');
362
+ ln('');
363
+ for (const item of completedInCall) {
364
+ ln(`- ✅ ${item}`);
365
+ }
366
+ ln('');
367
+ }
368
+
369
+ hr();
370
+ ln('');
371
+
372
+ // ══════════════════════════════════════════════════════
373
+ // YOUR TASKS (current user — prominent top section)
374
+ // ══════════════════════════════════════════════════════
375
+ if (currentUserCanonical && yourTasks) {
376
+ ln(`## ⭐ Your Tasks — ${currentUserCanonical}`);
377
+ ln('');
378
+
379
+ // Your overall summary
380
+ if (yourTasks.summary) {
381
+ ln(`> ${yourTasks.summary}`);
382
+ ln('');
383
+ }
384
+
385
+ // Owned tickets (compact)
386
+ const myTickets = dedupBy(
387
+ allTickets.filter(t => nameMatch(t.assignee, currentUserCanonical)),
388
+ t => t.ticket_id
389
+ );
390
+ if (myTickets.length > 0) {
391
+ ln(`**🎫 Your Tickets**: ${myTickets.map(t => `${t.ticket_id} (${(t.status || '?').replace(/_/g, ' ')})`).join(' · ')}`);
392
+ ln('');
393
+ }
394
+
395
+ // To-do items (merged from your_tasks.tasks_todo + action_items assigned to user)
396
+ const todoItems = dedupByDesc(yourTasks.tasks_todo || []);
397
+ const myActions = allActions.filter(ai =>
398
+ nameMatch(ai.assigned_to, currentUserCanonical) &&
399
+ (ai.status === 'todo' || ai.status === 'in_progress')
400
+ );
401
+ const allTodos = [...todoItems];
402
+ // Add action items not already in todo list (fuzzy match to avoid near-duplicates)
403
+ const todoDescKeys = new Set(allTodos.map(t => normalizeDesc(t.description)));
404
+ for (const ai of myActions) {
405
+ const dk = normalizeDesc(ai.description);
406
+ if (!todoDescKeys.has(dk)) {
407
+ allTodos.push(ai);
408
+ todoDescKeys.add(dk);
409
+ }
410
+ }
411
+ if (allTodos.length > 0) {
412
+ ln('### 📌 To Do');
413
+ ln('');
414
+ for (const item of allTodos) {
415
+ const pri = priBadge(item.priority);
416
+ const conf = confBadge(item.confidence);
417
+ const source = item.source ? ` _(${item.source})_` : '';
418
+ const ts = item.referenced_at ? ` @ ${fmtTs(item.referenced_at, item.source_segment)}` : '';
419
+ const blocker = item.blocked_by ? `\n - ⛔ **Blocked by**: ${item.blocked_by}` : '';
420
+ const relTickets = (item.related_tickets || []).length > 0 ? `\n - Tickets: ${item.related_tickets.join(', ')}` : '';
421
+ const relChanges = (item.related_changes || []).length > 0 ? `\n - Changes: ${item.related_changes.join(', ')}` : '';
422
+ ln(`- [ ] ${item.description}${pri}${conf}${source}${ts}${blocker}${relTickets}${relChanges}`);
423
+ }
424
+ ln('');
425
+ }
426
+
427
+ // CRs assigned to user
428
+ const myCRs = dedupBy(
429
+ allCRs.filter(cr => nameMatch(cr.assigned_to, currentUserCanonical) && cr.status !== 'completed'),
430
+ cr => cr.id
431
+ );
432
+ if (myCRs.length > 0) {
433
+ ln('### 🔧 Your Change Requests');
434
+ ln('');
435
+ for (const cr of myCRs) {
436
+ const status = cr.status ? ` \`${cr.status}\`` : '';
437
+ const pri = priBadge(cr.priority);
438
+ const where = cr.where?.file_path ? ` → \`${cr.where.file_path}\`` : '';
439
+ const ts = cr.referenced_at ? ` @ ${fmtTs(cr.referenced_at, cr.source_segment)}` : '';
440
+ const type = cr.type ? ` _[${cr.type}]_` : '';
441
+ ln(`- **${cr.id}**: ${cr.title || cr.what}${status}${pri}${type}${where}${ts}`);
442
+ if (cr.what && cr.what !== cr.title) ln(` - **What**: ${cr.what}`);
443
+ if (cr.how) ln(` - **How**: ${cr.how}`);
444
+ if (cr.why) ln(` - **Why**: ${cr.why}`);
445
+ if (cr.blocked_by) ln(` - ⛔ **Blocked by**: ${cr.blocked_by}`);
446
+ if (cr.code_map_match) ln(` - Code map: \`${cr.code_map_match}\``);
447
+ if ((cr.related_tickets || []).length > 0) ln(` - Tickets: ${cr.related_tickets.join(', ')}`);
448
+ }
449
+ ln('');
450
+ }
451
+
452
+ // Waiting on others
453
+ const waitingItems = dedupByDesc(yourTasks.tasks_waiting_on_others || []);
454
+ if (waitingItems.length > 0) {
455
+ ln('### ⏳ Waiting On Others');
456
+ ln('');
457
+ for (const w of waitingItems) {
458
+ const resolvedWho = resolve(w.waiting_on, clusterMap);
459
+ const ts = w.referenced_at ? ` @ ${fmtTs(w.referenced_at, w.source_segment)}` : '';
460
+ ln(`- ⏳ ${w.description} → waiting on **${resolvedWho}**${w.source ? ` _(${w.source})_` : ''}${ts}`);
461
+ }
462
+ ln('');
463
+ }
464
+
465
+ // Decisions needed
466
+ const decisionItems = dedupByDesc(yourTasks.decisions_needed || []);
467
+ if (decisionItems.length > 0) {
468
+ ln('### ❓ Decisions Needed');
469
+ ln('');
470
+ for (const d of decisionItems) {
471
+ const resolvedWho = resolve(d.from_whom, clusterMap);
472
+ const ts = d.referenced_at ? ` @ ${fmtTs(d.referenced_at, d.source_segment)}` : '';
473
+ ln(`- ${d.description} → from **${resolvedWho}**${d.source ? ` _(${d.source})_` : ''}${ts}`);
474
+ }
475
+ ln('');
476
+ }
477
+
478
+ // Blockers owned by user
479
+ const myBlockers = dedupBy(
480
+ allBlockers.filter(b => nameMatch(b.owner, currentUserCanonical)),
481
+ b => b.id
482
+ );
483
+ if (myBlockers.length > 0) {
484
+ ln('### 🚫 Your Blockers');
485
+ ln('');
486
+ for (const b of myBlockers) {
487
+ const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
488
+ const status = b.status ? ` (${b.status})` : '';
489
+ const type = b.type ? ` _[${b.type.replace(/_/g, ' ')}]_` : '';
490
+ const ts = b.referenced_at ? ` @ ${fmtTs(b.referenced_at, b.source_segment)}` : '';
491
+ const bConf = confBadge(b.confidence);
492
+ ln(`- **${b.id}**: ${b.description}${env}${status}${type}${bConf}${ts}`);
493
+ if (b.blocks?.length > 0) ln(` - Blocks: ${b.blocks.join(', ')}`);
494
+ if (b.checklist_match) ln(` - Checklist: ${b.checklist_match}`);
495
+ }
496
+ ln('');
497
+ }
498
+
499
+ hr();
500
+ ln('');
501
+ }
502
+
503
+ // ══════════════════════════════════════════════════════
504
+ // DETAILED TICKET ANALYSIS
505
+ // ══════════════════════════════════════════════════════
506
+ if (allTickets.length > 0) {
507
+ ln('## 🎫 Detailed Ticket Analysis');
508
+ ln('');
509
+
510
+ for (const t of allTickets) {
511
+ const assignee = t.assignee ? resolve(t.assignee, clusterMap) : 'Unassigned';
512
+ const reviewer = t.reviewer ? resolve(t.reviewer, clusterMap) : null;
513
+ const status = (t.status || 'unknown').replace(/_/g, ' ');
514
+
515
+ const tConf = confBadge(t.confidence);
516
+ ln(`### ${t.ticket_id} — ${t.title || 'Untitled'}${tConf}`);
517
+ ln('');
518
+ ln(`> **Status**: ${status} | **Assignee**: ${assignee}${reviewer ? ` | **Reviewer**: ${reviewer}` : ''}`);
519
+ if (t.confidence_reason) ln(`> **Confidence**: ${t.confidence} — ${t.confidence_reason}`);
520
+ ln('');
521
+
522
+ // Documented state
523
+ const ds = t.documented_state;
524
+ if (ds) {
525
+ ln('#### 📄 Documented State');
526
+ ln('');
527
+ if (ds.source) ln(`- **Source**: \`${ds.source}\``);
528
+ if (ds.plan_status) ln(`- **Plan Status**: ${ds.plan_status}`);
529
+ if (ds.checklist_progress) ln(`- **Checklist**: ${ds.checklist_progress}`);
530
+
531
+ // Sub-tickets
532
+ if (ds.sub_tickets && ds.sub_tickets.length > 0) {
533
+ ln('- **Sub-tickets**:');
534
+ for (const st of ds.sub_tickets) {
535
+ ln(` - **${st.id}** ${st.title} — ${st.documented_status || '?'}`);
536
+ }
537
+ }
538
+
539
+ // Open blockers from docs
540
+ if (ds.open_blockers && ds.open_blockers.length > 0) {
541
+ ln('- **Documented Blockers**:');
542
+ for (const ob of ds.open_blockers) {
543
+ ln(` - ⚠️ ${ob}`);
544
+ }
545
+ }
546
+ ln('');
547
+ }
548
+
549
+ // Discussed state (what happened in the call)
550
+ const disc = t.discussed_state;
551
+ if (disc) {
552
+ ln('#### 💬 Discussed in Call');
553
+ ln('');
554
+ if (disc.summary) ln(disc.summary);
555
+ ln('');
556
+
557
+ // Discrepancies between docs and call
558
+ if (disc.discrepancies && disc.discrepancies.length > 0) {
559
+ ln('**⚡ Discrepancies (docs vs. call)**:');
560
+ ln('');
561
+ for (const d of disc.discrepancies) {
562
+ ln(`- ⚡ ${d}`);
563
+ }
564
+ ln('');
565
+ }
566
+ }
567
+
568
+ // Video segments (timestamps)
569
+ const vs = t.video_segments || [];
570
+ if (vs.length > 0) {
571
+ ln('#### 🎬 Video Segments');
572
+ ln('');
573
+ for (const seg of vs) {
574
+ const start = seg.start_time || '?';
575
+ const end = seg.end_time || '?';
576
+ const segLabel = seg.source_segment ? ` _(Seg ${seg.source_segment})_` : '';
577
+ ln(`- \`${start}\` → \`${end}\`${segLabel}: ${seg.description}`);
578
+ }
579
+ ln('');
580
+ }
581
+
582
+ // Key comments (with timestamps and speakers)
583
+ const comments = t.comments || [];
584
+ if (comments.length > 0) {
585
+ ln('#### 🗣️ Key Quotes');
586
+ ln('');
587
+ for (const c of comments) {
588
+ const speaker = c.speaker ? resolve(c.speaker, clusterMap) : 'Unknown';
589
+ const ts = c.timestamp ? `\`${c.timestamp}\`` : '';
590
+ const segLabel = c.source_segment ? ` _(Seg ${c.source_segment})_` : '';
591
+ ln(`- ${ts}${segLabel} **${speaker}**: "${c.text}"`);
592
+ }
593
+ ln('');
594
+ }
595
+
596
+ // Code changes
597
+ const codeChanges = t.code_changes || [];
598
+ if (codeChanges.length > 0) {
599
+ ln('#### 💻 Code Changes');
600
+ ln('');
601
+ for (const cc of codeChanges) {
602
+ const type = cc.type ? `[${cc.type}]` : '';
603
+ const pri = priBadge(cc.priority);
604
+ const ts = cc.referenced_at ? ` @ ${fmtTs(cc.referenced_at, cc.source_segment)}` : '';
605
+ ln(`- ${type} \`${cc.file_path || '?'}\`${pri}${ts}`);
606
+ ln(` - ${cc.description}`);
607
+ if (cc.details && cc.details !== cc.description) {
608
+ ln(` - Details: ${cc.details}`);
609
+ }
610
+ }
611
+ ln('');
612
+ }
613
+
614
+ // Related CRs for this ticket
615
+ const ticketCRs = allCRs.filter(cr => (cr.related_tickets || []).includes(t.ticket_id));
616
+ if (ticketCRs.length > 0) {
617
+ ln(`#### 🔗 Change Requests for ${t.ticket_id}`);
618
+ ln('');
619
+ for (const cr of ticketCRs) {
620
+ const status = cr.status ? ` \`${cr.status}\`` : '';
621
+ const assignee = cr.assigned_to ? resolve(cr.assigned_to, clusterMap) : '?';
622
+ const ts = cr.referenced_at ? ` @ ${fmtTs(cr.referenced_at, cr.source_segment)}` : '';
623
+ ln(`- **${cr.id}**: ${cr.title || cr.what}${status} → ${assignee}${ts}`);
624
+ if (cr.what && cr.what !== cr.title) ln(` - What: ${cr.what}`);
625
+ if (cr.how) ln(` - How: ${cr.how}`);
626
+ if (cr.why) ln(` - Why: ${cr.why}`);
627
+ if (cr.where?.file_path) ln(` - File: \`${cr.where.file_path}\``);
628
+ }
629
+ ln('');
630
+ }
631
+
632
+ // Related blockers for this ticket
633
+ const ticketBlockers = allBlockers.filter(b => (b.blocks || []).includes(t.ticket_id));
634
+ if (ticketBlockers.length > 0) {
635
+ ln(`#### 🚫 Blockers for ${t.ticket_id}`);
636
+ ln('');
637
+ for (const b of ticketBlockers) {
638
+ const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
639
+ const status = b.status ? ` (${b.status})` : '';
640
+ const owner = b.owner ? ` → ${resolve(b.owner, clusterMap)}` : '';
641
+ const ts = b.referenced_at ? ` @ ${fmtTs(b.referenced_at, b.source_segment)}` : '';
642
+ ln(`- **${b.id}**: ${b.description}${env}${status}${owner}${ts}`);
643
+ }
644
+ ln('');
645
+ }
646
+
647
+ hr();
648
+ ln('');
649
+ }
650
+ }
651
+
652
+ // ══════════════════════════════════════════════════════
653
+ // ALL ACTION ITEMS (full detail table)
654
+ // ══════════════════════════════════════════════════════
655
+ if (allActions.length > 0) {
656
+ ln('## 📋 All Action Items');
657
+ ln('');
658
+ ln('| ID | Description | Assigned To | Status | Priority | Conf | Ref | Timestamp |');
659
+ ln('| --- | --- | --- | --- | --- | --- | --- | --- |');
660
+ for (const ai of allActions) {
661
+ const assignee = ai.assigned_to ? resolve(ai.assigned_to, clusterMap) : '-';
662
+ const status = (ai.status || '?').replace(/_/g, ' ');
663
+ const pri = ai.priority || '-';
664
+ const conf = ai.confidence || '-';
665
+ const confIcon = { HIGH: '🟢', MEDIUM: '🟡', LOW: '🔴' }[conf] || '';
666
+ const ref = [...(ai.related_tickets || []), ...(ai.related_changes || [])].join(', ') || '-';
667
+ const ts = ai.referenced_at || '-';
668
+ const seg = ai.source_segment ? `Seg ${ai.source_segment}` : '';
669
+ const dep = ai.depends_on ? ` ⛔ ${ai.depends_on}` : '';
670
+ const check = ai.checklist_match ? ` ✓${ai.checklist_match}` : '';
671
+ ln(`| ${ai.id} | ${ai.description}${dep}${check} | ${assignee} | ${status} | ${pri} | ${confIcon}${conf} | ${ref} | ${ts} ${seg} |`);
672
+ }
673
+ ln('');
674
+ }
675
+
676
+ // ══════════════════════════════════════════════════════
677
+ // OTHER PARTICIPANTS
678
+ // ══════════════════════════════════════════════════════
679
+ const otherPeople = orderedPeople.filter(p => p !== currentUserCanonical);
680
+ if (otherPeople.length > 0) {
681
+ ln('## 👥 Other Participants');
682
+ ln('');
683
+
684
+ for (const person of otherPeople) {
685
+ const personActions = allActions.filter(ai => nameMatch(ai.assigned_to, person));
686
+ const personCRs = dedupBy(
687
+ allCRs.filter(cr => nameMatch(cr.assigned_to, person) && cr.status !== 'completed'),
688
+ cr => cr.id
689
+ );
690
+ const personBlockersOwned = dedupBy(
691
+ allBlockers.filter(b => nameMatch(b.owner, person)),
692
+ b => b.id
693
+ );
694
+ const personTickets = dedupBy(
695
+ allTickets.filter(t => nameMatch(t.assignee, person)),
696
+ t => t.ticket_id
697
+ );
698
+
699
+ // Check if person has anything
700
+ const hasContent = personActions.length > 0 || personCRs.length > 0 ||
701
+ personBlockersOwned.length > 0 || personTickets.length > 0;
702
+
703
+ if (!hasContent) continue;
704
+
705
+ ln(`### ${person}`);
706
+ ln('');
707
+
708
+ // Owned tickets (inline)
709
+ if (personTickets.length > 0) {
710
+ ln(`**🎫 Tickets**: ${personTickets.map(t => `${t.ticket_id} (${(t.status || '?').replace(/_/g, ' ')})`).join(' · ')}`);
711
+ ln('');
712
+ }
713
+
714
+ // Action items
715
+ const actionableTodos = dedupByDesc(
716
+ personActions.filter(ai => ai.status === 'todo' || ai.status === 'in_progress')
717
+ );
718
+ if (actionableTodos.length > 0) {
719
+ ln('**📌 To Do**');
720
+ ln('');
721
+ for (const item of actionableTodos) {
722
+ const pri = priBadge(item.priority);
723
+ const ts = item.referenced_at ? ` @ ${fmtTs(item.referenced_at, item.source_segment)}` : '';
724
+ const ref = (item.related_tickets || []).length > 0 ? ` _(${item.related_tickets.join(', ')})_` : '';
725
+ const dep = item.depends_on ? ` ⛔ blocked by: ${item.depends_on}` : '';
726
+ ln(`- [ ] ${item.description}${pri}${ref}${ts}${dep}`);
727
+ }
728
+ ln('');
729
+ }
730
+
731
+ // Change requests
732
+ if (personCRs.length > 0) {
733
+ ln('**🔧 Change Requests**');
734
+ ln('');
735
+ for (const cr of personCRs) {
736
+ const status = cr.status ? ` \`${cr.status}\`` : '';
737
+ const pri = priBadge(cr.priority);
738
+ const where = cr.where?.file_path ? ` → \`${cr.where.file_path}\`` : '';
739
+ const ts = cr.referenced_at ? ` @ ${fmtTs(cr.referenced_at, cr.source_segment)}` : '';
740
+ ln(`- **${cr.id}**: ${cr.title || cr.what}${status}${pri}${where}${ts}`);
741
+ if (cr.what && cr.what !== cr.title) ln(` - What: ${cr.what}`);
742
+ if (cr.how) ln(` - How: ${cr.how}`);
743
+ if (cr.why) ln(` - Why: ${cr.why}`);
744
+ }
745
+ ln('');
746
+ }
747
+
748
+ // Blockers
749
+ if (personBlockersOwned.length > 0) {
750
+ ln('**🚫 Blockers**');
751
+ ln('');
752
+ for (const b of personBlockersOwned) {
753
+ const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
754
+ const status = b.status ? ` (${b.status})` : '';
755
+ const type = b.type ? ` _[${b.type.replace(/_/g, ' ')}]_` : '';
756
+ const ts = b.referenced_at ? ` @ ${fmtTs(b.referenced_at, b.source_segment)}` : '';
757
+ ln(`- **${b.id}**: ${b.description}${env}${status}${type}${ts}`);
758
+ if (b.blocks?.length > 0) ln(` - Blocks: ${b.blocks.join(', ')}`);
759
+ }
760
+ ln('');
761
+ }
762
+
763
+ hr();
764
+ ln('');
765
+ }
766
+ }
767
+
768
+ // ══════════════════════════════════════════════════════
769
+ // TEAM / EXTERNAL BLOCKERS
770
+ // ══════════════════════════════════════════════════════
771
+ const teamBlockers = dedupBy(
772
+ allBlockers.filter(b => !orderedPeople.some(p => nameMatch(b.owner, p))),
773
+ b => b.id
774
+ );
775
+ if (teamBlockers.length > 0) {
776
+ ln('## 🚫 Team / External Blockers');
777
+ ln('');
778
+ for (const b of teamBlockers) {
779
+ const env = (b.environments || []).length > 0 ? ` [${b.environments.join(', ')}]` : '';
780
+ const status = b.status ? ` (${b.status})` : '';
781
+ const type = b.type ? ` _[${b.type.replace(/_/g, ' ')}]_` : '';
782
+ const ts = b.referenced_at ? ` @ ${fmtTs(b.referenced_at, b.source_segment)}` : '';
783
+ const check = b.checklist_match ? ` ✓${b.checklist_match}` : '';
784
+ ln(`- **${b.id}** (${b.owner || 'unassigned'}): ${b.description}${env}${status}${type}${ts}${check}`);
785
+ if (b.blocks?.length > 0) ln(` - Blocks: ${b.blocks.join(', ')}`);
786
+ }
787
+ ln('');
788
+ hr();
789
+ ln('');
790
+ }
791
+
792
+ // ══════════════════════════════════════════════════════
793
+ // SCOPE CHANGES
794
+ // ══════════════════════════════════════════════════════
795
+ if (allScope.length > 0) {
796
+ ln('## 🔀 Scope Changes');
797
+ ln('');
798
+ for (const sc of allScope) {
799
+ const icon = { added: '➕', removed: '➖', deferred: '⏸️', approach_changed: '🔄', ownership_changed: '👤', requirements_changed: '📋' }[sc.type] || '🔀';
800
+ const decidedBy = sc.decided_by ? resolve(sc.decided_by, clusterMap) : null;
801
+ const ts = sc.referenced_at ? ` @ ${fmtTs(sc.referenced_at, sc.source_segment)}` : '';
802
+ const scConf = confBadge(sc.confidence);
803
+ ln(`- ${icon} **${sc.id}** (${(sc.type || '').replace(/_/g, ' ')}): ${sc.new_scope}${scConf}${ts}`);
804
+ if (sc.original_scope && sc.original_scope !== 'not documented') {
805
+ ln(` - **Was**: ${sc.original_scope}`);
806
+ }
807
+ if (sc.reason) ln(` - **Reason**: ${sc.reason}`);
808
+ if (decidedBy) ln(` - **Decided by**: ${decidedBy}`);
809
+ if (sc.impact) ln(` - **Impact**: \`${sc.impact}\``);
810
+ if ((sc.related_tickets || []).length > 0) ln(` - **Tickets**: ${sc.related_tickets.join(', ')}`);
811
+ }
812
+ ln('');
813
+ hr();
814
+ ln('');
815
+ }
816
+
817
+ // ══════════════════════════════════════════════════════
818
+ // ALL CHANGE REQUESTS (complete table)
819
+ // ══════════════════════════════════════════════════════
820
+ if (allCRs.length > 0) {
821
+ ln('## 🔧 All Change Requests');
822
+ ln('');
823
+ ln('| ID | Title | Type | Status | Priority | Conf | Assignee | File | Timestamp |');
824
+ ln('| --- | --- | --- | --- | --- | --- | --- | --- | --- |');
825
+ for (const cr of allCRs) {
826
+ const assignee = cr.assigned_to ? resolve(cr.assigned_to, clusterMap) : '-';
827
+ const status = cr.status || '-';
828
+ const pri = cr.priority || '-';
829
+ const conf = cr.confidence || '-';
830
+ const confIcon = { HIGH: '🟢', MEDIUM: '🟡', LOW: '🔴' }[conf] || '';
831
+ const type = cr.type || '-';
832
+ const file = cr.where?.file_path ? `\`${cr.where.file_path}\`` : '-';
833
+ const ts = cr.referenced_at || '-';
834
+ const seg = cr.source_segment ? `Seg ${cr.source_segment}` : '';
835
+ ln(`| ${cr.id} | ${cr.title || cr.what || '-'} | ${type} | ${status} | ${pri} | ${confIcon}${conf} | ${assignee} | ${file} | ${ts} ${seg} |`);
836
+ }
837
+ ln('');
838
+
839
+ // Detailed breakdown in collapsible
840
+ ln('<details>');
841
+ ln('<summary>📖 Change Request Details</summary>');
842
+ ln('');
843
+ for (const cr of allCRs) {
844
+ const assignee = cr.assigned_to ? resolve(cr.assigned_to, clusterMap) : '?';
845
+ const status = cr.status ? ` \`${cr.status}\`` : '';
846
+ const ts = cr.referenced_at ? ` @ ${fmtTs(cr.referenced_at, cr.source_segment)}` : '';
847
+ ln(`**${cr.id}**: ${cr.title || cr.what}${status} → ${assignee}${ts}`);
848
+ if (cr.what && cr.what !== cr.title) ln(`- What: ${cr.what}`);
849
+ if (cr.how) ln(`- How: ${cr.how}`);
850
+ if (cr.why) ln(`- Why: ${cr.why}`);
851
+ if (cr.where?.file_path) ln(`- File: \`${cr.where.file_path}\` (${cr.where.module || '?'}/${cr.where.component || '?'})`);
852
+ if (cr.code_map_match) ln(`- Code map: \`${cr.code_map_match}\``);
853
+ if ((cr.related_tickets || []).length > 0) ln(`- Tickets: ${cr.related_tickets.join(', ')}`);
854
+ ln('');
855
+ }
856
+ ln('</details>');
857
+ ln('');
858
+ hr();
859
+ ln('');
860
+ }
861
+
862
+ // ══════════════════════════════════════════════════════
863
+ // FILE REFERENCES (complete — not just actionable)
864
+ // ══════════════════════════════════════════════════════
865
+ if (allFiles.length > 0) {
866
+ // Split into actionable and reference-only
867
+ const actionableFiles = allFiles.filter(f => f.role && !['reference_only', 'source_of_truth'].includes(f.role));
868
+ const referenceFiles = allFiles.filter(f => !f.role || ['reference_only', 'source_of_truth'].includes(f.role));
869
+
870
+ if (actionableFiles.length > 0) {
871
+ ln('## 📂 Files Requiring Action');
872
+ ln('');
873
+ ln('| File | Role | Type | Tickets | Changes | Path |');
874
+ ln('| --- | --- | --- | --- | --- | --- |');
875
+ for (const f of actionableFiles) {
876
+ const role = (f.role || '').replace(/_/g, ' ');
877
+ const type = (f.file_type || '').replace(/_/g, ' ');
878
+ const tickets = (f.mentioned_in_tickets || []).join(', ') || '-';
879
+ const changes = (f.mentioned_in_changes || []).join(', ') || '-';
880
+ const fpath = f.resolved_path || '-';
881
+ ln(`| ${f.file_name} | ${role} | ${type} | ${tickets} | ${changes} | \`${fpath}\` |`);
882
+ }
883
+ ln('');
884
+ }
885
+
886
+ if (referenceFiles.length > 0) {
887
+ ln('<details>');
888
+ ln(`<summary>📎 Reference Files (${referenceFiles.length} files — not requiring changes)</summary>`);
889
+ ln('');
890
+ ln('| File | Type | Tickets | Context Doc | Notes |');
891
+ ln('| --- | --- | --- | --- | --- |');
892
+ for (const f of referenceFiles) {
893
+ const type = (f.file_type || '').replace(/_/g, ' ');
894
+ const tickets = (f.mentioned_in_tickets || []).join(', ') || '-';
895
+ const ctxDoc = f.context_doc_match || '-';
896
+ const notes = f.notes ? f.notes.slice(0, 80) + (f.notes.length > 80 ? '...' : '') : '-';
897
+ ln(`| ${f.file_name} | ${type} | ${tickets} | ${ctxDoc} | ${notes} |`);
898
+ }
899
+ ln('');
900
+ ln('</details>');
901
+ ln('');
902
+ }
903
+ }
904
+
905
+ // ── Footer ──
906
+ hr();
907
+ ln('');
908
+ const genTs = new Date().toISOString().slice(0, 19).replace('T', ' ');
909
+ const stats = [];
910
+ stats.push(`${allTickets.length} tickets`);
911
+ stats.push(`${allCRs.length} change requests`);
912
+ stats.push(`${allActions.length} action items`);
913
+ stats.push(`${allBlockers.length} blockers`);
914
+ stats.push(`${allScope.length} scope changes`);
915
+ stats.push(`${allFiles.length} file references`);
916
+ ln(`_Generated ${genTs} — AI-compiled final result | ${stats.join(' · ')}_`);
917
+ ln('');
918
+
919
+ return lines.join('\n');
920
+ }
921
+
922
+ /**
923
+ * Legacy renderer — renders from the raw results object (merges all segment analyses).
924
+ * Applies ID-based dedup before rendering to reduce duplicates from naive flat merge.
925
+ * Kept for backward compatibility. Use renderResultsMarkdown() for new code.
926
+ */
927
+ function renderResultsMarkdownLegacy(results) {
928
+ // Collect all analyses across all files/segments
929
+ const allAnalyses = [];
930
+ for (const file of (results.files || [])) {
931
+ for (const seg of (file.segments || [])) {
932
+ if (seg.analysis && !seg.analysis.error) {
933
+ allAnalyses.push({ seg: seg.segmentFile, ...seg.analysis });
934
+ }
935
+ }
936
+ }
937
+
938
+ if (allAnalyses.length === 0) {
939
+ return '# Call Analysis\n\nNo segments were successfully analyzed.\n';
940
+ }
941
+
942
+ // Merge all data — dedup applied at render time via dedupBy
943
+ const merged = {
944
+ tickets: allAnalyses.flatMap(a => a.tickets || []),
945
+ change_requests: allAnalyses.flatMap(a => a.change_requests || []),
946
+ action_items: allAnalyses.flatMap(a => a.action_items || []),
947
+ blockers: allAnalyses.flatMap(a => a.blockers || []),
948
+ scope_changes: allAnalyses.flatMap(a => a.scope_changes || []),
949
+ file_references: allAnalyses.flatMap(a => a.file_references || []),
950
+ summary: allAnalyses.map(a => a.summary).filter(Boolean).pop() || '',
951
+ your_tasks: allAnalyses.map(a => a.your_tasks).filter(Boolean).pop() || null,
952
+ };
953
+
954
+ const segmentCount = allAnalyses.length;
955
+
956
+ return renderResultsMarkdown({
957
+ compiled: merged,
958
+ meta: {
959
+ callName: results.callName,
960
+ processedAt: results.processedAt,
961
+ geminiModel: results.settings?.geminiModel,
962
+ userName: results.userName,
963
+ segmentCount,
964
+ },
965
+ });
966
+ }
967
+
968
+ module.exports = { renderResultsMarkdown, renderResultsMarkdownLegacy };