@signal4/s4 2.1.2 → 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.
- package/README.md +145 -17
- package/bin/s4.js +0 -0
- package/bin/signal4-mcp.js +0 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +510 -63
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +124 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +172 -5
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +23 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +455 -9
- package/dist/format.js.map +1 -1
- package/dist/handlers/ads.d.ts.map +1 -1
- package/dist/handlers/ads.js +20 -2
- package/dist/handlers/ads.js.map +1 -1
- package/dist/handlers/analyze.d.ts +1 -0
- package/dist/handlers/analyze.d.ts.map +1 -1
- package/dist/handlers/analyze.js +44 -6
- package/dist/handlers/analyze.js.map +1 -1
- package/dist/handlers/claim_detail.d.ts +4 -0
- package/dist/handlers/claim_detail.d.ts.map +1 -0
- package/dist/handlers/claim_detail.js +29 -0
- package/dist/handlers/claim_detail.js.map +1 -0
- package/dist/handlers/claims.d.ts +12 -1
- package/dist/handlers/claims.d.ts.map +1 -1
- package/dist/handlers/claims.js +190 -13
- package/dist/handlers/claims.js.map +1 -1
- package/dist/handlers/explore.d.ts.map +1 -1
- package/dist/handlers/explore.js +103 -20
- package/dist/handlers/explore.js.map +1 -1
- package/dist/handlers/registry.d.ts.map +1 -1
- package/dist/handlers/registry.js +18 -2
- package/dist/handlers/registry.js.map +1 -1
- package/dist/handlers/reports.d.ts.map +1 -1
- package/dist/handlers/reports.js +69 -10
- package/dist/handlers/reports.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +39 -10
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/timeline.d.ts +4 -0
- package/dist/handlers/timeline.d.ts.map +1 -0
- package/dist/handlers/timeline.js +28 -0
- package/dist/handlers/timeline.js.map +1 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +251 -0
- package/dist/types.d.ts.map +1 -1
- 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,
|
|
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;
|
|
@@ -155,7 +192,7 @@ export function formatQuotes(data) {
|
|
|
155
192
|
const tier = simTier(sim);
|
|
156
193
|
const tierStr = tier ? ` ${tier}` : "";
|
|
157
194
|
const idStr = spkId ? ` id:${spkId}` : "";
|
|
158
|
-
const clipTag = appear
|
|
195
|
+
const clipTag = appear !== "featured" && appear ? " **[played]**" : "";
|
|
159
196
|
const timeStr = tStart != null ? ` ${fmtTime(tStart)}-${fmtTime(tEnd)}` : "";
|
|
160
197
|
lines.push(`${i + 1}. **${spkName}**${clipTag} (conf:${Math.round(conf * 100)}%${idStr}) on ${ch} ` +
|
|
161
198
|
`[${date}${timeStr}] sim:${sim.toFixed(2)}${tierStr}`);
|
|
@@ -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
|
|
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
|
-
|
|
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
|