@signal4/s4 2.2.0 → 2.4.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 (50) hide show
  1. package/README.md +145 -17
  2. package/bin/s4.js +0 -0
  3. package/bin/signal4-mcp.js +0 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +412 -36
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +114 -5
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +162 -6
  10. package/dist/client.js.map +1 -1
  11. package/dist/format.d.ts +23 -1
  12. package/dist/format.d.ts.map +1 -1
  13. package/dist/format.js +454 -8
  14. package/dist/format.js.map +1 -1
  15. package/dist/handlers/ads.d.ts.map +1 -1
  16. package/dist/handlers/ads.js +20 -2
  17. package/dist/handlers/ads.js.map +1 -1
  18. package/dist/handlers/analyze.d.ts +1 -0
  19. package/dist/handlers/analyze.d.ts.map +1 -1
  20. package/dist/handlers/analyze.js +28 -6
  21. package/dist/handlers/analyze.js.map +1 -1
  22. package/dist/handlers/claim_detail.d.ts +4 -0
  23. package/dist/handlers/claim_detail.d.ts.map +1 -0
  24. package/dist/handlers/claim_detail.js +29 -0
  25. package/dist/handlers/claim_detail.js.map +1 -0
  26. package/dist/handlers/claims.d.ts +12 -1
  27. package/dist/handlers/claims.d.ts.map +1 -1
  28. package/dist/handlers/claims.js +189 -12
  29. package/dist/handlers/claims.js.map +1 -1
  30. package/dist/handlers/explore.d.ts.map +1 -1
  31. package/dist/handlers/explore.js +103 -20
  32. package/dist/handlers/explore.js.map +1 -1
  33. package/dist/handlers/registry.d.ts.map +1 -1
  34. package/dist/handlers/registry.js +18 -2
  35. package/dist/handlers/registry.js.map +1 -1
  36. package/dist/handlers/reports.d.ts.map +1 -1
  37. package/dist/handlers/reports.js +69 -10
  38. package/dist/handlers/reports.js.map +1 -1
  39. package/dist/handlers/search.d.ts.map +1 -1
  40. package/dist/handlers/search.js +24 -10
  41. package/dist/handlers/search.js.map +1 -1
  42. package/dist/handlers/timeline.d.ts +4 -0
  43. package/dist/handlers/timeline.d.ts.map +1 -0
  44. package/dist/handlers/timeline.js +28 -0
  45. package/dist/handlers/timeline.js.map +1 -0
  46. package/dist/index.js +9 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.d.ts +251 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +13 -1
package/dist/format.js CHANGED
@@ -1,6 +1,32 @@
1
1
  /** Shared formatting utilities — markdown output for MCP tools. */
2
2
  const PUBLIC_URL = "https://api.signal4.ca";
3
3
  // ---------------------------------------------------------------------------
4
+ // Political lean parsing
5
+ // ---------------------------------------------------------------------------
6
+ const LEAN_PRESETS = {
7
+ left: [0.0, 0.3],
8
+ "center-left": [0.2, 0.4],
9
+ center: [0.35, 0.65],
10
+ "center-right": [0.6, 0.8],
11
+ right: [0.7, 1.0],
12
+ };
13
+ export function parsePoliticalLean(lean) {
14
+ if (!lean)
15
+ return {};
16
+ const key = lean.trim().toLowerCase();
17
+ const preset = LEAN_PRESETS[key];
18
+ if (preset)
19
+ return { min: preset[0], max: preset[1] };
20
+ if (key.includes("-")) {
21
+ const [a, b] = key.split("-", 2);
22
+ const min = parseFloat(a);
23
+ const max = parseFloat(b);
24
+ if (!isNaN(min) && !isNaN(max))
25
+ return { min, max };
26
+ }
27
+ return {};
28
+ }
29
+ // ---------------------------------------------------------------------------
4
30
  // Primitives
5
31
  // ---------------------------------------------------------------------------
6
32
  export function fmtTime(seconds) {
@@ -80,6 +106,17 @@ function simTier(sim) {
80
106
  return "weak";
81
107
  return "";
82
108
  }
109
+ function claimSimTier(sim) {
110
+ if (sim >= 0.45)
111
+ return "strong";
112
+ if (sim >= 0.35)
113
+ return "good";
114
+ if (sim >= 0.25)
115
+ return "relevant";
116
+ if (sim > 0)
117
+ return "weak";
118
+ return "";
119
+ }
83
120
  function topicRelevanceTier(relevance) {
84
121
  if (relevance >= 0.35)
85
122
  return "strong";
@@ -146,7 +183,7 @@ export function formatQuotes(data) {
146
183
  const sim = q.similarity ?? 0;
147
184
  const ch = q.channel_name ?? "?";
148
185
  const date = fmtDate(q.episode_date);
149
- const text = (q.text ?? "").slice(0, 300);
186
+ const text = (q.text ?? "").slice(0, 400);
150
187
  const segId = q.segment_id ?? "?";
151
188
  const contentId = q.content_id ?? "?";
152
189
  const tStart = q.timestamp_start ?? q.start_time;
@@ -183,12 +220,16 @@ export function formatClaims(data) {
183
220
  for (let i = 0; i < claims.length; i++) {
184
221
  const c = claims[i];
185
222
  const sim = c.similarity;
186
- const simStr = sim != null ? ` sim:${sim.toFixed(2)}` : "";
223
+ const tier = sim != null ? claimSimTier(sim) : "";
224
+ const tierStr = tier ? ` ${tier}` : "";
225
+ const simStr = sim != null ? ` sim:${sim.toFixed(2)}${tierStr}` : "";
187
226
  const stanceStr = c.stance ? ` [${c.stance}]` : "";
188
227
  const topicStr = c.topic ? ` (${c.topic})` : "";
189
228
  const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
190
229
  lines.push(`${i + 1}. **${c.summary}**${stanceStr}${topicStr}${simStr}`);
191
- lines.push(` ${c.observation_count} observations across ${c.episode_count} episodes, ${c.channel_count} channels` +
230
+ const av = c.episode_count - (c.text_count ?? 0);
231
+ const spread = `${av} episode${av === 1 ? "" : "s"}`;
232
+ lines.push(` ${c.observation_count} observations across ${spread}, ${c.channel_count} channels` +
192
233
  (span ? ` (${span})` : ""));
193
234
  if (c.entities.length) {
194
235
  lines.push(` Entities: ${c.entities.join(", ")}`);
@@ -225,6 +266,276 @@ export function formatClaims(data) {
225
266
  return lines.join("\n");
226
267
  }
227
268
  // ---------------------------------------------------------------------------
269
+ // Claims analysis (/api/v1/analysis/claims/*)
270
+ // ---------------------------------------------------------------------------
271
+ export function formatClaimsTopicAnalysis(data, topic) {
272
+ if (!data.themes?.length) {
273
+ return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.`;
274
+ }
275
+ const m = data.quantitative_metrics ?? {};
276
+ const lines = [`# Claims analysis: "${topic}"`, ""];
277
+ const stat = (label, v) => (v != null ? `${v} ${label}` : null);
278
+ const summaryParts = [
279
+ stat("claims", m.total_claims),
280
+ stat("observations", m.total_observations),
281
+ stat("channels", m.unique_channels),
282
+ stat("speakers", m.unique_speakers),
283
+ stat("themes", m.theme_count),
284
+ ].filter(Boolean);
285
+ if (summaryParts.length)
286
+ lines.push(summaryParts.join(" · "), "");
287
+ for (const theme of data.themes) {
288
+ lines.push(`## ${theme.name} — ${theme.claim_count} claims, ${theme.total_observations} obs across ${theme.channel_count} channels`);
289
+ if (theme.entities?.length)
290
+ lines.push(`Entities: ${theme.entities.slice(0, 8).join(", ")}`);
291
+ for (const c of theme.claims) {
292
+ const stanceStr = c.stance ? ` [${c.stance}]` : "";
293
+ const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
294
+ lines.push(`- **${c.summary}**${stanceStr} — ${c.observation_count} obs / ${c.channel_count} channels${span ? ` (${span})` : ""}`);
295
+ const speakers = (c.top_speakers ?? []).slice(0, 3).map((s) => `${s.name} (${s.observation_count})`);
296
+ if (speakers.length)
297
+ lines.push(` Speakers: ${speakers.join(", ")}`);
298
+ }
299
+ lines.push("");
300
+ }
301
+ return lines.join("\n");
302
+ }
303
+ const TREND_ARROW = {
304
+ surging: "🚀", rising: "↗", steady: "→", declining: "↘", fading: "📉", new: "✨", no_data: "∅",
305
+ };
306
+ export function formatClaimsTrend(data, topic) {
307
+ if (data.direction === "no_data") {
308
+ return `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.`;
309
+ }
310
+ const arrow = TREND_ARROW[data.direction] ?? "";
311
+ const pct = data.change_pct != null ? `${data.change_pct >= 0 ? "+" : ""}${data.change_pct.toFixed(0)}%` : "n/a";
312
+ const lines = [
313
+ `# Claim trend: "${topic}" — ${arrow} ${data.direction.toUpperCase()} (${pct})`,
314
+ "",
315
+ `Recent (${data.recent.days}d): ${data.recent.claims} claims` +
316
+ (data.recent.new_claims != null ? ` (${data.recent.new_claims} new)` : "") +
317
+ `, ${data.recent.observations} obs, ${data.recent.per_day}/day`,
318
+ `Baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
319
+ ];
320
+ return lines.join("\n");
321
+ }
322
+ export function formatClaimsContradictions(data, speaker, topic) {
323
+ if (data.verdict === "no_data") {
324
+ return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.`;
325
+ }
326
+ const lines = [`# Contradiction check: ${speaker} on "${topic}"`, "", `Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, ""];
327
+ if (data.contradictions.length) {
328
+ lines.push("## Contradictions");
329
+ for (const c of data.contradictions) {
330
+ lines.push(`- [${c.severity}] ${c.description}`);
331
+ lines.push(` • ${c.earlier.stance}: "${truncate(c.earlier.claim, 120)}" (${fmtDate(c.earlier.date)}, ${c.earlier.observations} obs)`);
332
+ lines.push(` • ${c.later.stance}: "${truncate(c.later.claim, 120)}" (${fmtDate(c.later.date)}, ${c.later.observations} obs)`);
333
+ }
334
+ lines.push("");
335
+ }
336
+ else {
337
+ lines.push("No contradictions detected.", "");
338
+ }
339
+ if (data.consistent_positions.length) {
340
+ lines.push("## Consistent positions");
341
+ for (const p of data.consistent_positions) {
342
+ lines.push(`- [${p.stance}] "${truncate(p.claim, 120)}" (${p.observations} obs)`);
343
+ }
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+ export function formatClaimsComparison(data, topic) {
348
+ const lines = [`# Speaker comparison on claims: "${topic}"`, ""];
349
+ for (const sp of data.speakers) {
350
+ lines.push(`## ${sp.name} — ${sp.claim_count} claims${sp.framing ? ` (${sp.framing})` : ""}`);
351
+ if (sp.position_summary)
352
+ lines.push(sp.position_summary);
353
+ const sb = Object.entries(sp.stance_breakdown ?? {});
354
+ if (sb.length)
355
+ lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
356
+ for (const kc of (sp.key_claims ?? []).slice(0, 3)) {
357
+ lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
358
+ }
359
+ lines.push("");
360
+ }
361
+ if (data.agreements?.length) {
362
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
363
+ }
364
+ if (data.disagreements?.length) {
365
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
366
+ }
367
+ return lines.join("\n");
368
+ }
369
+ export function formatChannelDossier(data) {
370
+ const label = data.channels.join(", ");
371
+ if (!data.claims_analyzed) {
372
+ return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.`;
373
+ }
374
+ const lines = [`# Channel dossier: ${label} on "${data.topic}"`, ""];
375
+ lines.push(`${data.claims_analyzed} claims analyzed${data.total_claims_available ? ` (${data.total_claims_available} available)` : ""}`);
376
+ const sb = Object.entries(data.stance_breakdown ?? {});
377
+ if (sb.length)
378
+ lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
379
+ lines.push("");
380
+ if (data.themes.length) {
381
+ lines.push("## Themes");
382
+ for (const t of data.themes) {
383
+ lines.push(`- **${t.name}** — ${t.claim_count} claims, ${t.total_observations} obs`);
384
+ if (t.summary)
385
+ lines.push(` ${truncate(t.summary, 200)}`);
386
+ }
387
+ lines.push("");
388
+ }
389
+ if (data.key_speakers.length) {
390
+ lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (${s.claim_count})`).join(", ")}`, "");
391
+ }
392
+ if (data.key_claims.length) {
393
+ lines.push("## Key claims");
394
+ for (const kc of data.key_claims) {
395
+ lines.push(`- "${truncate(kc.claim, 140)}"${kc.stance ? ` [${kc.stance}]` : ""} (id:${kc.claim_id}, ${kc.observations} obs)`);
396
+ }
397
+ lines.push("");
398
+ }
399
+ if (data.trajectory.length > 1) {
400
+ lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`);
401
+ }
402
+ return lines.join("\n");
403
+ }
404
+ export function formatChannelClaimsComparison(data, topic) {
405
+ const lines = [`# Channel comparison on claims: "${topic}"`, ""];
406
+ for (const ch of data.channels) {
407
+ lines.push(`## ${ch.name} — ${ch.claim_count} claims${ch.framing ? ` (${ch.framing})` : ""}`);
408
+ if (ch.position_summary)
409
+ lines.push(ch.position_summary);
410
+ const sb = Object.entries(ch.stance_breakdown ?? {});
411
+ if (sb.length)
412
+ lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
413
+ if (ch.key_speakers?.length) {
414
+ lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => s.name).join(", ")}`);
415
+ }
416
+ for (const kc of (ch.key_claims ?? []).slice(0, 3)) {
417
+ lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
418
+ }
419
+ lines.push("");
420
+ }
421
+ if (data.agreements?.length) {
422
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
423
+ }
424
+ if (data.disagreements?.length) {
425
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
426
+ }
427
+ if (data.speaker_overlap?.length) {
428
+ lines.push("## Shared speakers");
429
+ for (const o of data.speaker_overlap) {
430
+ lines.push(`- ${o.channels.join(" & ")}: ${o.shared_speakers.join(", ")}`);
431
+ }
432
+ }
433
+ return lines.join("\n");
434
+ }
435
+ // ---------------------------------------------------------------------------
436
+ // Claims graph (entity-centric, hierarchy/relations, leaderboard)
437
+ // ---------------------------------------------------------------------------
438
+ /**
439
+ * Per-claim content breakdown. Text (substack) observations are excluded
440
+ * from the tool surface, so only the AV episode count is shown.
441
+ */
442
+ export function contentBreakdown(c) {
443
+ const av = c.episode_count - (c.text_count ?? 0);
444
+ return `${av} ep`;
445
+ }
446
+ /** One compact line for a claim in a list (used by graph/top/entity views). */
447
+ function claimLine(c, idx) {
448
+ const stanceStr = c.stance ? ` [${c.stance}]` : "";
449
+ const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
450
+ const sim = c.similarity;
451
+ const simStr = sim != null ? ` sim:${sim.toFixed(2)}` : "";
452
+ const prefix = idx != null ? `${idx}. ` : "- ";
453
+ return `${prefix}**${c.summary}**${stanceStr}${simStr}\n id:${c.claim_id} · ${c.observation_count} obs / ${contentBreakdown(c)} / ${c.channel_count} ch${span ? ` (${span})` : ""}`;
454
+ }
455
+ export function formatEntitySearch(data) {
456
+ if (!data.entities.length) {
457
+ return `No entities found matching "${data.query}". Try a shorter or differently-spelled query.`;
458
+ }
459
+ const lines = [`# Entities matching "${data.query}"`, ""];
460
+ for (const e of data.entities) {
461
+ const meta = [];
462
+ if (e.entity_type)
463
+ meta.push(e.entity_type);
464
+ meta.push(`${e.claim_count.toLocaleString()} claims`, `${e.channel_count.toLocaleString()} channels`);
465
+ const span = [fmtDate(e.first_seen_date), fmtDate(e.last_seen_date)].filter((d) => d !== "?").join(" – ");
466
+ if (span)
467
+ meta.push(span);
468
+ lines.push(`- **${e.name}** (${meta.join(" · ")})`);
469
+ if (e.aliases?.length)
470
+ lines.push(` Aliases: ${e.aliases.slice(0, 5).join(", ")}`);
471
+ }
472
+ lines.push("", "_Use entity_claims with the exact name for the claims themselves._");
473
+ return lines.join("\n");
474
+ }
475
+ export function formatEntityClaims(data) {
476
+ if (!data.entity_id) {
477
+ return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.`;
478
+ }
479
+ const lines = [`# Claims about ${data.entity}`];
480
+ const meta = [];
481
+ if (data.entity_type)
482
+ meta.push(data.entity_type);
483
+ if (data.claim_count_rollup != null)
484
+ meta.push(`${data.claim_count_rollup.toLocaleString()} claims`);
485
+ if (data.channel_count != null)
486
+ meta.push(`${data.channel_count.toLocaleString()} channels`);
487
+ if (meta.length)
488
+ lines.push(meta.join(" · "));
489
+ if (data.aliases?.length)
490
+ lines.push(`Aliases: ${data.aliases.slice(0, 6).join(", ")}`);
491
+ if (data.stance_distribution?.length) {
492
+ const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
493
+ const parts = data.stance_distribution
494
+ .map((s) => `${s.stance}: ${s.count.toLocaleString()} (${Math.round((s.count / total) * 100)}%)`);
495
+ lines.push(`\nStance: ${parts.join(", ")}`);
496
+ }
497
+ lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims.toLocaleString()})`);
498
+ data.claims.forEach((c, i) => lines.push(claimLine(c, i + 1)));
499
+ if (data.has_more)
500
+ lines.push(`\n_More available (use offset)._`);
501
+ return lines.join("\n");
502
+ }
503
+ export function formatClaimGraph(data) {
504
+ const lines = [`# Claim graph`, "", `**${data.claim.summary}**`,
505
+ `id:${data.claim.claim_id} · ${data.claim.observation_count} obs / ${data.claim.channel_count} channels`, ""];
506
+ if (data.parent) {
507
+ lines.push("## Broader claim (parent)", claimLine(data.parent), "");
508
+ }
509
+ if (data.children?.length) {
510
+ lines.push(`## More specific claims (${data.children.length} children)`);
511
+ data.children.forEach((c) => lines.push(claimLine(c)));
512
+ lines.push("");
513
+ }
514
+ if (data.siblings?.length) {
515
+ lines.push(`## Variant phrasings (${data.siblings.length} siblings)`);
516
+ data.siblings.forEach((c) => lines.push(claimLine(c)));
517
+ lines.push("");
518
+ }
519
+ if (!data.parent && !data.children?.length && !data.siblings?.length) {
520
+ lines.push("_No parent, children, or sibling claims found. This claim stands alone in the hierarchy._");
521
+ }
522
+ return lines.join("\n");
523
+ }
524
+ export function formatTopClaims(data) {
525
+ if (!data.claims?.length) {
526
+ return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.`;
527
+ }
528
+ const label = data.metric === "contested" ? "Most contested claims" : "Most widely-spread claims";
529
+ const lines = [`# ${label}`, ""];
530
+ data.claims.forEach((c, i) => {
531
+ lines.push(claimLine(c, i + 1));
532
+ const speakers = (c.top_speakers ?? []).slice(0, 3).map((s) => `${s.name} (${s.observation_count})`);
533
+ if (speakers.length)
534
+ lines.push(` Speakers: ${speakers.join(", ")}`);
535
+ });
536
+ return lines.join("\n");
537
+ }
538
+ // ---------------------------------------------------------------------------
228
539
  // Speaker
229
540
  // ---------------------------------------------------------------------------
230
541
  export function formatSpeaker(data) {
@@ -364,8 +675,12 @@ export function formatSegment(data) {
364
675
  export function formatAnalysis(data) {
365
676
  if (data.error)
366
677
  return `Error: ${data.error}`;
367
- const lines = ["**Analysis complete**\n"];
368
678
  const q = data.quantitative_metrics ?? {};
679
+ const themes = data.themes ?? [];
680
+ if (!themes.length && !q.total_segments && !data.summary) {
681
+ return "No analysis results found. The topic may not appear in the corpus for the given time window. Try broader keywords or a wider date range (days=90).";
682
+ }
683
+ const lines = ["**Analysis complete**\n"];
369
684
  const centrality = q.discourse_centrality;
370
685
  if (centrality?.label) {
371
686
  lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "?"}/5)`);
@@ -379,7 +694,6 @@ export function formatAnalysis(data) {
379
694
  parts.push(`${q.unique_videos ?? q.episode_count} episodes`);
380
695
  if (parts.length)
381
696
  lines.push(parts.join(", ") + "\n");
382
- const themes = data.themes ?? [];
383
697
  if (themes.length) {
384
698
  lines.push("**Themes:**");
385
699
  for (let i = 0; i < themes.length; i++) {
@@ -401,13 +715,62 @@ export function formatAnalysis(data) {
401
715
  return lines.join("\n");
402
716
  }
403
717
  // ---------------------------------------------------------------------------
718
+ // Frame comparison (left vs right)
719
+ // ---------------------------------------------------------------------------
720
+ export function formatFrameComparison(left, right, topic) {
721
+ if (left.error && right.error)
722
+ return `Error: left=${left.error}, right=${right.error}`;
723
+ const leftEmpty = !left.error && !(left.themes ?? []).length && !left.summary && !(left.quantitative_metrics?.total_segments);
724
+ const rightEmpty = !right.error && !(right.themes ?? []).length && !right.summary && !(right.quantitative_metrics?.total_segments);
725
+ if (leftEmpty && rightEmpty) {
726
+ return `No frame comparison data found for "${topic}". The topic may not appear in enough left-leaning and right-leaning channels. Try broader keywords or a wider date range (days=90).`;
727
+ }
728
+ const lines = [`**Frame comparison: ${topic}**\n`];
729
+ function sideBlock(label, data) {
730
+ if (data.error) {
731
+ lines.push(`### ${label}\nError: ${data.error}\n`);
732
+ return;
733
+ }
734
+ const q = data.quantitative_metrics ?? {};
735
+ const parts = [];
736
+ if (q.total_segments)
737
+ parts.push(`${q.total_segments} segments`);
738
+ if (q.unique_channels)
739
+ parts.push(`${q.unique_channels} channels`);
740
+ if (q.unique_speakers)
741
+ parts.push(`${q.unique_speakers} speakers`);
742
+ lines.push(`### ${label}${parts.length ? ` (${parts.join(", ")})` : ""}`);
743
+ const themes = (data.themes ?? []).slice(0, 8);
744
+ if (themes.length) {
745
+ lines.push("**Key themes:**");
746
+ for (const t of themes) {
747
+ const name = (t.name ?? t.theme_name ?? t.label);
748
+ const desc = (t.summary ?? t.description);
749
+ if (name)
750
+ lines.push(`- **${name}**${desc ? ": " + desc.slice(0, 150) : ""}`);
751
+ }
752
+ }
753
+ if (data.summary) {
754
+ lines.push("");
755
+ lines.push(data.summary);
756
+ }
757
+ lines.push("");
758
+ }
759
+ sideBlock("Left-leaning media", left);
760
+ sideBlock("Right-leaning media", right);
761
+ return lines.join("\n");
762
+ }
763
+ // ---------------------------------------------------------------------------
404
764
  // Comparison
405
765
  // ---------------------------------------------------------------------------
406
766
  export function formatComparison(data, topic) {
407
767
  if (data.error)
408
768
  return `Error: ${data.error}`;
409
- const lines = [`**Speaker comparison: ${topic}**\n`];
410
769
  const speakers = data.speakers ?? {};
770
+ if (!Object.keys(speakers).length && !data.summary) {
771
+ return `No comparison data found for these speakers on "${topic}". The speakers may not have discussed this topic in the given time window. Try broader keywords or a wider date range.`;
772
+ }
773
+ const lines = [`**Speaker comparison: ${topic}**\n`];
411
774
  for (const [name, sp] of Object.entries(speakers)) {
412
775
  const count = sp.segment_count ?? 0;
413
776
  lines.push(`### ${name} (${count} segments)`);
@@ -439,8 +802,11 @@ export function formatComparison(data, topic) {
439
802
  export function formatDossier(data, speakerName, topic) {
440
803
  if (data.error)
441
804
  return `Error: ${data.error}`;
442
- const lines = [`**Dossier: ${speakerName} on ${topic}**\n`];
443
805
  const sp = data.speaker ?? {};
806
+ if (!sp.segment_count && !data.summary) {
807
+ return `No data found for ${speakerName} on "${topic}". This speaker may not appear in the corpus for this topic. Use find_speakers to verify the name, or try broader keywords.`;
808
+ }
809
+ const lines = [`**Dossier: ${speakerName} on ${topic}**\n`];
444
810
  if (sp.occupation)
445
811
  lines.push(`*${sp.occupation}*`);
446
812
  if (sp.segment_count)
@@ -463,9 +829,12 @@ export function formatDossier(data, speakerName, topic) {
463
829
  export function formatContradictions(data, speakerName, topic) {
464
830
  if (data.error)
465
831
  return `Error: ${data.error}`;
466
- const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
467
832
  const contras = data.contradictions ?? {};
468
833
  const verdict = contras.verdict ?? "unknown";
834
+ if (verdict === "unknown" && !(contras.contradictions ?? []).length && !data.summary) {
835
+ return `No data found for contradiction check on ${speakerName} regarding "${topic}". This speaker may not have enough content on this topic. Use find_speakers to verify the name, or try broader keywords.`;
836
+ }
837
+ const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
469
838
  lines.push(`**Verdict:** ${verdict.replace(/_/g, " ")}\n`);
470
839
  for (const c of contras.contradictions ?? []) {
471
840
  lines.push(`### [${c.severity ?? "?"}] ${c.description ?? "?"}`);
@@ -708,4 +1077,81 @@ export function formatAdTrends(data) {
708
1077
  }
709
1078
  return lines.join("\n");
710
1079
  }
1080
+ // ---------------------------------------------------------------------------
1081
+ // Claim Detail
1082
+ // ---------------------------------------------------------------------------
1083
+ export function formatClaimDetail(detail, observations, related) {
1084
+ const c = detail.claim;
1085
+ const lines = [];
1086
+ lines.push(`# Claim #${c.claim_id}`);
1087
+ lines.push(`**${c.summary}**\n`);
1088
+ const stance = c.stance ?? "unknown";
1089
+ const topic = c.topic ?? "uncategorized";
1090
+ lines.push(`Stance: ${stance} | Category: ${topic}`);
1091
+ lines.push(`${c.observation_count} observations across ${c.episode_count} episodes, ${c.channel_count} channels`);
1092
+ const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "?").join(" – ");
1093
+ if (span)
1094
+ lines.push(`Period: ${span}`);
1095
+ if (c.entities?.length) {
1096
+ lines.push(`Entities: ${c.entities.join(", ")}`);
1097
+ }
1098
+ lines.push("");
1099
+ if (c.top_speakers?.length) {
1100
+ lines.push("**Speakers:**");
1101
+ for (const s of c.top_speakers.slice(0, 10)) {
1102
+ const idStr = s.identity_id ? ` (id:${s.identity_id})` : "";
1103
+ lines.push(`- ${s.name}${idStr} — ${s.observation_count} observations`);
1104
+ }
1105
+ lines.push("");
1106
+ }
1107
+ if (c.top_channels?.length) {
1108
+ lines.push("**Channels:**");
1109
+ for (const ch of c.top_channels.slice(0, 10)) {
1110
+ lines.push(`- ${ch.name} — ${ch.observation_count} observations`);
1111
+ }
1112
+ lines.push("");
1113
+ }
1114
+ const obs = observations?.observations ?? c.sample_observations ?? [];
1115
+ if (obs.length) {
1116
+ const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
1117
+ lines.push(label);
1118
+ for (const o of obs.slice(0, 20)) {
1119
+ const date = fmtDate(o.publish_date);
1120
+ const meta = [o.speaker_name, o.channel_name, date].filter(Boolean).join(", ");
1121
+ lines.push(`- "${truncate(o.chunk_text, 150)}" — ${meta}`);
1122
+ if (o.clip_url)
1123
+ lines.push(` 🔊 ${o.clip_url}`);
1124
+ }
1125
+ lines.push("");
1126
+ }
1127
+ if (related?.claims?.length) {
1128
+ lines.push("**Related Claims:**");
1129
+ for (const r of related.claims) {
1130
+ const sim = r.similarity != null ? ` (sim:${r.similarity.toFixed(2)})` : "";
1131
+ lines.push(`- ${r.summary} — ${r.observation_count} observations${sim}`);
1132
+ }
1133
+ lines.push("");
1134
+ }
1135
+ return lines.join("\n");
1136
+ }
1137
+ // ---------------------------------------------------------------------------
1138
+ // Timeline
1139
+ // ---------------------------------------------------------------------------
1140
+ export function formatTimeline(data) {
1141
+ const buckets = data.buckets ?? [];
1142
+ if (!buckets.length)
1143
+ return "No data found for this topic in the given time range.";
1144
+ const maxCount = Math.max(...buckets.map(b => b.count));
1145
+ const barWidth = 30;
1146
+ const lines = [
1147
+ `**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
1148
+ ];
1149
+ for (const b of buckets) {
1150
+ const barLen = maxCount > 0 ? Math.round((b.count / maxCount) * barWidth) : 0;
1151
+ const bar = "█".repeat(barLen);
1152
+ const countStr = String(b.count).padStart(4);
1153
+ lines.push(`${b.start} ${countStr} ${bar}`);
1154
+ }
1155
+ return lines.join("\n");
1156
+ }
711
1157
  //# sourceMappingURL=format.js.map