task-summary-extractor 9.1.0 → 9.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-summary-extractor",
3
- "version": "9.1.0",
3
+ "version": "9.2.1",
4
4
  "description": "AI-powered meeting analysis & document generation CLI — video + document processing, deep dive docs, dynamic mode, interactive CLI with model selection, confidence scoring, learning loop, git progress tracking",
5
5
  "main": "process_and_upload.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@ const { compileFinalResult } = require('../services/gemini');
8
8
 
9
9
  // --- Utils ---
10
10
  const { calculateCompilationBudget } = require('../utils/adaptive-budget');
11
- const { validateAnalysis, formatSchemaLine } = require('../utils/schema-validator');
11
+ const { validateAnalysis, formatSchemaLine, normalizeAnalysis } = require('../utils/schema-validator');
12
12
  const { c } = require('../utils/colors');
13
13
 
14
14
  // --- Shared state ---
@@ -41,7 +41,7 @@ async function phaseCompile(ctx, allSegmentAnalyses) {
41
41
  { thinkingBudget: compBudget.budget }
42
42
  );
43
43
 
44
- compiledAnalysis = compilationResult.compiled;
44
+ compiledAnalysis = normalizeAnalysis(compilationResult.compiled);
45
45
  compilationRun = compilationResult.run;
46
46
 
47
47
  // Track compilation cost
@@ -12,7 +12,7 @@ const {
12
12
 
13
13
  // --- Utils ---
14
14
  const { c } = require('../utils/colors');
15
- const { parseArgs, showHelp, selectFolder, selectModel } = require('../utils/cli');
15
+ const { parseArgs, showHelp, selectFolder, selectModel, selectRunMode, selectFormats, selectConfidence } = require('../utils/cli');
16
16
  const { promptForKey } = require('../utils/global-config');
17
17
  const Logger = require('../logger');
18
18
  const Progress = require('../utils/checkpoint');
@@ -73,9 +73,78 @@ async function phaseInit() {
73
73
  repoPath: flags.repo || null,
74
74
  model: typeof flags.model === 'string' ? flags.model : null,
75
75
  minConfidence: typeof flags['min-confidence'] === 'string' ? flags['min-confidence'].toLowerCase() : null,
76
- format: typeof flags.format === 'string' ? flags.format.toLowerCase() : 'all',
76
+ format: typeof flags.format === 'string' ? flags.format.toLowerCase() : null,
77
+ runMode: null, // will be populated by interactive selector or inferred
77
78
  };
78
79
 
80
+ // --- Determine if user provided enough flags to skip interactive mode ---
81
+ const hasExplicitMode = opts.model || flags['no-focused-pass'] || flags['no-learning'] || flags['no-diff'] || opts.format;
82
+ const isNonInteractive = !process.stdin.isTTY;
83
+
84
+ // --- Interactive Run-Mode selector (only when TTY and no explicit flags) ---
85
+ if (!hasExplicitMode && !isNonInteractive && !opts.skipGemini) {
86
+ // Show the welcome banner
87
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
88
+ console.log('');
89
+ console.log(c.heading(' ┌──────────────────────────────────────────────────────────────────────────────┐'));
90
+ console.log(c.heading(` │ ${c.bold('taskex')} ${c.dim(`v${pkg.version}`)} — AI-powered meeting analysis │`));
91
+ console.log(c.heading(' └──────────────────────────────────────────────────────────────────────────────┘'));
92
+
93
+ const mode = await selectRunMode();
94
+ opts.runMode = mode;
95
+
96
+ if (mode !== 'custom') {
97
+ // Apply preset overrides
98
+ const { selectRunMode: _ignore, ...cliModule } = require('../utils/cli');
99
+ // Access RUN_PRESETS from the module
100
+ const presetOverrides = {
101
+ fast: {
102
+ disableFocusedPass: true,
103
+ disableLearning: true,
104
+ disableDiff: true,
105
+ format: 'md,json',
106
+ formats: new Set(['md', 'json']),
107
+ modelTier: 'economy',
108
+ },
109
+ balanced: {
110
+ disableFocusedPass: false,
111
+ disableLearning: false,
112
+ disableDiff: false,
113
+ format: 'all',
114
+ formats: new Set(['md', 'html', 'json', 'pdf', 'docx']),
115
+ modelTier: 'balanced',
116
+ },
117
+ detailed: {
118
+ disableFocusedPass: false,
119
+ disableLearning: false,
120
+ disableDiff: false,
121
+ format: 'all',
122
+ formats: new Set(['md', 'html', 'json', 'pdf', 'docx']),
123
+ modelTier: 'premium',
124
+ },
125
+ };
126
+ const preset = presetOverrides[mode];
127
+ if (preset) {
128
+ opts.disableFocusedPass = preset.disableFocusedPass;
129
+ opts.disableLearning = preset.disableLearning;
130
+ opts.disableDiff = preset.disableDiff;
131
+ opts.format = preset.format;
132
+ opts.formats = preset.formats;
133
+ opts._modelTier = preset.modelTier; // used later for model auto-selection
134
+ }
135
+ } else {
136
+ // Custom mode: show interactive pickers for format & confidence
137
+ const chosenFormats = await selectFormats();
138
+ opts.formats = chosenFormats;
139
+ opts.format = chosenFormats.size === 5 ? 'all' : [...chosenFormats].join(',');
140
+
141
+ const chosenConfidence = await selectConfidence();
142
+ if (chosenConfidence) {
143
+ opts.minConfidence = chosenConfidence;
144
+ }
145
+ }
146
+ }
147
+
79
148
  // --- Validate min-confidence level ---
80
149
  if (opts.minConfidence) {
81
150
  const { validateConfidenceLevel } = require('../utils/confidence-filter');
@@ -87,18 +156,22 @@ async function phaseInit() {
87
156
  }
88
157
 
89
158
  // --- Validate --format flag (supports comma-separated: md,html,pdf) ---
90
- const VALID_FORMATS = new Set(['md', 'html', 'json', 'pdf', 'docx', 'all']);
91
- const requestedFormats = opts.format.split(',').map(f => f.trim()).filter(Boolean);
92
- const invalidFormats = requestedFormats.filter(f => !VALID_FORMATS.has(f));
93
- if (invalidFormats.length > 0) {
94
- throw new Error(`Invalid --format "${invalidFormats.join(', ')}". Valid: md, html, json, pdf, docx, all`);
159
+ // If format wasn't set by interactive picker or flag, default to 'all'
160
+ if (!opts.format) opts.format = 'all';
161
+ if (!opts.formats) {
162
+ const VALID_FORMATS = new Set(['md', 'html', 'json', 'pdf', 'docx', 'all']);
163
+ const requestedFormats = opts.format.split(',').map(f => f.trim()).filter(Boolean);
164
+ const invalidFormats = requestedFormats.filter(f => !VALID_FORMATS.has(f));
165
+ if (invalidFormats.length > 0) {
166
+ throw new Error(`Invalid --format "${invalidFormats.join(', ')}". Valid: md, html, json, pdf, docx, all`);
167
+ }
168
+ // Normalise: "all" or set of specific formats
169
+ opts.formats = requestedFormats.includes('all')
170
+ ? new Set(['md', 'html', 'json', 'pdf', 'docx'])
171
+ : new Set(requestedFormats);
172
+ // Keep opts.format as the original string for backwards compatibility
173
+ opts.format = requestedFormats.includes('all') ? 'all' : requestedFormats.join(',');
95
174
  }
96
- // Normalise: "all" or set of specific formats
97
- opts.formats = requestedFormats.includes('all')
98
- ? new Set(['md', 'html', 'json', 'pdf', 'docx'])
99
- : new Set(requestedFormats);
100
- // Keep opts.format as the original string for backwards compatibility
101
- opts.format = requestedFormats.includes('all') ? 'all' : requestedFormats.join(',');
102
175
 
103
176
  // --- Resolve folder: positional arg or interactive selection ---
104
177
  let folderArg = positional[0];
@@ -186,6 +259,21 @@ async function phaseInit() {
186
259
  // CLI flag: --model <id> — validate and activate
187
260
  setActiveModel(opts.model);
188
261
  log.step(`Model set via flag: ${config.GEMINI_MODEL}`);
262
+ } else if (opts._modelTier) {
263
+ // Preset-driven: auto-select the best model for the chosen tier
264
+ const modelIds = Object.keys(GEMINI_MODELS);
265
+ const tierModel = modelIds.find(id => GEMINI_MODELS[id].tier === opts._modelTier);
266
+ if (tierModel) {
267
+ setActiveModel(tierModel);
268
+ console.log(c.success(`Model auto-selected: ${GEMINI_MODELS[tierModel].name} (${opts._modelTier} tier)`));
269
+ log.step(`Model auto-selected for ${opts._modelTier} tier: ${config.GEMINI_MODEL}`);
270
+ } else {
271
+ // Fallback to interactive if tier not found
272
+ const chosenModel = await selectModel(GEMINI_MODELS, config.GEMINI_MODEL);
273
+ setActiveModel(chosenModel);
274
+ log.step(`Model selected: ${config.GEMINI_MODEL}`);
275
+ }
276
+ delete opts._modelTier;
189
277
  } else {
190
278
  // Interactive model selection
191
279
  const chosenModel = await selectModel(GEMINI_MODELS, config.GEMINI_MODEL);
@@ -193,6 +281,9 @@ async function phaseInit() {
193
281
  log.step(`Model selected: ${config.GEMINI_MODEL}`);
194
282
  }
195
283
 
284
+ // --- Print run summary ---
285
+ _printRunSummary(opts, config.GEMINI_MODEL, GEMINI_MODELS, targetDir);
286
+
196
287
  // --- Initialize progress tracking ---
197
288
  const progress = new Progress(targetDir);
198
289
  const costTracker = new CostTracker(getActiveModelPricing());
@@ -204,4 +295,56 @@ async function phaseInit() {
204
295
  return { opts, targetDir, progress, costTracker, progressBar };
205
296
  }
206
297
 
207
- module.exports = phaseInit;
298
+ /**
299
+ * Print a compact run summary with all active settings.
300
+ */
301
+ function _printRunSummary(opts, modelId, models, targetDir) {
302
+ const modelName = (models[modelId] || {}).name || modelId;
303
+ const tier = (models[modelId] || {}).tier || '?';
304
+ const cost = (models[modelId] || {}).costEstimate || '';
305
+
306
+ console.log('');
307
+ console.log(c.heading(' ┌──────────────────────────────────────────────────────────────────────────────┐'));
308
+ console.log(c.heading(' │ 📋 Run Summary │'));
309
+ console.log(c.heading(' └──────────────────────────────────────────────────────────────────────────────┘'));
310
+ console.log('');
311
+ console.log(` ${c.dim('Folder:')} ${c.bold(path.basename(targetDir))}`);
312
+ console.log(` ${c.dim('Model:')} ${c.bold(modelName)} ${c.dim(`(${tier})`)} ${cost ? c.dim(cost) : ''}`);
313
+ console.log(` ${c.dim('Formats:')} ${c.bold(opts.format === 'all' ? 'all (md, html, json, pdf, docx)' : opts.format)}`);
314
+
315
+ if (opts.minConfidence) {
316
+ console.log(` ${c.dim('Confidence:')} ${c.bold(opts.minConfidence)}+`);
317
+ }
318
+
319
+ // Feature toggles
320
+ const features = [];
321
+ if (!opts.disableFocusedPass) features.push(c.green('focused-pass'));
322
+ if (!opts.disableLearning) features.push(c.green('learning'));
323
+ if (!opts.disableDiff) features.push(c.green('diff'));
324
+ if (opts.deepDive) features.push(c.cyan('deep-dive'));
325
+ if (opts.dynamic) features.push(c.cyan('dynamic'));
326
+ if (opts.resume) features.push(c.yellow('resume'));
327
+ if (opts.dryRun) features.push(c.yellow('dry-run'));
328
+ if (opts.skipUpload) features.push(c.dim('skip-upload'));
329
+
330
+ const disabled = [];
331
+ if (opts.disableFocusedPass) disabled.push(c.dim('no-focused'));
332
+ if (opts.disableLearning) disabled.push(c.dim('no-learning'));
333
+ if (opts.disableDiff) disabled.push(c.dim('no-diff'));
334
+
335
+ if (features.length > 0) {
336
+ console.log(` ${c.dim('Features:')} ${features.join(c.dim(' · '))}`);
337
+ }
338
+ if (disabled.length > 0) {
339
+ console.log(` ${c.dim('Disabled:')} ${disabled.join(c.dim(' · '))}`);
340
+ }
341
+
342
+ if (opts.runMode) {
343
+ console.log(` ${c.dim('Run mode:')} ${c.bold(opts.runMode)}`);
344
+ }
345
+
346
+ console.log(c.dim(' ' + '─'.repeat(78)));
347
+ console.log('');
348
+ }
349
+
350
+ module.exports = phaseInit;
@@ -17,7 +17,7 @@ const { fmtDuration, fmtBytes } = require('../utils/format');
17
17
  const { promptUser } = require('../utils/cli');
18
18
  const { parallelMap } = require('../utils/retry');
19
19
  const { assessQuality, formatQualityLine, getConfidenceStats, THRESHOLDS } = require('../utils/quality-gate');
20
- const { validateAnalysis, formatSchemaLine, schemaScore } = require('../utils/schema-validator');
20
+ const { validateAnalysis, formatSchemaLine, schemaScore, normalizeAnalysis } = require('../utils/schema-validator');
21
21
  const { calculateThinkingBudget } = require('../utils/adaptive-budget');
22
22
  const { detectBoundaryContext, sliceVttForSegment } = require('../utils/context-manager');
23
23
 
@@ -264,7 +264,7 @@ async function phaseProcessVideo(ctx, videoPath, videoIndex) {
264
264
  try {
265
265
  const existingRun = JSON.parse(fs.readFileSync(latestRunPath, 'utf8'));
266
266
  geminiRunFile = path.relative(PROJECT_ROOT, path.join(geminiRunsDir, latestRunFile));
267
- analysis = existingRun.output.parsed || { rawResponse: existingRun.output.raw };
267
+ analysis = normalizeAnalysis(existingRun.output.parsed || { rawResponse: existingRun.output.raw });
268
268
  analysis._geminiMeta = {
269
269
  model: existingRun.run.model,
270
270
  processedAt: existingRun.run.timestamp,
@@ -396,7 +396,7 @@ async function phaseProcessVideo(ctx, videoPath, videoIndex) {
396
396
  geminiFileName = geminiRun.input.videoFile.geminiFileName || null;
397
397
  const usedExternalUrl = geminiRun.input.videoFile.usedExternalUrl || false;
398
398
 
399
- analysis = geminiRun.output.parsed || { rawResponse: geminiRun.output.raw };
399
+ analysis = normalizeAnalysis(geminiRun.output.parsed || { rawResponse: geminiRun.output.raw });
400
400
  analysis._geminiMeta = {
401
401
  model: geminiRun.run.model,
402
402
  processedAt: geminiRun.run.timestamp,
@@ -474,7 +474,7 @@ async function phaseProcessVideo(ctx, videoPath, videoIndex) {
474
474
  const retryRunFilePath = path.join(geminiRunsDir, retryRunFileName);
475
475
  fs.writeFileSync(retryRunFilePath, JSON.stringify(retryRun, null, 2), 'utf8');
476
476
 
477
- const retryAnalysis = retryRun.output.parsed || { rawResponse: retryRun.output.raw };
477
+ const retryAnalysis = normalizeAnalysis(retryRun.output.parsed || { rawResponse: retryRun.output.raw });
478
478
  const retryQuality = assessQuality(retryAnalysis, {
479
479
  parseSuccess: retryRun.output.parseSuccess,
480
480
  rawLength: (retryRun.output.raw || '').length,
@@ -492,7 +492,7 @@ async function phaseProcessVideo(ctx, videoPath, videoIndex) {
492
492
  // Use retry result if better
493
493
  if (retryQuality.score > qualityReport.score) {
494
494
  retryImproved = true;
495
- analysis = retryAnalysis;
495
+ analysis = normalizeAnalysis(retryAnalysis);
496
496
  analysis._geminiMeta = {
497
497
  model: retryRun.run.model,
498
498
  processedAt: retryRun.run.timestamp,
package/src/pipeline.js CHANGED
@@ -231,7 +231,7 @@ async function run() {
231
231
  async function runDocOnly(ctx) {
232
232
  // Lazy imports for doc-only mode
233
233
  const { compileFinalResult } = require('./services/gemini');
234
- const { validateAnalysis, formatSchemaLine } = require('./utils/schema-validator');
234
+ const { validateAnalysis, formatSchemaLine, normalizeAnalysis } = require('./utils/schema-validator');
235
235
  const { renderResultsMarkdown } = require('./renderers/markdown');
236
236
  const { renderResultsHtml } = require('./renderers/html');
237
237
  const { renderResultsPdf } = require('./renderers/pdf');
@@ -297,7 +297,7 @@ async function runDocOnly(ctx) {
297
297
  }
298
298
  );
299
299
 
300
- compiledAnalysis = compilationResult.compiled;
300
+ compiledAnalysis = normalizeAnalysis(compilationResult.compiled);
301
301
  compilationRun = compilationResult.run;
302
302
 
303
303
  if (compilationRun?.tokenUsage) {
@@ -5,7 +5,7 @@
5
5
  "description": "JSON Schema for a single segment's AI analysis output. Uses additionalProperties:true to allow extra fields without failing.",
6
6
  "type": "object",
7
7
  "additionalProperties": true,
8
- "required": ["tickets", "action_items", "change_requests", "summary"],
8
+ "required": ["summary"],
9
9
  "properties": {
10
10
  "tickets": {
11
11
  "type": "array",
package/src/utils/cli.js CHANGED
@@ -354,6 +354,11 @@ function showHelp() {
354
354
  ${c.bold('taskex setup')} ${c.dim('[--check | --silent]')}
355
355
  ${c.bold('taskex config')} ${c.dim('[--show | --clear]')}
356
356
 
357
+ ${h('INTERACTIVE MODE')}
358
+ ${f('taskex', 'Launch interactive wizard — choose run mode, model, format, confidence')}
359
+ ${f2('Run modes: ⚡ Fast · ⚖️ Balanced · 🔬 Detailed · ⚙️ Custom')}
360
+ ${f2('When flags are provided, interactive prompts are skipped automatically.')}
361
+
357
362
  ${h('SUBCOMMANDS')}
358
363
  ${f('setup', 'Full interactive setup (prerequisites, deps, .env)')}
359
364
  ${f('setup --check', 'Validation only — verify environment')}
@@ -417,8 +422,10 @@ ${f('--help, -h', 'Show this help message')}
417
422
  ${f('--version, -v', 'Show version')}
418
423
 
419
424
  ${h('EXAMPLES')}
420
- ${c.dim('$')} taskex ${c.dim('# Interactive mode')}
421
- ${c.dim('$')} taskex "call 1" ${c.dim('# Analyze a call')}
425
+ ${c.dim('$')} taskex ${c.dim('# Interactive wizard — choose mode, model, format')}
426
+ ${c.dim('$')} taskex "call 1" ${c.dim('# Analyze a call (interactive wizard)')}
427
+ ${c.dim('$')} taskex --model gemini-2.5-pro "call 1" ${c.dim('# Skip wizard, use specific model')}
428
+ ${c.dim('$')} taskex --format md,html "call 1" ${c.dim('# Skip wizard, specific formats')}
422
429
  ${c.dim('$')} taskex --name "Jane" --skip-upload "call 1"
423
430
  ${c.dim('$')} taskex --model gemini-2.5-pro --deep-dive "call 1"
424
431
  ${c.dim('$')} taskex --dynamic --request "Plan API migration" "specs"
@@ -434,7 +441,10 @@ ${f('--version, -v', 'Show version')}
434
441
  throw Object.assign(new Error('HELP_SHOWN'), { code: 'HELP_SHOWN' });
435
442
  }
436
443
 
437
- module.exports = { parseArgs, showHelp, discoverFolders, selectFolder, selectModel, promptUser, promptUserText };
444
+ module.exports = {
445
+ parseArgs, showHelp, discoverFolders, selectFolder, selectModel,
446
+ promptUser, promptUserText, selectRunMode, selectFormats, selectConfidence,
447
+ };
438
448
 
439
449
  // ======================== INTERACTIVE PROMPTS ========================
440
450
 
@@ -462,3 +472,252 @@ function promptUserText(question) {
462
472
  });
463
473
  });
464
474
  }
475
+
476
+ // ======================== RUN MODE SELECTOR ========================
477
+
478
+ /**
479
+ * Run-mode presets. Each key maps to a set of opts overrides.
480
+ * 'custom' triggers the interactive per-setting prompts.
481
+ */
482
+ const RUN_PRESETS = {
483
+ fast: {
484
+ label: 'Fast',
485
+ icon: '⚡',
486
+ description: 'Economy model, skip extras, Markdown + JSON only — fastest & cheapest',
487
+ overrides: {
488
+ disableFocusedPass: true,
489
+ disableLearning: true,
490
+ disableDiff: true,
491
+ format: 'md,json',
492
+ formats: new Set(['md', 'json']),
493
+ modelTier: 'economy',
494
+ },
495
+ },
496
+ balanced: {
497
+ label: 'Balanced',
498
+ icon: '⚖️',
499
+ description: 'Balanced model, learning enabled, all formats — recommended default',
500
+ overrides: {
501
+ disableFocusedPass: false,
502
+ disableLearning: false,
503
+ disableDiff: false,
504
+ format: 'all',
505
+ formats: new Set(['md', 'html', 'json', 'pdf', 'docx']),
506
+ modelTier: 'balanced',
507
+ },
508
+ },
509
+ detailed: {
510
+ label: 'Detailed',
511
+ icon: '🔬',
512
+ description: 'Premium model, all features, all formats — highest quality analysis',
513
+ overrides: {
514
+ disableFocusedPass: false,
515
+ disableLearning: false,
516
+ disableDiff: false,
517
+ format: 'all',
518
+ formats: new Set(['md', 'html', 'json', 'pdf', 'docx']),
519
+ modelTier: 'premium',
520
+ },
521
+ },
522
+ custom: {
523
+ label: 'Custom',
524
+ icon: '⚙️',
525
+ description: 'Choose each setting interactively',
526
+ overrides: {},
527
+ },
528
+ };
529
+
530
+ /**
531
+ * Interactive run-mode selector. Shows preset options and returns the chosen
532
+ * preset key. The caller applies overrides to opts.
533
+ *
534
+ * @returns {Promise<string>} Preset key: 'fast' | 'balanced' | 'detailed' | 'custom'
535
+ */
536
+ async function selectRunMode() {
537
+ const readline = require('readline');
538
+ const presetKeys = Object.keys(RUN_PRESETS);
539
+
540
+ console.log('');
541
+ console.log(c.heading(' ┌──────────────────────────────────────────────────────────────────────────────┐'));
542
+ console.log(c.heading(' │ 🚀 Run Mode │'));
543
+ console.log(c.heading(' └──────────────────────────────────────────────────────────────────────────────┘'));
544
+ console.log('');
545
+
546
+ presetKeys.forEach((key, i) => {
547
+ const p = RUN_PRESETS[key];
548
+ const num = c.cyan(`[${i + 1}]`);
549
+ const label = c.bold(`${p.icon} ${p.label}`);
550
+ const desc = c.dim(p.description);
551
+ const marker = key === 'balanced' ? c.green(' ← default') : '';
552
+ console.log(` ${num} ${label}${marker}`);
553
+ console.log(` ${desc}`);
554
+ });
555
+ console.log('');
556
+
557
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
558
+ return new Promise(resolve => {
559
+ rl.question(' Select run mode [1-4] (Enter = balanced): ', answer => {
560
+ rl.close();
561
+ const trimmed = (answer || '').trim();
562
+
563
+ if (!trimmed) {
564
+ console.log(c.success('Using balanced mode'));
565
+ resolve('balanced');
566
+ return;
567
+ }
568
+
569
+ const num = parseInt(trimmed, 10);
570
+ if (num >= 1 && num <= presetKeys.length) {
571
+ const key = presetKeys[num - 1];
572
+ console.log(c.success(`Using ${RUN_PRESETS[key].label} mode`));
573
+ resolve(key);
574
+ return;
575
+ }
576
+
577
+ // Try matching by name
578
+ const lower = trimmed.toLowerCase();
579
+ const match = presetKeys.find(k => k === lower || RUN_PRESETS[k].label.toLowerCase() === lower);
580
+ if (match) {
581
+ console.log(c.success(`Using ${RUN_PRESETS[match].label} mode`));
582
+ resolve(match);
583
+ return;
584
+ }
585
+
586
+ console.log(c.warn(`Unknown "${trimmed}" — using balanced mode`));
587
+ resolve('balanced');
588
+ });
589
+ });
590
+ }
591
+
592
+ // ======================== FORMAT PICKER ========================
593
+
594
+ const ALL_FORMATS = [
595
+ { key: 'md', icon: '📝', label: 'Markdown', desc: 'Human-readable report' },
596
+ { key: 'html', icon: '🌐', label: 'HTML', desc: 'Styled web page' },
597
+ { key: 'pdf', icon: '📄', label: 'PDF', desc: 'Portable document' },
598
+ { key: 'docx', icon: '📘', label: 'Word', desc: 'Editable Word document' },
599
+ { key: 'json', icon: '🔧', label: 'JSON', desc: 'Machine-readable data' },
600
+ ];
601
+
602
+ /**
603
+ * Interactive format picker — user enters comma-separated numbers or "all".
604
+ * Returns a Set of chosen format keys.
605
+ *
606
+ * @returns {Promise<Set<string>>}
607
+ */
608
+ async function selectFormats() {
609
+ const readline = require('readline');
610
+
611
+ console.log('');
612
+ console.log(` ${c.bold('📦 Output Formats')} ${c.dim('(select one or more)')}`);
613
+ console.log(c.dim(' ' + '─'.repeat(50)));
614
+
615
+ ALL_FORMATS.forEach((f, i) => {
616
+ const num = c.cyan(`[${i + 1}]`);
617
+ console.log(` ${num} ${f.icon} ${c.bold(f.label.padEnd(12))} ${c.dim(f.desc)}`);
618
+ });
619
+ console.log(` ${c.cyan('[A]')} ${c.bold('All formats')}`);
620
+ console.log('');
621
+
622
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
623
+ return new Promise(resolve => {
624
+ rl.question(' Formats (e.g. 1,2,3 or A for all) [Enter = all]: ', answer => {
625
+ rl.close();
626
+ const trimmed = (answer || '').trim().toLowerCase();
627
+
628
+ if (!trimmed || trimmed === 'a' || trimmed === 'all') {
629
+ const all = new Set(ALL_FORMATS.map(f => f.key));
630
+ console.log(c.success('All formats selected'));
631
+ resolve(all);
632
+ return;
633
+ }
634
+
635
+ const parts = trimmed.split(/[\s,]+/).filter(Boolean);
636
+ const chosen = new Set();
637
+ for (const p of parts) {
638
+ const num = parseInt(p, 10);
639
+ if (num >= 1 && num <= ALL_FORMATS.length) {
640
+ chosen.add(ALL_FORMATS[num - 1].key);
641
+ } else {
642
+ // Try matching format key by name
643
+ const match = ALL_FORMATS.find(f => f.key === p || f.label.toLowerCase() === p);
644
+ if (match) chosen.add(match.key);
645
+ }
646
+ }
647
+
648
+ if (chosen.size === 0) {
649
+ console.log(c.warn('No valid formats selected — using all'));
650
+ resolve(new Set(ALL_FORMATS.map(f => f.key)));
651
+ return;
652
+ }
653
+
654
+ const labels = [...chosen].map(k => ALL_FORMATS.find(f => f.key === k)?.label || k);
655
+ console.log(c.success(`Formats: ${labels.join(', ')}`));
656
+ resolve(chosen);
657
+ });
658
+ });
659
+ }
660
+
661
+ // ======================== CONFIDENCE PICKER ========================
662
+
663
+ /**
664
+ * Interactive confidence-level selector.
665
+ * Returns null (keep all) or a string like 'high' or 'medium'.
666
+ *
667
+ * @returns {Promise<string|null>}
668
+ */
669
+ async function selectConfidence() {
670
+ const readline = require('readline');
671
+
672
+ const levels = [
673
+ { key: null, icon: '🌐', label: 'All', desc: 'Keep everything — no filtering' },
674
+ { key: 'low', icon: '🟡', label: 'Low+', desc: 'Keep low, medium & high confidence' },
675
+ { key: 'medium', icon: '🟠', label: 'Medium+', desc: 'Keep medium & high confidence' },
676
+ { key: 'high', icon: '🔴', label: 'High', desc: 'Only high-confidence items' },
677
+ ];
678
+
679
+ console.log('');
680
+ console.log(` ${c.bold('🎯 Confidence Filter')}`);
681
+ console.log(c.dim(' ' + '─'.repeat(50)));
682
+
683
+ levels.forEach((l, i) => {
684
+ const num = c.cyan(`[${i + 1}]`);
685
+ const marker = i === 0 ? c.green(' ← default') : '';
686
+ console.log(` ${num} ${l.icon} ${c.bold(l.label.padEnd(10))} ${c.dim(l.desc)}${marker}`);
687
+ });
688
+ console.log('');
689
+
690
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
691
+ return new Promise(resolve => {
692
+ rl.question(' Confidence filter [1-4] (Enter = keep all): ', answer => {
693
+ rl.close();
694
+ const trimmed = (answer || '').trim();
695
+
696
+ if (!trimmed) {
697
+ console.log(c.success('Keeping all confidence levels'));
698
+ resolve(null);
699
+ return;
700
+ }
701
+
702
+ const num = parseInt(trimmed, 10);
703
+ if (num >= 1 && num <= levels.length) {
704
+ const chosen = levels[num - 1];
705
+ console.log(c.success(`Confidence filter: ${chosen.label}`));
706
+ resolve(chosen.key);
707
+ return;
708
+ }
709
+
710
+ // Try matching by name
711
+ const lower = trimmed.toLowerCase();
712
+ const match = levels.find(l => l.key === lower || l.label.toLowerCase() === lower);
713
+ if (match) {
714
+ console.log(c.success(`Confidence filter: ${match.label}`));
715
+ resolve(match.key);
716
+ return;
717
+ }
718
+
719
+ console.log(c.warn(`Unknown "${trimmed}" — keeping all`));
720
+ resolve(null);
721
+ });
722
+ });
723
+ }
@@ -29,14 +29,16 @@ const THRESHOLDS = {
29
29
 
30
30
  // Required top-level fields in a valid analysis
31
31
  const REQUIRED_FIELDS = [
32
- 'tickets',
33
- 'action_items',
34
- 'change_requests',
35
32
  'summary',
36
33
  ];
37
34
 
38
35
  // Optional but valuable fields (boost score when present)
36
+ // tickets, action_items, change_requests are here because a segment may
37
+ // legitimately contain none of these (e.g. a segment that is only chit-chat).
39
38
  const VALUED_FIELDS = [
39
+ 'tickets',
40
+ 'action_items',
41
+ 'change_requests',
40
42
  'blockers',
41
43
  'scope_changes',
42
44
  'file_references',
@@ -70,11 +72,11 @@ function scoreStructure(analysis) {
70
72
  let bonus = 0;
71
73
  for (const field of VALUED_FIELDS) {
72
74
  if (analysis[field] !== undefined && analysis[field] !== null) {
73
- bonus += 3; // up to 12 bonus points
75
+ bonus += 2; // up to 14 bonus points (7 valued fields)
74
76
  }
75
77
  }
76
78
 
77
- const baseScore = (present / REQUIRED_FIELDS.length) * 80;
79
+ const baseScore = (present / REQUIRED_FIELDS.length) * 70;
78
80
  return { score: Math.min(100, baseScore + bonus), issues };
79
81
  }
80
82
 
@@ -335,7 +337,7 @@ function buildRetryHints(analysis, issues) {
335
337
  const hints = [];
336
338
 
337
339
  if (issues.some(i => i.includes('Missing required field'))) {
338
- hints.push('CRITICAL: Your previous response was missing required fields. You MUST include ALL of: tickets, action_items, change_requests, summary. Use empty arrays [] if no items exist.');
340
+ hints.push('CRITICAL: Your previous response was missing the required "summary" field. You MUST include a "summary" string. Include tickets, action_items, and change_requests arrays if relevant — omit them or use empty arrays [] if none exist in this segment.');
339
341
  }
340
342
 
341
343
  if (issues.some(i => i.includes('JSON parse failed'))) {
@@ -306,9 +306,38 @@ function formatSchemaLine(report) {
306
306
  return ` ${c.warn(`Schema: ${report.errorCount} error(s) — ${report.errors.slice(0, 2).map(e => e.message).join('; ')}`)}`;
307
307
  }
308
308
 
309
+ // ======================== POST-PARSE NORMALIZATION ========================
310
+
311
+ /** Array fields that should default to [] when missing from analysis output. */
312
+ const ARRAY_DEFAULTS = [
313
+ 'tickets', 'action_items', 'change_requests',
314
+ 'blockers', 'scope_changes', 'file_references',
315
+ ];
316
+
317
+ /**
318
+ * Normalize a parsed analysis object by filling in missing array fields
319
+ * with empty arrays. This prevents downstream code from crashing when
320
+ * a segment legitimately has no tickets/action_items/etc.
321
+ *
322
+ * Mutates `data` in-place and returns it for convenience.
323
+ *
324
+ * @param {object} data - Parsed analysis (may have missing fields)
325
+ * @returns {object} The same object with defaults applied
326
+ */
327
+ function normalizeAnalysis(data) {
328
+ if (!data || typeof data !== 'object') return data;
329
+ for (const field of ARRAY_DEFAULTS) {
330
+ if (data[field] === undefined || data[field] === null) {
331
+ data[field] = [];
332
+ }
333
+ }
334
+ return data;
335
+ }
336
+
309
337
  module.exports = {
310
338
  validateAnalysis,
311
339
  buildSchemaRetryHints,
312
340
  schemaScore,
313
341
  formatSchemaLine,
342
+ normalizeAnalysis,
314
343
  };