@signal4/s4 2.5.0 → 2.6.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/README.md +15 -0
- package/bin/signal4-mcp-http.js +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +587 -102
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +58 -9
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +72 -18
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +646 -358
- package/dist/format.js.map +1 -1
- package/dist/handler_types.d.ts +4 -0
- package/dist/handler_types.d.ts.map +1 -1
- package/dist/handler_types.js +8 -1
- package/dist/handler_types.js.map +1 -1
- package/dist/handlers/ads.d.ts.map +1 -1
- package/dist/handlers/ads.js +11 -0
- package/dist/handlers/ads.js.map +1 -1
- package/dist/handlers/bookmarks.js +4 -4
- package/dist/handlers/bookmarks.js.map +1 -1
- package/dist/handlers/claims.d.ts.map +1 -1
- package/dist/handlers/claims.js +78 -19
- package/dist/handlers/claims.js.map +1 -1
- package/dist/handlers/explore.d.ts +2 -1
- package/dist/handlers/explore.d.ts.map +1 -1
- package/dist/handlers/explore.js +64 -10
- package/dist/handlers/explore.js.map +1 -1
- package/dist/handlers/registry.d.ts.map +1 -1
- package/dist/handlers/registry.js +2 -1
- package/dist/handlers/registry.js.map +1 -1
- package/dist/handlers/reports.d.ts.map +1 -1
- package/dist/handlers/reports.js +62 -33
- package/dist/handlers/reports.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +64 -39
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/timeline.d.ts.map +1 -1
- package/dist/handlers/timeline.js +12 -1
- package/dist/handlers/timeline.js.map +1 -1
- package/dist/http.d.ts +16 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +132 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -149
- package/dist/index.js.map +1 -1
- package/dist/provenance.d.ts +41 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +65 -0
- package/dist/provenance.js.map +1 -0
- package/dist/server_core.d.ts +40 -0
- package/dist/server_core.d.ts.map +1 -0
- package/dist/server_core.js +174 -0
- package/dist/server_core.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/format.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting utilities — markdown output for MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Implements RESPONSE_SPEC.md (v1). This file is the reference renderer;
|
|
5
|
+
* the golden fixtures in test/drift/ are the byte-authoritative contract for
|
|
6
|
+
* any port. Canonical tokens, truncation markers, and footers all live
|
|
7
|
+
* here — do not invent per-tool vocabulary in handlers.
|
|
8
|
+
*/
|
|
2
9
|
const PUBLIC_URL = "https://api.signal4.ca";
|
|
3
10
|
// ---------------------------------------------------------------------------
|
|
4
|
-
// Political lean
|
|
11
|
+
// Political lean — single canonical bucket table (spec §5), shared with the
|
|
12
|
+
// query presets so a `lean:` label always round-trips into the same filter.
|
|
5
13
|
// ---------------------------------------------------------------------------
|
|
6
14
|
const LEAN_PRESETS = {
|
|
7
15
|
left: [0.0, 0.3],
|
|
8
|
-
"center-left": [0.
|
|
9
|
-
center: [0.
|
|
10
|
-
"center-right": [0.
|
|
16
|
+
"center-left": [0.3, 0.42],
|
|
17
|
+
center: [0.42, 0.58],
|
|
18
|
+
"center-right": [0.58, 0.7],
|
|
11
19
|
right: [0.7, 1.0],
|
|
12
20
|
};
|
|
13
21
|
export function parsePoliticalLean(lean) {
|
|
@@ -26,6 +34,21 @@ export function parsePoliticalLean(lean) {
|
|
|
26
34
|
}
|
|
27
35
|
return {};
|
|
28
36
|
}
|
|
37
|
+
export function leanLabel(lean) {
|
|
38
|
+
if (lean < 0.3)
|
|
39
|
+
return "left";
|
|
40
|
+
if (lean < 0.42)
|
|
41
|
+
return "center-left";
|
|
42
|
+
if (lean <= 0.58)
|
|
43
|
+
return "center";
|
|
44
|
+
if (lean <= 0.7)
|
|
45
|
+
return "center-right";
|
|
46
|
+
return "right";
|
|
47
|
+
}
|
|
48
|
+
/** `lean:0.72 (right)` — numeric always present (spec §3). */
|
|
49
|
+
export function leanToken(lean) {
|
|
50
|
+
return `lean:${lean.toFixed(2)} (${leanLabel(lean)})`;
|
|
51
|
+
}
|
|
29
52
|
// ---------------------------------------------------------------------------
|
|
30
53
|
// Primitives
|
|
31
54
|
// ---------------------------------------------------------------------------
|
|
@@ -40,17 +63,24 @@ export function fmtTime(seconds) {
|
|
|
40
63
|
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
41
64
|
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
42
65
|
}
|
|
66
|
+
/** `t:512.4-598.2s (8:32-9:58)` — raw seconds, humanized in parens (spec §3). */
|
|
67
|
+
export function fmtTimeRange(start, end) {
|
|
68
|
+
if (start == null)
|
|
69
|
+
return "t:n/a";
|
|
70
|
+
const rawEnd = end ?? start;
|
|
71
|
+
return `t:${start.toFixed(1)}-${rawEnd.toFixed(1)}s (${fmtTime(start)}-${fmtTime(rawEnd)})`;
|
|
72
|
+
}
|
|
43
73
|
export function fmtHours(seconds) {
|
|
44
74
|
if (!seconds)
|
|
45
|
-
return "
|
|
75
|
+
return "n/a";
|
|
46
76
|
const h = seconds / 3600;
|
|
47
77
|
if (h >= 1)
|
|
48
|
-
return `${h.
|
|
78
|
+
return `${h.toFixed(1)}h`;
|
|
49
79
|
return fmtTime(seconds);
|
|
50
80
|
}
|
|
51
81
|
export function fmtDuration(seconds) {
|
|
52
82
|
if (!seconds)
|
|
53
|
-
return "
|
|
83
|
+
return "n/a";
|
|
54
84
|
const m = Math.floor(seconds / 60);
|
|
55
85
|
const s = Math.floor(seconds % 60);
|
|
56
86
|
if (m >= 60) {
|
|
@@ -60,6 +90,32 @@ export function fmtDuration(seconds) {
|
|
|
60
90
|
}
|
|
61
91
|
return `${m}m${String(s).padStart(2, "0")}s`;
|
|
62
92
|
}
|
|
93
|
+
export function fmtDate(iso) {
|
|
94
|
+
return iso ? iso.slice(0, 10) : "n/a";
|
|
95
|
+
}
|
|
96
|
+
/** `conf:87%` or `conf:unverified` — null is NEVER 0% (spec §3/§9). */
|
|
97
|
+
export function confToken(confidence) {
|
|
98
|
+
if (confidence == null)
|
|
99
|
+
return "conf:unverified";
|
|
100
|
+
return `conf:${Math.round(confidence * 100)}%`;
|
|
101
|
+
}
|
|
102
|
+
/** `trend:surging (+340%)` / `trend:new` / `trend:steady (n/a)` (spec §5). */
|
|
103
|
+
export function trendToken(direction, changePct) {
|
|
104
|
+
if (direction === "new")
|
|
105
|
+
return "trend:new";
|
|
106
|
+
const pct = changePct != null ? `${changePct >= 0 ? "+" : ""}${changePct.toFixed(0)}%` : "n/a";
|
|
107
|
+
return `trend:${direction} (${pct})`;
|
|
108
|
+
}
|
|
109
|
+
/** `lang:es` when non-English; empty string for en/unknown. */
|
|
110
|
+
export function langToken(language) {
|
|
111
|
+
if (!language || language === "en")
|
|
112
|
+
return "";
|
|
113
|
+
return `lang:${language}`;
|
|
114
|
+
}
|
|
115
|
+
export const NON_ENGLISH_NOTE = "note: non-English claim extraction is beta (elevated error rate)";
|
|
116
|
+
/** Plain word-boundary truncation with no marker — ONLY for synthesized text
|
|
117
|
+
* where a retrieval pointer exists nearby. Verbatim text must use
|
|
118
|
+
* truncateWithPointer instead. */
|
|
63
119
|
export function truncate(text, maxlen = 200) {
|
|
64
120
|
if (!text)
|
|
65
121
|
return "";
|
|
@@ -70,20 +126,75 @@ export function truncate(text, maxlen = 200) {
|
|
|
70
126
|
const last = cut.lastIndexOf(" ");
|
|
71
127
|
return (last > maxlen * 0.5 ? cut.slice(0, last) : cut) + "...";
|
|
72
128
|
}
|
|
73
|
-
|
|
74
|
-
|
|
129
|
+
/** Default budget for verbatim quote text (spec §4). */
|
|
130
|
+
export const QUOTE_BUDGET = 600;
|
|
131
|
+
/**
|
|
132
|
+
* Truncation with mandatory marker + retrieval pointer (spec §4):
|
|
133
|
+
* `…[truncated — full text: get_segment seg:42314442]`.
|
|
134
|
+
* When no retrieval pointer exists, the text is NOT cut — nothing is
|
|
135
|
+
* silently unrecoverable.
|
|
136
|
+
*/
|
|
137
|
+
export function truncateWithPointer(text, pointer, maxlen = QUOTE_BUDGET) {
|
|
138
|
+
if (!text)
|
|
139
|
+
return "";
|
|
140
|
+
const clean = text.replace(/\n/g, " ").trim();
|
|
141
|
+
if (clean.length <= maxlen || !pointer)
|
|
142
|
+
return clean;
|
|
143
|
+
const cut = clean.slice(0, maxlen);
|
|
144
|
+
const last = cut.lastIndexOf(" ");
|
|
145
|
+
const body = last > maxlen * 0.5 ? cut.slice(0, last) : cut;
|
|
146
|
+
return `${body}…[truncated — full text: ${pointer}]`;
|
|
147
|
+
}
|
|
148
|
+
/** Rewrite LLM `{seg_N}` / `{{seg_N}}` citations to the spec token `seg:N`. */
|
|
149
|
+
export function rewriteSegCitations(text) {
|
|
150
|
+
if (!text)
|
|
151
|
+
return text;
|
|
152
|
+
return text.replace(/\{\{?seg[_:](\d+)\}?\}/g, "seg:$1");
|
|
75
153
|
}
|
|
76
154
|
// ---------------------------------------------------------------------------
|
|
77
|
-
//
|
|
155
|
+
// Footers (spec §7): provenance footer → paging note → next-call hint
|
|
78
156
|
// ---------------------------------------------------------------------------
|
|
157
|
+
/** `showing 20 of 249 — offset:20 for more` (spec §7.2). */
|
|
158
|
+
export function pagingNote(opts) {
|
|
159
|
+
if (opts.countCapped) {
|
|
160
|
+
return `\nshowing ${opts.shown} of ${opts.total ?? "n/a"} (count_capped — true total unknown) — offset:${(opts.offset ?? 0) + opts.shown} for more`;
|
|
161
|
+
}
|
|
162
|
+
if (opts.total != null && opts.total > opts.shown + (opts.offset ?? 0)) {
|
|
163
|
+
return `\nshowing ${opts.shown} of ${opts.total} — offset:${(opts.offset ?? 0) + opts.shown} for more`;
|
|
164
|
+
}
|
|
165
|
+
return "";
|
|
166
|
+
}
|
|
167
|
+
/** `→ get_claim claim:1499738 include_observations:true · get_segment seg:42314442` */
|
|
168
|
+
export function nextCallHint(...calls) {
|
|
169
|
+
const real = calls.filter((c) => !!c);
|
|
170
|
+
if (!real.length)
|
|
171
|
+
return "";
|
|
172
|
+
return `\n→ ${real.join(" · ")}`;
|
|
173
|
+
}
|
|
174
|
+
/** Assemble body + footers in spec order. */
|
|
175
|
+
function withFooters(body, footer, paging, hint) {
|
|
176
|
+
return body + footer + paging + hint;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Quote dedup — episode + timestamp + full-text hash (spec / audit M8)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
function textHash(text) {
|
|
182
|
+
// djb2 — stable, dependency-free, over the FULL text so distinct quotes
|
|
183
|
+
// sharing an 80-char prefix never collapse.
|
|
184
|
+
let h = 5381;
|
|
185
|
+
for (let i = 0; i < text.length; i++) {
|
|
186
|
+
h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
|
|
187
|
+
}
|
|
188
|
+
return h.toString(16);
|
|
189
|
+
}
|
|
79
190
|
export function dedupQuotes(quotes) {
|
|
80
191
|
const seen = new Set();
|
|
81
192
|
const out = [];
|
|
82
193
|
for (const q of quotes) {
|
|
83
194
|
const key = [
|
|
84
|
-
q.
|
|
195
|
+
q.content_id ?? q.episode_title ?? "",
|
|
85
196
|
q.timestamp_start ?? q.start_time ?? 0,
|
|
86
|
-
(q.text ?? "")
|
|
197
|
+
textHash(q.text ?? ""),
|
|
87
198
|
].join("|");
|
|
88
199
|
if (!seen.has(key)) {
|
|
89
200
|
seen.add(key);
|
|
@@ -93,38 +204,6 @@ export function dedupQuotes(quotes) {
|
|
|
93
204
|
return out;
|
|
94
205
|
}
|
|
95
206
|
// ---------------------------------------------------------------------------
|
|
96
|
-
// Similarity tier
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
function simTier(sim) {
|
|
99
|
-
if (sim >= 0.60)
|
|
100
|
-
return "strong";
|
|
101
|
-
if (sim >= 0.45)
|
|
102
|
-
return "relevant";
|
|
103
|
-
if (sim >= 0.40)
|
|
104
|
-
return "borderline";
|
|
105
|
-
if (sim > 0)
|
|
106
|
-
return "weak";
|
|
107
|
-
return "";
|
|
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
|
-
}
|
|
120
|
-
function topicRelevanceTier(relevance) {
|
|
121
|
-
if (relevance >= 0.35)
|
|
122
|
-
return "strong";
|
|
123
|
-
if (relevance >= 0.28)
|
|
124
|
-
return "good";
|
|
125
|
-
return "weak";
|
|
126
|
-
}
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
207
|
// Clip URL
|
|
129
208
|
// ---------------------------------------------------------------------------
|
|
130
209
|
function clipUrl(contentId, start, end, existing) {
|
|
@@ -135,28 +214,45 @@ function clipUrl(contentId, start, end, existing) {
|
|
|
135
214
|
}
|
|
136
215
|
return "";
|
|
137
216
|
}
|
|
217
|
+
/** Missing clips are disclosed, never silently omitted (spec §9). */
|
|
218
|
+
function clipLine(contentType, url) {
|
|
219
|
+
if (url)
|
|
220
|
+
return `clip:${url}`;
|
|
221
|
+
if (contentType === "text")
|
|
222
|
+
return "clip:n/a (text source)";
|
|
223
|
+
return "clip:n/a (unresolved segment)";
|
|
224
|
+
}
|
|
138
225
|
// ---------------------------------------------------------------------------
|
|
139
226
|
// Empty state
|
|
140
227
|
// ---------------------------------------------------------------------------
|
|
141
228
|
export function emptyStateSuggestions(params) {
|
|
142
229
|
const suggestions = [];
|
|
143
|
-
if (params.
|
|
144
|
-
suggestions.push("Try
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
230
|
+
if (params.fullHistory) {
|
|
231
|
+
suggestions.push("Try lower min_similarity (e.g. 0.3) or broader phrasing of the topic");
|
|
232
|
+
if (params.speaker_name)
|
|
233
|
+
suggestions.push("Verify the speaker name with find_speakers");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
if (params.days && params.days < 30)
|
|
237
|
+
suggestions.push("Try a wider window: days=30");
|
|
238
|
+
else if (params.days && params.days < 90)
|
|
239
|
+
suggestions.push("Try a wider window: days=90");
|
|
240
|
+
if (params.mode === "semantic" && params.topic)
|
|
241
|
+
suggestions.push("Try hybrid mode: mode='hybrid'");
|
|
242
|
+
if (params.mode === "keyword" && params.topic)
|
|
243
|
+
suggestions.push("Try semantic mode: mode='semantic'");
|
|
244
|
+
if (params.speaker_name && params.topic)
|
|
245
|
+
suggestions.push("Drop speaker_name to search all speakers");
|
|
246
|
+
if (params.channel_name)
|
|
247
|
+
suggestions.push(`Drop channel_name to search all channels (the topic may not appear on "${params.channel_name}")`);
|
|
248
|
+
}
|
|
155
249
|
const query = params.topic || params.speaker_name || "this query";
|
|
156
250
|
let msg = `No matching content for "${query}"`;
|
|
157
251
|
if (params.channel_name)
|
|
158
252
|
msg += ` on channel "${params.channel_name}"`;
|
|
159
|
-
if (params.
|
|
253
|
+
if (params.fullHistory)
|
|
254
|
+
msg += ` in the full corpus history`;
|
|
255
|
+
else if (params.days)
|
|
160
256
|
msg += ` in the last ${params.days} days`;
|
|
161
257
|
msg += ".";
|
|
162
258
|
if (suggestions.length)
|
|
@@ -166,111 +262,148 @@ export function emptyStateSuggestions(params) {
|
|
|
166
262
|
// ---------------------------------------------------------------------------
|
|
167
263
|
// Quotes
|
|
168
264
|
// ---------------------------------------------------------------------------
|
|
169
|
-
export function formatQuotes(data) {
|
|
265
|
+
export function formatQuotes(data, footer = "", offset = 0) {
|
|
170
266
|
const quotes = dedupQuotes(data.quotes ?? []);
|
|
171
267
|
if (!quotes.length)
|
|
172
|
-
return "No quotes found matching your search criteria.";
|
|
268
|
+
return "No quotes found matching your search criteria." + footer;
|
|
173
269
|
const total = data.total_results ?? quotes.length;
|
|
174
270
|
const ms = data.processing_time_ms ?? 0;
|
|
175
271
|
const lines = [`**${total} results** (showing ${quotes.length}, ${ms.toFixed(0)}ms)\n`];
|
|
176
272
|
for (let i = 0; i < quotes.length; i++) {
|
|
177
273
|
const q = quotes[i];
|
|
178
274
|
const spk = q.speaker ?? {};
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const date = fmtDate(q.episode_date);
|
|
186
|
-
const text = (q.text ?? "").slice(0, 400);
|
|
187
|
-
const segId = q.segment_id ?? "?";
|
|
188
|
-
const contentId = q.content_id ?? "?";
|
|
275
|
+
const resolved = spk.identity_id != null;
|
|
276
|
+
const spkName = resolved ? (spk.name ?? `Speaker ${spk.identity_id}`) : "Unresolved speaker";
|
|
277
|
+
const spkToken = resolved ? `speaker:${spk.identity_id}` : "speaker:unresolved (diarization cluster)";
|
|
278
|
+
const sim = q.similarity;
|
|
279
|
+
const date = fmtDate(q.episode_date ?? q.publish_date);
|
|
280
|
+
const segId = q.segment_id;
|
|
189
281
|
const tStart = q.timestamp_start ?? q.start_time;
|
|
190
282
|
const tEnd = q.timestamp_end ?? q.end_time;
|
|
283
|
+
const tokens = [spkToken, confToken(spk.confidence)];
|
|
284
|
+
if (spk.appearance_type)
|
|
285
|
+
tokens.push(`appearance:${spk.appearance_type}`);
|
|
286
|
+
if (sim != null)
|
|
287
|
+
tokens.push(`sim:${sim.toFixed(2)}`);
|
|
288
|
+
tokens.push(`date:${date}`);
|
|
289
|
+
if (tStart != null)
|
|
290
|
+
tokens.push(fmtTimeRange(tStart, tEnd));
|
|
291
|
+
const lt = langToken(q.language);
|
|
292
|
+
if (lt)
|
|
293
|
+
tokens.push(lt);
|
|
294
|
+
if (q.channel_id != null)
|
|
295
|
+
tokens.push(`channel_id:${q.channel_id}`);
|
|
296
|
+
tokens.push(`channel:${q.channel_name ?? "n/a"}`);
|
|
297
|
+
lines.push(`${i + 1}. **${spkName}** — ${tokens.join(" ")}`);
|
|
298
|
+
lines.push(` seg:${segId ?? "n/a"} episode:${q.content_id ?? "n/a"}`);
|
|
191
299
|
const clip = clipUrl(q.content_id, tStart ?? undefined, tEnd ?? undefined, q.clip_url);
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (clip)
|
|
201
|
-
lines.push(` 🔊 ${clip}`);
|
|
202
|
-
lines.push(` > ${text}\n`);
|
|
203
|
-
}
|
|
204
|
-
if (total > quotes.length) {
|
|
205
|
-
lines.push(`_${total - quotes.length} more results available (use offset parameter)_`);
|
|
206
|
-
}
|
|
207
|
-
return lines.join("\n");
|
|
300
|
+
lines.push(` ${clipLine(q.content_type, clip)}`);
|
|
301
|
+
const pointer = segId != null ? `get_segment seg:${segId}` : undefined;
|
|
302
|
+
lines.push(` > ${truncateWithPointer(q.text, pointer)}\n`);
|
|
303
|
+
}
|
|
304
|
+
const first = quotes[0];
|
|
305
|
+
const hint = nextCallHint(first?.segment_id != null ? `get_segment seg:${first.segment_id}` : undefined, first?.speaker?.identity_id != null ? `get_speaker speaker_id:${first.speaker.identity_id}` : undefined);
|
|
306
|
+
const paging = pagingNote({ shown: quotes.length, total, offset, countCapped: data.count_capped });
|
|
307
|
+
return withFooters(lines.join("\n"), footer, paging, hint);
|
|
208
308
|
}
|
|
209
309
|
// ---------------------------------------------------------------------------
|
|
210
310
|
// Claims
|
|
211
311
|
// ---------------------------------------------------------------------------
|
|
212
|
-
|
|
312
|
+
/** Episode-count harmonization (spec §9): `episodes:` is the full count across
|
|
313
|
+
* modalities; the AV-only subset is labeled `av_episodes:` when text exists. */
|
|
314
|
+
function episodeTokens(c) {
|
|
315
|
+
let s = `episodes:${c.episode_count}`;
|
|
316
|
+
if (c.text_count)
|
|
317
|
+
s += ` av_episodes:${c.episode_count - c.text_count}`;
|
|
318
|
+
return s;
|
|
319
|
+
}
|
|
320
|
+
/** One compact claim line — used on EVERY claim list surface (spec §3). */
|
|
321
|
+
export function claimLine(c, idx, opts = {}) {
|
|
322
|
+
const tokens = [`claim:${c.claim_id}`];
|
|
323
|
+
if (c.stance)
|
|
324
|
+
tokens.push(`stance:${c.stance}`);
|
|
325
|
+
if (c.topic)
|
|
326
|
+
tokens.push(`topic:${c.topic}`);
|
|
327
|
+
if (c.similarity != null)
|
|
328
|
+
tokens.push(`sim:${c.similarity.toFixed(2)}`);
|
|
329
|
+
const lt = langToken(c.language);
|
|
330
|
+
if (lt)
|
|
331
|
+
tokens.push(lt);
|
|
332
|
+
const detail = [`obs:${c.observation_count}`, episodeTokens(c), `channels:${c.channel_count}`];
|
|
333
|
+
// Only render the split when BOTH counts are known — a lone count with the
|
|
334
|
+
// other defaulted to 0 would fabricate a zero (spec §4/§9 unknown ≠ zero).
|
|
335
|
+
if (opts.assertDeny && c.assert_count != null && c.deny_count != null) {
|
|
336
|
+
detail.push(`assert:${c.assert_count} deny:${c.deny_count}`);
|
|
337
|
+
}
|
|
338
|
+
if (c.first_seen)
|
|
339
|
+
detail.push(`first_seen:${fmtDate(c.first_seen)}`);
|
|
340
|
+
if (c.last_seen)
|
|
341
|
+
detail.push(`last_seen:${fmtDate(c.last_seen)}`);
|
|
342
|
+
const prefix = idx != null ? `${idx}. ` : "- ";
|
|
343
|
+
return `${prefix}**${c.summary}** — ${tokens.join(" ")}\n ${detail.join(" ")}`;
|
|
344
|
+
}
|
|
345
|
+
function anyNonEnglish(claims) {
|
|
346
|
+
return claims.some((c) => c.language && c.language !== "en");
|
|
347
|
+
}
|
|
348
|
+
function claimSpeakerParts(speakers, n = 4) {
|
|
349
|
+
return speakers.slice(0, n)
|
|
350
|
+
.map((s) => `${s.name} (speaker:${s.identity_id ?? "n/a"}, ${s.observation_count})`)
|
|
351
|
+
.join(", ");
|
|
352
|
+
}
|
|
353
|
+
/** Notice prepended when the backend reports the speaker_name filter did not
|
|
354
|
+
* resolve (speaker_resolved === false). Absent field → no notice. */
|
|
355
|
+
export function speakerResolutionNote(data) {
|
|
356
|
+
if (data.speaker_resolved !== false)
|
|
357
|
+
return "";
|
|
358
|
+
return "Note: speaker name did not resolve to a known speaker — 0 results may mean an unrecognized/variant spelling (ASR may render names phonetically), not that the speaker never said this. Try find_speakers to locate the canonical name.\n\n";
|
|
359
|
+
}
|
|
360
|
+
export function formatClaims(data, footer = "", offset = 0) {
|
|
213
361
|
const claims = data.claims ?? [];
|
|
362
|
+
const note = speakerResolutionNote(data);
|
|
214
363
|
if (!claims.length)
|
|
215
|
-
return "No claims found matching your search criteria.";
|
|
364
|
+
return note + "No claims found matching your search criteria." + footer;
|
|
216
365
|
const total = data.total_results ?? claims.length;
|
|
217
366
|
const ms = data.processing_time_ms ?? 0;
|
|
218
|
-
const mode = data.search_mode ?? "
|
|
367
|
+
const mode = data.search_mode ?? "n/a";
|
|
219
368
|
const lines = [`**${total} claims** (showing ${claims.length}, ${mode}, ${ms.toFixed(0)}ms)\n`];
|
|
220
369
|
for (let i = 0; i < claims.length; i++) {
|
|
221
370
|
const c = claims[i];
|
|
222
|
-
|
|
223
|
-
const tier = sim != null ? claimSimTier(sim) : "";
|
|
224
|
-
const tierStr = tier ? ` ${tier}` : "";
|
|
225
|
-
const simStr = sim != null ? ` sim:${sim.toFixed(2)}${tierStr}` : "";
|
|
226
|
-
const stanceStr = c.stance ? ` [${c.stance}]` : "";
|
|
227
|
-
const topicStr = c.topic ? ` (${c.topic})` : "";
|
|
228
|
-
const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
|
|
229
|
-
lines.push(`${i + 1}. **${c.summary}**${stanceStr}${topicStr}${simStr}`);
|
|
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` +
|
|
233
|
-
(span ? ` (${span})` : ""));
|
|
371
|
+
lines.push(claimLine(c, i + 1));
|
|
234
372
|
if (c.entities.length) {
|
|
235
|
-
lines.push(`
|
|
373
|
+
lines.push(` entities:${c.entities.join(",")}`);
|
|
236
374
|
}
|
|
237
375
|
const speakers = c.top_speakers ?? [];
|
|
238
376
|
if (speakers.length) {
|
|
239
|
-
|
|
240
|
-
const idStr = s.identity_id ? ` id:${s.identity_id}` : "";
|
|
241
|
-
return `${s.name}${idStr} (${s.observation_count})`;
|
|
242
|
-
});
|
|
243
|
-
lines.push(` Speakers: ${parts.join(", ")}`);
|
|
377
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers)}`);
|
|
244
378
|
}
|
|
245
379
|
const channels = c.top_channels ?? [];
|
|
246
380
|
if (channels.length) {
|
|
247
|
-
const parts = channels.slice(0, 4).map((ch) => `${ch.name} (${ch.observation_count})`);
|
|
248
|
-
lines.push(`
|
|
381
|
+
const parts = channels.slice(0, 4).map((ch) => `${ch.name} (channel_id:${ch.channel_id}, ${ch.observation_count})`);
|
|
382
|
+
lines.push(` channels: ${parts.join(", ")}`);
|
|
249
383
|
}
|
|
250
384
|
const obs = c.sample_observations ?? [];
|
|
251
385
|
if (obs.length) {
|
|
252
386
|
const o = obs[0];
|
|
253
387
|
const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
|
|
254
388
|
const label = [o.speaker_name, o.channel_name, fmtDate(o.publish_date)].filter(Boolean).join(", ");
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (clip)
|
|
259
|
-
lines.push(` 🔊 ${clip}`);
|
|
389
|
+
const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
|
|
390
|
+
lines.push(` sample: "${truncateWithPointer(o.chunk_text, pointer)}" — ${label}`);
|
|
391
|
+
lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`);
|
|
260
392
|
}
|
|
261
393
|
lines.push("");
|
|
262
394
|
}
|
|
263
|
-
if (
|
|
264
|
-
lines.push(
|
|
265
|
-
}
|
|
266
|
-
|
|
395
|
+
if (anyNonEnglish(claims))
|
|
396
|
+
lines.push(NON_ENGLISH_NOTE, "");
|
|
397
|
+
const paging = pagingNote({ shown: claims.length, total, offset });
|
|
398
|
+
const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
|
|
399
|
+
return withFooters(note + lines.join("\n"), footer, paging, hint);
|
|
267
400
|
}
|
|
268
401
|
// ---------------------------------------------------------------------------
|
|
269
402
|
// Claims analysis (/api/v1/analysis/claims/*)
|
|
270
403
|
// ---------------------------------------------------------------------------
|
|
271
|
-
export function formatClaimsTopicAnalysis(data, topic) {
|
|
404
|
+
export function formatClaimsTopicAnalysis(data, topic, footer = "") {
|
|
272
405
|
if (!data.themes?.length) {
|
|
273
|
-
return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic
|
|
406
|
+
return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.` + footer;
|
|
274
407
|
}
|
|
275
408
|
const m = data.quantitative_metrics ?? {};
|
|
276
409
|
const lines = [`# Claims analysis: "${topic}"`, ""];
|
|
@@ -284,52 +417,71 @@ export function formatClaimsTopicAnalysis(data, topic) {
|
|
|
284
417
|
].filter(Boolean);
|
|
285
418
|
if (summaryParts.length)
|
|
286
419
|
lines.push(summaryParts.join(" · "), "");
|
|
420
|
+
let firstClaimId;
|
|
287
421
|
for (const theme of data.themes) {
|
|
288
422
|
lines.push(`## ${theme.name} — ${theme.claim_count} claims, ${theme.total_observations} obs across ${theme.channel_count} channels`);
|
|
289
423
|
if (theme.entities?.length)
|
|
290
|
-
lines.push(`
|
|
424
|
+
lines.push(`entities:${theme.entities.slice(0, 8).join(",")}`);
|
|
291
425
|
for (const c of theme.claims) {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
426
|
+
const tokens = [`claim:${c.claim_id ?? "n/a"}`];
|
|
427
|
+
if (firstClaimId == null && c.claim_id != null)
|
|
428
|
+
firstClaimId = c.claim_id;
|
|
429
|
+
if (c.stance)
|
|
430
|
+
tokens.push(`stance:${c.stance}`);
|
|
431
|
+
const detail = [`obs:${c.observation_count}`, `channels:${c.channel_count}`];
|
|
432
|
+
if (c.first_seen)
|
|
433
|
+
detail.push(`first_seen:${fmtDate(c.first_seen)}`);
|
|
434
|
+
if (c.last_seen)
|
|
435
|
+
detail.push(`last_seen:${fmtDate(c.last_seen)}`);
|
|
436
|
+
lines.push(`- **${c.summary}** — ${tokens.join(" ")}`);
|
|
437
|
+
lines.push(` ${detail.join(" ")}`);
|
|
438
|
+
const speakers = (c.top_speakers ?? []).slice(0, 3);
|
|
296
439
|
if (speakers.length)
|
|
297
|
-
lines.push(`
|
|
440
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
|
|
298
441
|
}
|
|
299
442
|
lines.push("");
|
|
300
443
|
}
|
|
301
|
-
|
|
444
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
445
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
302
446
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
};
|
|
306
|
-
export function formatClaimsTrend(data, topic) {
|
|
447
|
+
export function formatClaimsTrend(data, topic, footer = "") {
|
|
448
|
+
const note = speakerResolutionNote(data);
|
|
307
449
|
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
|
|
450
|
+
return note + `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
|
|
309
451
|
}
|
|
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
452
|
const lines = [
|
|
313
|
-
`# Claim trend: "${topic}" — ${
|
|
453
|
+
`# Claim trend: "${topic}" — ${trendToken(data.direction, data.change_pct)}`,
|
|
314
454
|
"",
|
|
315
|
-
`
|
|
455
|
+
`recent (${data.recent.days}d): ${data.recent.claims} claims` +
|
|
316
456
|
(data.recent.new_claims != null ? ` (${data.recent.new_claims} new)` : "") +
|
|
317
457
|
`, ${data.recent.observations} obs, ${data.recent.per_day}/day`,
|
|
318
|
-
`
|
|
458
|
+
`baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
|
|
319
459
|
];
|
|
320
|
-
|
|
460
|
+
const hint = nextCallHint(`search_claims topic:"${topic}"`, `get_timeline topic:"${topic}"`);
|
|
461
|
+
return withFooters(note + lines.join("\n"), footer, "", hint);
|
|
321
462
|
}
|
|
322
|
-
export function formatClaimsContradictions(data, speaker, topic) {
|
|
463
|
+
export function formatClaimsContradictions(data, speaker, topic, footer = "") {
|
|
323
464
|
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
|
|
465
|
+
return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.` + footer;
|
|
325
466
|
}
|
|
326
|
-
const lines = [
|
|
467
|
+
const lines = [
|
|
468
|
+
`# Contradiction check: ${speaker} on "${topic}"`, "",
|
|
469
|
+
`Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, "",
|
|
470
|
+
];
|
|
471
|
+
let firstClaimId;
|
|
472
|
+
// Contradiction claim texts are synthesized summaries (never quote-marked)
|
|
473
|
+
// and are NEVER truncated (spec §4/§6).
|
|
474
|
+
const side = (label, s) => {
|
|
475
|
+
if (firstClaimId == null && s.claim_id != null)
|
|
476
|
+
firstClaimId = s.claim_id;
|
|
477
|
+
return ` • ${label} — claim:${s.claim_id ?? "n/a"} stance:${s.stance} date:${fmtDate(s.date)} obs:${s.observations}\n ${s.claim}`;
|
|
478
|
+
};
|
|
327
479
|
if (data.contradictions.length) {
|
|
328
480
|
lines.push("## Contradictions");
|
|
329
481
|
for (const c of data.contradictions) {
|
|
330
482
|
lines.push(`- [${c.severity}] ${c.description}`);
|
|
331
|
-
lines.push(
|
|
332
|
-
lines.push(
|
|
483
|
+
lines.push(side("earlier", c.earlier));
|
|
484
|
+
lines.push(side("later", c.later));
|
|
333
485
|
}
|
|
334
486
|
lines.push("");
|
|
335
487
|
}
|
|
@@ -339,170 +491,181 @@ export function formatClaimsContradictions(data, speaker, topic) {
|
|
|
339
491
|
if (data.consistent_positions.length) {
|
|
340
492
|
lines.push("## Consistent positions");
|
|
341
493
|
for (const p of data.consistent_positions) {
|
|
342
|
-
lines.push(`-
|
|
494
|
+
lines.push(`- **${p.claim}** — claim:${p.claim_id ?? "n/a"} stance:${p.stance} obs:${p.observations}`);
|
|
495
|
+
if (firstClaimId == null && p.claim_id != null)
|
|
496
|
+
firstClaimId = p.claim_id;
|
|
343
497
|
}
|
|
498
|
+
lines.push("");
|
|
344
499
|
}
|
|
345
|
-
|
|
500
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
501
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
502
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
346
503
|
}
|
|
347
|
-
export function formatClaimsComparison(data, topic) {
|
|
504
|
+
export function formatClaimsComparison(data, topic, footer = "") {
|
|
348
505
|
const lines = [`# Speaker comparison on claims: "${topic}"`, ""];
|
|
506
|
+
let firstClaimId;
|
|
349
507
|
for (const sp of data.speakers) {
|
|
350
508
|
lines.push(`## ${sp.name} — ${sp.claim_count} claims${sp.framing ? ` (${sp.framing})` : ""}`);
|
|
351
509
|
if (sp.position_summary)
|
|
352
|
-
lines.push(sp.position_summary);
|
|
510
|
+
lines.push(rewriteSegCitations(sp.position_summary));
|
|
353
511
|
const sb = Object.entries(sp.stance_breakdown ?? {});
|
|
354
512
|
if (sb.length)
|
|
355
|
-
lines.push(`
|
|
513
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
356
514
|
for (const kc of (sp.key_claims ?? []).slice(0, 3)) {
|
|
357
|
-
|
|
515
|
+
// Key-claim texts are synthesized and never truncated (spec §4/§6).
|
|
516
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
517
|
+
if (firstClaimId == null && kc.claim_id != null)
|
|
518
|
+
firstClaimId = kc.claim_id;
|
|
358
519
|
}
|
|
359
520
|
lines.push("");
|
|
360
521
|
}
|
|
361
522
|
if (data.agreements?.length) {
|
|
362
|
-
lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
|
|
523
|
+
lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
|
|
363
524
|
}
|
|
364
525
|
if (data.disagreements?.length) {
|
|
365
|
-
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
|
|
526
|
+
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
|
|
366
527
|
}
|
|
367
|
-
|
|
528
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
529
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
530
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
368
531
|
}
|
|
369
|
-
export function formatChannelDossier(data) {
|
|
532
|
+
export function formatChannelDossier(data, footer = "") {
|
|
370
533
|
const label = data.channels.join(", ");
|
|
371
534
|
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
|
|
535
|
+
return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.` + footer;
|
|
373
536
|
}
|
|
374
537
|
const lines = [`# Channel dossier: ${label} on "${data.topic}"`, ""];
|
|
375
538
|
lines.push(`${data.claims_analyzed} claims analyzed${data.total_claims_available ? ` (${data.total_claims_available} available)` : ""}`);
|
|
376
539
|
const sb = Object.entries(data.stance_breakdown ?? {});
|
|
377
540
|
if (sb.length)
|
|
378
|
-
lines.push(`
|
|
541
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
379
542
|
lines.push("");
|
|
380
543
|
if (data.themes.length) {
|
|
381
544
|
lines.push("## Themes");
|
|
382
545
|
for (const t of data.themes) {
|
|
383
546
|
lines.push(`- **${t.name}** — ${t.claim_count} claims, ${t.total_observations} obs`);
|
|
384
547
|
if (t.summary)
|
|
385
|
-
lines.push(` ${
|
|
548
|
+
lines.push(` ${rewriteSegCitations(t.summary)}`);
|
|
386
549
|
}
|
|
387
550
|
lines.push("");
|
|
388
551
|
}
|
|
389
552
|
if (data.key_speakers.length) {
|
|
390
|
-
lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (${s.claim_count})`).join(", ")}`, "");
|
|
553
|
+
lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"}, ${s.claim_count})`).join(", ")}`, "");
|
|
391
554
|
}
|
|
555
|
+
let firstClaimId;
|
|
392
556
|
if (data.key_claims.length) {
|
|
393
557
|
lines.push("## Key claims");
|
|
394
558
|
for (const kc of data.key_claims) {
|
|
395
|
-
|
|
559
|
+
if (firstClaimId == null)
|
|
560
|
+
firstClaimId = kc.claim_id;
|
|
561
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
396
562
|
}
|
|
397
563
|
lines.push("");
|
|
398
564
|
}
|
|
399
565
|
if (data.trajectory.length > 1) {
|
|
400
|
-
lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}
|
|
566
|
+
lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`, "");
|
|
401
567
|
}
|
|
402
|
-
|
|
568
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
569
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
570
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
571
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
403
572
|
}
|
|
404
|
-
export function formatChannelClaimsComparison(data, topic) {
|
|
573
|
+
export function formatChannelClaimsComparison(data, topic, footer = "") {
|
|
405
574
|
const lines = [`# Channel comparison on claims: "${topic}"`, ""];
|
|
575
|
+
let firstClaimId;
|
|
406
576
|
for (const ch of data.channels) {
|
|
407
577
|
lines.push(`## ${ch.name} — ${ch.claim_count} claims${ch.framing ? ` (${ch.framing})` : ""}`);
|
|
408
578
|
if (ch.position_summary)
|
|
409
|
-
lines.push(ch.position_summary);
|
|
579
|
+
lines.push(rewriteSegCitations(ch.position_summary));
|
|
410
580
|
const sb = Object.entries(ch.stance_breakdown ?? {});
|
|
411
581
|
if (sb.length)
|
|
412
|
-
lines.push(`
|
|
582
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
413
583
|
if (ch.key_speakers?.length) {
|
|
414
|
-
lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => s.name).join(", ")}`);
|
|
584
|
+
lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
|
|
415
585
|
}
|
|
416
586
|
for (const kc of (ch.key_claims ?? []).slice(0, 3)) {
|
|
417
|
-
lines.push(`-
|
|
587
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
588
|
+
if (firstClaimId == null && kc.claim_id != null)
|
|
589
|
+
firstClaimId = kc.claim_id;
|
|
418
590
|
}
|
|
419
591
|
lines.push("");
|
|
420
592
|
}
|
|
421
593
|
if (data.agreements?.length) {
|
|
422
|
-
lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
|
|
594
|
+
lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
|
|
423
595
|
}
|
|
424
596
|
if (data.disagreements?.length) {
|
|
425
|
-
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
|
|
597
|
+
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
|
|
426
598
|
}
|
|
427
599
|
if (data.speaker_overlap?.length) {
|
|
428
600
|
lines.push("## Shared speakers");
|
|
429
601
|
for (const o of data.speaker_overlap) {
|
|
430
602
|
lines.push(`- ${o.channels.join(" & ")}: ${o.shared_speakers.join(", ")}`);
|
|
431
603
|
}
|
|
604
|
+
lines.push("");
|
|
432
605
|
}
|
|
433
|
-
|
|
606
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
607
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
608
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
609
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
434
610
|
}
|
|
435
611
|
// ---------------------------------------------------------------------------
|
|
436
612
|
// Claims graph (entity-centric, hierarchy/relations, leaderboard)
|
|
437
613
|
// ---------------------------------------------------------------------------
|
|
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
614
|
export function formatEntitySearch(data) {
|
|
456
615
|
if (!data.entities.length) {
|
|
457
616
|
return `No entities found matching "${data.query}". Try a shorter or differently-spelled query.`;
|
|
458
617
|
}
|
|
459
618
|
const lines = [`# Entities matching "${data.query}"`, ""];
|
|
460
619
|
for (const e of data.entities) {
|
|
461
|
-
const
|
|
620
|
+
const tokens = [`entity:${e.id}`];
|
|
462
621
|
if (e.entity_type)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
622
|
+
tokens.push(`type:${e.entity_type}`);
|
|
623
|
+
tokens.push(`claims:${e.claim_count}`, `channels:${e.channel_count}`);
|
|
624
|
+
if (e.first_seen_date)
|
|
625
|
+
tokens.push(`first_seen:${fmtDate(e.first_seen_date)}`);
|
|
626
|
+
if (e.last_seen_date)
|
|
627
|
+
tokens.push(`last_seen:${fmtDate(e.last_seen_date)}`);
|
|
628
|
+
lines.push(`- **${e.name}** — ${tokens.join(" ")}`);
|
|
469
629
|
if (e.aliases?.length)
|
|
470
|
-
lines.push(`
|
|
630
|
+
lines.push(` aliases:${e.aliases.slice(0, 5).join(",")}`);
|
|
471
631
|
}
|
|
472
|
-
|
|
473
|
-
return lines.join("\n");
|
|
632
|
+
const hint = nextCallHint(`entity_claims entity:"${data.entities[0].name}"`);
|
|
633
|
+
return lines.join("\n") + hint;
|
|
474
634
|
}
|
|
475
|
-
export function formatEntityClaims(data) {
|
|
635
|
+
export function formatEntityClaims(data, footer = "", offset = 0) {
|
|
476
636
|
if (!data.entity_id) {
|
|
477
|
-
return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching
|
|
637
|
+
return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.` + footer;
|
|
478
638
|
}
|
|
479
639
|
const lines = [`# Claims about ${data.entity}`];
|
|
480
|
-
const
|
|
640
|
+
const tokens = [`entity:${data.entity_id}`];
|
|
481
641
|
if (data.entity_type)
|
|
482
|
-
|
|
642
|
+
tokens.push(`type:${data.entity_type}`);
|
|
483
643
|
if (data.claim_count_rollup != null)
|
|
484
|
-
|
|
644
|
+
tokens.push(`claims:${data.claim_count_rollup}`);
|
|
485
645
|
if (data.channel_count != null)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
lines.push(meta.join(" · "));
|
|
646
|
+
tokens.push(`channels:${data.channel_count}`);
|
|
647
|
+
lines.push(tokens.join(" "));
|
|
489
648
|
if (data.aliases?.length)
|
|
490
|
-
lines.push(`
|
|
649
|
+
lines.push(`aliases:${data.aliases.slice(0, 6).join(",")}`);
|
|
491
650
|
if (data.stance_distribution?.length) {
|
|
492
651
|
const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
|
|
493
652
|
const parts = data.stance_distribution
|
|
494
|
-
.map((s) => `${s.stance}
|
|
495
|
-
lines.push(`\
|
|
653
|
+
.map((s) => `${s.stance}:${s.count} (${Math.round((s.count / total) * 100)}%)`);
|
|
654
|
+
lines.push(`\nstance: ${parts.join(" ")}`);
|
|
496
655
|
}
|
|
497
|
-
lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims
|
|
656
|
+
lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims})`);
|
|
498
657
|
data.claims.forEach((c, i) => lines.push(claimLine(c, i + 1)));
|
|
499
|
-
if (data.
|
|
500
|
-
lines.push(
|
|
501
|
-
|
|
658
|
+
if (anyNonEnglish(data.claims))
|
|
659
|
+
lines.push("", NON_ENGLISH_NOTE);
|
|
660
|
+
const paging = pagingNote({ shown: data.claims.length, total: data.total_claims, offset });
|
|
661
|
+
const hint = data.claims.length
|
|
662
|
+
? nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`)
|
|
663
|
+
: "";
|
|
664
|
+
return withFooters(lines.join("\n"), footer, paging, hint);
|
|
502
665
|
}
|
|
503
666
|
export function formatClaimGraph(data) {
|
|
504
667
|
const lines = [`# Claim graph`, "", `**${data.claim.summary}**`,
|
|
505
|
-
`
|
|
668
|
+
`claim:${data.claim.claim_id} obs:${data.claim.observation_count} channels:${data.claim.channel_count}`, ""];
|
|
506
669
|
if (data.parent) {
|
|
507
670
|
lines.push("## Broader claim (parent)", claimLine(data.parent), "");
|
|
508
671
|
}
|
|
@@ -519,21 +682,27 @@ export function formatClaimGraph(data) {
|
|
|
519
682
|
if (!data.parent && !data.children?.length && !data.siblings?.length) {
|
|
520
683
|
lines.push("_No parent, children, or sibling claims found. This claim stands alone in the hierarchy._");
|
|
521
684
|
}
|
|
522
|
-
|
|
685
|
+
const next = data.children?.[0] ?? data.siblings?.[0] ?? data.parent;
|
|
686
|
+
const hint = nextCallHint(`get_claim claim:${data.claim.claim_id} include_observations:true`, next ? `claim_graph claim_id:${next.claim_id}` : undefined);
|
|
687
|
+
return lines.join("\n") + hint;
|
|
523
688
|
}
|
|
524
|
-
export function formatTopClaims(data) {
|
|
689
|
+
export function formatTopClaims(data, footer = "") {
|
|
525
690
|
if (!data.claims?.length) {
|
|
526
|
-
return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations
|
|
691
|
+
return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.` + footer;
|
|
527
692
|
}
|
|
528
|
-
const
|
|
693
|
+
const contested = data.metric === "contested";
|
|
694
|
+
const label = contested ? "Most contested claims" : "Most widely-spread claims";
|
|
529
695
|
const lines = [`# ${label}`, ""];
|
|
530
696
|
data.claims.forEach((c, i) => {
|
|
531
|
-
lines.push(claimLine(c, i + 1));
|
|
532
|
-
const speakers = (c.top_speakers ?? []).slice(0, 3)
|
|
697
|
+
lines.push(claimLine(c, i + 1, { assertDeny: contested }));
|
|
698
|
+
const speakers = (c.top_speakers ?? []).slice(0, 3);
|
|
533
699
|
if (speakers.length)
|
|
534
|
-
lines.push(`
|
|
700
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
|
|
535
701
|
});
|
|
536
|
-
|
|
702
|
+
if (anyNonEnglish(data.claims))
|
|
703
|
+
lines.push("", NON_ENGLISH_NOTE);
|
|
704
|
+
const hint = nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`);
|
|
705
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
537
706
|
}
|
|
538
707
|
// ---------------------------------------------------------------------------
|
|
539
708
|
// Speaker
|
|
@@ -561,31 +730,31 @@ export function formatSpeaker(data) {
|
|
|
561
730
|
lines.push(`**Stats:** ${stats.join(", ")}`);
|
|
562
731
|
const sid = data.speaker_id ?? data.id;
|
|
563
732
|
if (sid)
|
|
564
|
-
lines.push(`
|
|
733
|
+
lines.push(`speaker:${sid}`);
|
|
565
734
|
const topChannels = data.top_channels ?? [];
|
|
566
735
|
if (topChannels.length) {
|
|
567
736
|
lines.push("\n**Top Channels:**");
|
|
568
737
|
for (const ch of topChannels.slice(0, 8)) {
|
|
569
|
-
lines.push(`- ${ch.name ?? "
|
|
738
|
+
lines.push(`- ${ch.name ?? "n/a"} — channel_id:${ch.channel_id ?? "n/a"} appearances:${ch.count ?? 0}`);
|
|
570
739
|
}
|
|
571
740
|
}
|
|
572
741
|
const related = data.related_speakers ?? [];
|
|
573
742
|
if (related.length) {
|
|
574
743
|
lines.push("\n**Co-appearances:**");
|
|
575
744
|
for (const r of related.slice(0, 10)) {
|
|
576
|
-
const count = r.appearance_count ?? r.co_appearances ?? "
|
|
577
|
-
lines.push(`- ${r.name ?? "
|
|
745
|
+
const count = r.appearance_count ?? r.co_appearances ?? "n/a";
|
|
746
|
+
lines.push(`- ${r.name ?? "n/a"} — speaker:${r.speaker_id ?? r.identity_id ?? "n/a"} count:${count}`);
|
|
578
747
|
}
|
|
579
748
|
}
|
|
580
749
|
const recent = data.recent_episodes ?? [];
|
|
581
750
|
if (recent.length) {
|
|
582
751
|
lines.push("\n**Recent Episodes:**");
|
|
583
752
|
for (const ep of recent.slice(0, 8)) {
|
|
584
|
-
|
|
585
|
-
lines.push(`- [${date}] ${ep.channel_name ?? "?"} — ${ep.title ?? "?"}`);
|
|
753
|
+
lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.channel_name ?? "n/a"} — ${ep.title ?? "n/a"}`);
|
|
586
754
|
}
|
|
587
755
|
}
|
|
588
|
-
|
|
756
|
+
const hint = nextCallHint(sid ? `search_quotes speaker_id:${sid} sort:chronological` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
|
|
757
|
+
return lines.join("\n") + hint;
|
|
589
758
|
}
|
|
590
759
|
// ---------------------------------------------------------------------------
|
|
591
760
|
// Channel
|
|
@@ -602,12 +771,10 @@ export function formatChannel(data) {
|
|
|
602
771
|
meta.push(`**Language:** ${data.language}`);
|
|
603
772
|
if (meta.length)
|
|
604
773
|
lines.push(meta.join(" | "));
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
lines.push(`**Political lean:** ${lean.toFixed(2)} (${label})`);
|
|
610
|
-
}
|
|
774
|
+
const cid = data.channel_id ?? data.id;
|
|
775
|
+
lines.push(`channel_id:${cid ?? "n/a"}`);
|
|
776
|
+
if (data.political_lean != null)
|
|
777
|
+
lines.push(leanToken(data.political_lean));
|
|
611
778
|
if (data.description)
|
|
612
779
|
lines.push(`\n${data.description.slice(0, 500)}`);
|
|
613
780
|
if (data.primary_url)
|
|
@@ -622,52 +789,58 @@ export function formatChannel(data) {
|
|
|
622
789
|
if (parts.length)
|
|
623
790
|
lines.push(`\n**Ranking:** ${parts.join(", ")}`);
|
|
624
791
|
for (const cp of (ranking.chart_positions ?? []).slice(0, 5)) {
|
|
625
|
-
lines.push(`- Chart: ${cp.platform ?? "
|
|
792
|
+
lines.push(`- Chart: ${cp.platform ?? "n/a"} ${cp.country ?? "n/a"}/${cp.category ?? "n/a"} #${cp.rank ?? "n/a"}`);
|
|
626
793
|
}
|
|
627
794
|
}
|
|
628
795
|
const speakers = data.regular_speakers ?? data.speakers ?? [];
|
|
629
796
|
if (speakers.length) {
|
|
630
797
|
lines.push("\n**Speakers:**");
|
|
631
798
|
for (const s of speakers.slice(0, 10)) {
|
|
632
|
-
const count = s.appearance_count ?? s.count ?? "
|
|
633
|
-
lines.push(`- ${s.name ?? "
|
|
799
|
+
const count = s.appearance_count ?? s.count ?? "n/a";
|
|
800
|
+
lines.push(`- ${s.name ?? "n/a"} — speaker:${s.speaker_id ?? s.identity_id ?? "n/a"} appearances:${count}`);
|
|
634
801
|
}
|
|
635
802
|
}
|
|
636
803
|
const recent = data.recent_episodes ?? [];
|
|
637
804
|
if (recent.length) {
|
|
638
805
|
lines.push("\n**Recent Episodes:**");
|
|
639
806
|
for (const ep of recent.slice(0, 8)) {
|
|
640
|
-
lines.push(`-
|
|
807
|
+
lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.title ?? "n/a"}`);
|
|
641
808
|
}
|
|
642
809
|
}
|
|
643
|
-
|
|
810
|
+
const hint = nextCallHint(cid != null ? `search_quotes channel_id:${cid}` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
|
|
811
|
+
return lines.join("\n") + hint;
|
|
644
812
|
}
|
|
645
813
|
// ---------------------------------------------------------------------------
|
|
646
814
|
// Segment
|
|
647
815
|
// ---------------------------------------------------------------------------
|
|
648
816
|
export function formatSegment(data) {
|
|
649
|
-
const segId = data.segment_id
|
|
650
|
-
const spk = data.
|
|
651
|
-
const
|
|
652
|
-
const
|
|
653
|
-
const
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
817
|
+
const segId = data.segment_id;
|
|
818
|
+
const spk = data.speaker ?? {};
|
|
819
|
+
const spkName = data.speaker_name ?? spk.name;
|
|
820
|
+
const resolved = spk.identity_id != null;
|
|
821
|
+
const clip = clipUrl(data.content_id, data.start_time ?? undefined, data.end_time ?? undefined, data.clip_url);
|
|
822
|
+
const speakerTokens = [];
|
|
823
|
+
if (resolved)
|
|
824
|
+
speakerTokens.push(`speaker:${spk.identity_id}`);
|
|
825
|
+
else if (!spkName)
|
|
826
|
+
speakerTokens.push("speaker:unresolved (diarization cluster)");
|
|
827
|
+
if (spk.confidence !== undefined || resolved)
|
|
828
|
+
speakerTokens.push(confToken(spk.confidence));
|
|
829
|
+
if (spk.appearance_type)
|
|
830
|
+
speakerTokens.push(`appearance:${spk.appearance_type}`);
|
|
658
831
|
const lines = [
|
|
659
|
-
`**Segment ${segId}**`,
|
|
660
|
-
`- **Speaker:** ${
|
|
661
|
-
`- **Channel:** ${
|
|
662
|
-
`- **Episode:** ${
|
|
663
|
-
`- **Date:**
|
|
832
|
+
`**Segment ${segId ?? "n/a"}**`,
|
|
833
|
+
`- **Speaker:** ${spkName ?? "unresolved (diarization cluster)"}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
|
|
834
|
+
`- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
|
|
835
|
+
`- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
|
|
836
|
+
`- **Date:** date:${fmtDate(data.episode_date)}`,
|
|
664
837
|
];
|
|
665
|
-
if (
|
|
666
|
-
lines.push(`- **Time:** ${
|
|
667
|
-
|
|
668
|
-
lines.push(`- **Audio clip:** ${clip}`);
|
|
838
|
+
if (data.start_time != null)
|
|
839
|
+
lines.push(`- **Time:** ${fmtTimeRange(data.start_time, data.end_time)}`);
|
|
840
|
+
lines.push(`- ${clipLine(data.content_type, clip)}`);
|
|
669
841
|
lines.push(`\n> ${data.text ?? ""}`);
|
|
670
|
-
|
|
842
|
+
const hint = nextCallHint(segId != null ? `get_thread segment_id:${segId}` : undefined, data.content_id ? `get_episode content_id:${data.content_id}` : undefined);
|
|
843
|
+
return lines.join("\n") + hint;
|
|
671
844
|
}
|
|
672
845
|
// ---------------------------------------------------------------------------
|
|
673
846
|
// Analysis
|
|
@@ -683,7 +856,7 @@ export function formatAnalysis(data) {
|
|
|
683
856
|
const lines = ["**Analysis complete**\n"];
|
|
684
857
|
const centrality = q.discourse_centrality;
|
|
685
858
|
if (centrality?.label) {
|
|
686
|
-
lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "
|
|
859
|
+
lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "n/a"}/5)`);
|
|
687
860
|
}
|
|
688
861
|
const parts = [];
|
|
689
862
|
if (q.total_segments)
|
|
@@ -704,19 +877,45 @@ export function formatAnalysis(data) {
|
|
|
704
877
|
const count = (t.segment_count ?? t.count);
|
|
705
878
|
const countStr = count ? ` (${count} segments)` : "";
|
|
706
879
|
lines.push(`${i + 1}. ${name}${countStr}`);
|
|
707
|
-
const desc = (t.summary ?? t.description);
|
|
880
|
+
const desc = rewriteSegCitations((t.summary ?? t.description));
|
|
708
881
|
if (desc)
|
|
709
|
-
lines.push(` ${desc
|
|
882
|
+
lines.push(` ${desc}`);
|
|
710
883
|
}
|
|
711
884
|
lines.push("");
|
|
712
885
|
}
|
|
713
886
|
if (data.summary)
|
|
714
|
-
lines.push(data.summary);
|
|
715
|
-
|
|
887
|
+
lines.push(rewriteSegCitations(data.summary));
|
|
888
|
+
const firstSeg = (data.segment_ids ?? [])[0];
|
|
889
|
+
const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
890
|
+
return lines.join("\n") + hint;
|
|
716
891
|
}
|
|
717
892
|
// ---------------------------------------------------------------------------
|
|
718
893
|
// Frame comparison (left vs right)
|
|
719
894
|
// ---------------------------------------------------------------------------
|
|
895
|
+
// LLM theme summaries can still hit the token ceiling and stop mid-sentence or
|
|
896
|
+
// mid-citation ("...to leave {seg_4225"). Drop any trailing incomplete {seg_...}
|
|
897
|
+
// citation, then cut back to the last complete sentence and flag the trim.
|
|
898
|
+
function trimTruncatedSummary(text) {
|
|
899
|
+
if (!text)
|
|
900
|
+
return text;
|
|
901
|
+
let s = text.trimEnd();
|
|
902
|
+
let trimmed = false;
|
|
903
|
+
// Dangling open citation at the end (an unclosed "{...")
|
|
904
|
+
const lastOpen = s.lastIndexOf("{");
|
|
905
|
+
if (lastOpen !== -1 && s.indexOf("}", lastOpen) === -1) {
|
|
906
|
+
s = s.slice(0, lastOpen).trimEnd();
|
|
907
|
+
trimmed = true;
|
|
908
|
+
}
|
|
909
|
+
// Mid-sentence cut: back up to the last sentence/citation boundary.
|
|
910
|
+
if (!/[.!?…}"”')\]]$/.test(s)) {
|
|
911
|
+
const m = s.match(/^[\s\S]*[.!?…}]/);
|
|
912
|
+
if (m) {
|
|
913
|
+
s = m[0];
|
|
914
|
+
trimmed = true;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return trimmed ? `${s} [summary truncated]` : s;
|
|
918
|
+
}
|
|
720
919
|
export function formatFrameComparison(left, right, topic) {
|
|
721
920
|
if (left.error && right.error)
|
|
722
921
|
return `Error: left=${left.error}, right=${right.error}`;
|
|
@@ -726,6 +925,7 @@ export function formatFrameComparison(left, right, topic) {
|
|
|
726
925
|
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
926
|
}
|
|
728
927
|
const lines = [`**Frame comparison: ${topic}**\n`];
|
|
928
|
+
let firstSeg;
|
|
729
929
|
function sideBlock(label, data) {
|
|
730
930
|
if (data.error) {
|
|
731
931
|
lines.push(`### ${label}\nError: ${data.error}\n`);
|
|
@@ -740,25 +940,34 @@ export function formatFrameComparison(left, right, topic) {
|
|
|
740
940
|
if (q.unique_speakers)
|
|
741
941
|
parts.push(`${q.unique_speakers} speakers`);
|
|
742
942
|
lines.push(`### ${label}${parts.length ? ` (${parts.join(", ")})` : ""}`);
|
|
743
|
-
|
|
943
|
+
// Render EVERY theme, numbered — the synthesis prose refers to themes by
|
|
944
|
+
// number, so truncating the list left it citing "Themes 12–19" that were
|
|
945
|
+
// never displayed. Include per-theme prose and segment citations so a
|
|
946
|
+
// reader can verify any claim (seg:<id> works with get_segment).
|
|
947
|
+
const themes = data.themes ?? [];
|
|
744
948
|
if (themes.length) {
|
|
745
949
|
lines.push("**Key themes:**");
|
|
746
|
-
|
|
950
|
+
themes.forEach((t, i) => {
|
|
747
951
|
const name = (t.name ?? t.theme_name ?? t.label);
|
|
748
|
-
const desc = (t.summary ?? t.description);
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
952
|
+
const desc = rewriteSegCitations(trimTruncatedSummary((t.summary ?? t.description)));
|
|
953
|
+
const segs = t.segment_count ? ` — ${t.segment_count} segments` : "";
|
|
954
|
+
const cites = (t.segment_ids ?? []).slice(0, 4).map((id) => `seg:${id}`).join(" ");
|
|
955
|
+
if (firstSeg == null && (t.segment_ids ?? []).length)
|
|
956
|
+
firstSeg = t.segment_ids[0];
|
|
957
|
+
lines.push(`${i + 1}. **${name ?? `Theme ${i + 1}`}**${segs}${desc ? ": " + desc : ""}${cites ? ` [${cites}]` : ""}`);
|
|
958
|
+
});
|
|
752
959
|
}
|
|
753
960
|
if (data.summary) {
|
|
754
961
|
lines.push("");
|
|
755
|
-
lines.push(data.summary);
|
|
962
|
+
lines.push(rewriteSegCitations(data.summary));
|
|
756
963
|
}
|
|
757
964
|
lines.push("");
|
|
758
965
|
}
|
|
759
966
|
sideBlock("Left-leaning media", left);
|
|
760
967
|
sideBlock("Right-leaning media", right);
|
|
761
|
-
|
|
968
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
969
|
+
const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
970
|
+
return lines.join("\n") + hint;
|
|
762
971
|
}
|
|
763
972
|
// ---------------------------------------------------------------------------
|
|
764
973
|
// Comparison
|
|
@@ -773,11 +982,12 @@ export function formatComparison(data, topic) {
|
|
|
773
982
|
const lines = [`**Speaker comparison: ${topic}**\n`];
|
|
774
983
|
for (const [name, sp] of Object.entries(speakers)) {
|
|
775
984
|
const count = sp.segment_count ?? 0;
|
|
776
|
-
|
|
985
|
+
const gated = sp.gated_out ? `, ${sp.gated_out} weak matches excluded` : "";
|
|
986
|
+
lines.push(`### ${name} (${count} on-topic segments${gated})`);
|
|
777
987
|
if (sp.position_summary)
|
|
778
|
-
lines.push(`**Position:** ${sp.position_summary}`);
|
|
988
|
+
lines.push(`**Position:** ${rewriteSegCitations(sp.position_summary)}`);
|
|
779
989
|
if (sp.framing)
|
|
780
|
-
lines.push(`**Framing:** ${sp.framing}`);
|
|
990
|
+
lines.push(`**Framing:** ${rewriteSegCitations(sp.framing)}`);
|
|
781
991
|
if (sp.tone)
|
|
782
992
|
lines.push(`**Tone:** ${sp.tone}`);
|
|
783
993
|
const claims = sp.key_claims ?? [];
|
|
@@ -785,20 +995,45 @@ export function formatComparison(data, topic) {
|
|
|
785
995
|
lines.push("**Key claims:**");
|
|
786
996
|
for (const c of claims.slice(0, 5)) {
|
|
787
997
|
const text = typeof c === "string" ? c : c.claim ?? String(c);
|
|
788
|
-
|
|
998
|
+
const cid = typeof c === "object" && c ? c.claim_id : undefined;
|
|
999
|
+
const idTok = cid != null ? ` — claim:${cid}` : "";
|
|
1000
|
+
lines.push(`- ${rewriteSegCitations(text)}${idTok}`);
|
|
789
1001
|
}
|
|
790
1002
|
}
|
|
791
1003
|
lines.push("");
|
|
792
1004
|
}
|
|
793
1005
|
if (data.comparison || data.summary) {
|
|
794
|
-
lines.push("
|
|
795
|
-
lines.push(data.comparison ?? data.summary ?? "");
|
|
1006
|
+
lines.push("## Synthesis");
|
|
1007
|
+
lines.push(rewriteSegCitations(data.comparison ?? data.summary ?? ""));
|
|
1008
|
+
lines.push("");
|
|
796
1009
|
}
|
|
1010
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
797
1011
|
return lines.join("\n");
|
|
798
1012
|
}
|
|
799
1013
|
// ---------------------------------------------------------------------------
|
|
800
1014
|
// Dossier
|
|
801
1015
|
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Render the retrieval/provenance line shared by dossier and contradictions:
|
|
1017
|
+
// window searched, dates the evidence spans, match strength, gating stats.
|
|
1018
|
+
function pushRetrievalProvenance(lines, ret) {
|
|
1019
|
+
if (!ret)
|
|
1020
|
+
return;
|
|
1021
|
+
const parts = [];
|
|
1022
|
+
if (ret.window_days)
|
|
1023
|
+
parts.push(`window: last ${ret.window_days} days`);
|
|
1024
|
+
if (ret.date_range?.length === 2)
|
|
1025
|
+
parts.push(`evidence spans ${ret.date_range[0].slice(0, 10)} → ${ret.date_range[1].slice(0, 10)}`);
|
|
1026
|
+
if (ret.similarity_range?.length === 2)
|
|
1027
|
+
parts.push(`relevance ${ret.similarity_range[0].toFixed(2)}–${ret.similarity_range[1].toFixed(2)}`);
|
|
1028
|
+
if (ret.gated_out)
|
|
1029
|
+
parts.push(`${ret.gated_out} weak matches (<${(ret.min_similarity ?? 0.4).toFixed(2)}) excluded`);
|
|
1030
|
+
if (ret.weak_match_count)
|
|
1031
|
+
parts.push(`${ret.weak_match_count} weak matches (<0.40) included`);
|
|
1032
|
+
if (parts.length)
|
|
1033
|
+
lines.push(`_${parts.join(" · ")}_`);
|
|
1034
|
+
if (ret.thin_evidence)
|
|
1035
|
+
lines.push(`_⚠ Thin evidence: fewer than 5 on-topic segments met the relevance threshold — treat conclusions as provisional._`);
|
|
1036
|
+
}
|
|
802
1037
|
export function formatDossier(data, speakerName, topic) {
|
|
803
1038
|
if (data.error)
|
|
804
1039
|
return `Error: ${data.error}`;
|
|
@@ -810,17 +1045,23 @@ export function formatDossier(data, speakerName, topic) {
|
|
|
810
1045
|
if (sp.occupation)
|
|
811
1046
|
lines.push(`*${sp.occupation}*`);
|
|
812
1047
|
if (sp.segment_count)
|
|
813
|
-
lines.push(`${sp.segment_count} segments analyzed
|
|
1048
|
+
lines.push(`${sp.segment_count} segments analyzed`);
|
|
1049
|
+
// Show the working: window searched, dates the evidence actually spans, and
|
|
1050
|
+
// match strength — a dossier built from one recent episode must not pass
|
|
1051
|
+
// for a longitudinal record.
|
|
1052
|
+
pushRetrievalProvenance(lines, sp.retrieval);
|
|
1053
|
+
lines.push("");
|
|
814
1054
|
const contras = data.contradictions;
|
|
815
1055
|
if (contras?.verdict) {
|
|
816
1056
|
lines.push(`**Contradiction check:** ${contras.verdict.replace(/_/g, " ")}`);
|
|
817
1057
|
for (const c of contras.contradictions ?? []) {
|
|
818
|
-
lines.push(`- [${c.severity ?? "
|
|
1058
|
+
lines.push(`- [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
|
|
819
1059
|
}
|
|
820
1060
|
lines.push("");
|
|
821
1061
|
}
|
|
822
1062
|
if (data.summary)
|
|
823
|
-
lines.push(data.summary);
|
|
1063
|
+
lines.push(rewriteSegCitations(data.summary), "");
|
|
1064
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
824
1065
|
return lines.join("\n");
|
|
825
1066
|
}
|
|
826
1067
|
// ---------------------------------------------------------------------------
|
|
@@ -835,13 +1076,15 @@ export function formatContradictions(data, speakerName, topic) {
|
|
|
835
1076
|
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
1077
|
}
|
|
837
1078
|
const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
|
|
1079
|
+
pushRetrievalProvenance(lines, data.speaker?.retrieval);
|
|
838
1080
|
lines.push(`**Verdict:** ${verdict.replace(/_/g, " ")}\n`);
|
|
1081
|
+
// Contradiction evidence is NEVER truncated (spec §4).
|
|
839
1082
|
for (const c of contras.contradictions ?? []) {
|
|
840
|
-
lines.push(`### [${c.severity ?? "
|
|
1083
|
+
lines.push(`### [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
|
|
841
1084
|
if (c.earlier?.claim)
|
|
842
|
-
lines.push(`**Earlier** (
|
|
1085
|
+
lines.push(`**Earlier** (date:${fmtDate(c.earlier.date)}): ${rewriteSegCitations(c.earlier.claim)}`);
|
|
843
1086
|
if (c.later?.claim)
|
|
844
|
-
lines.push(`**Later** (
|
|
1087
|
+
lines.push(`**Later** (date:${fmtDate(c.later.date)}): ${rewriteSegCitations(c.later.claim)}`);
|
|
845
1088
|
if (c.possible_context)
|
|
846
1089
|
lines.push(`*Context:* ${c.possible_context}`);
|
|
847
1090
|
lines.push("");
|
|
@@ -850,18 +1093,22 @@ export function formatContradictions(data, speakerName, topic) {
|
|
|
850
1093
|
if (consistent.length) {
|
|
851
1094
|
lines.push("**Consistent positions:**");
|
|
852
1095
|
for (const pos of consistent)
|
|
853
|
-
lines.push(`- ${pos}`);
|
|
1096
|
+
lines.push(`- ${rewriteSegCitations(pos)}`);
|
|
854
1097
|
lines.push("");
|
|
855
1098
|
}
|
|
856
1099
|
const timeline = data.stance_timeline ?? [];
|
|
857
1100
|
if (timeline.length) {
|
|
858
1101
|
lines.push("**Stance timeline:**");
|
|
859
1102
|
for (const t of timeline.slice(0, 20)) {
|
|
860
|
-
|
|
1103
|
+
const pointer = t.segment_id != null ? `get_segment seg:${t.segment_id}` : undefined;
|
|
1104
|
+
lines.push(`- seg:${t.segment_id ?? "n/a"} stance:${t.stance ?? "n/a"} — ${truncateWithPointer(t.claim, pointer)}`);
|
|
861
1105
|
}
|
|
1106
|
+
lines.push("");
|
|
862
1107
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1108
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
1109
|
+
// Note: data.summary is deliberately NOT appended — the backend builds it
|
|
1110
|
+
// from the same verdict/contradictions/consistent_positions fields rendered
|
|
1111
|
+
// above, so it duplicated the report as a dangling stub.
|
|
865
1112
|
return lines.join("\n");
|
|
866
1113
|
}
|
|
867
1114
|
// ---------------------------------------------------------------------------
|
|
@@ -871,13 +1118,13 @@ export function formatBriefing(data) {
|
|
|
871
1118
|
const lines = [];
|
|
872
1119
|
const stats = data.statistics ?? {};
|
|
873
1120
|
if (stats.total_episodes || stats.total_channels) {
|
|
874
|
-
lines.push(`**Briefing:** ${stats.total_episodes ?? "
|
|
875
|
-
`${stats.total_channels ?? "
|
|
1121
|
+
lines.push(`**Briefing:** ${stats.total_episodes ?? "n/a"} episodes, ` +
|
|
1122
|
+
`${stats.total_channels ?? "n/a"} channels, ` +
|
|
876
1123
|
`${(stats.total_duration_hours ?? 0).toFixed(0)}h\n`);
|
|
877
1124
|
}
|
|
878
1125
|
for (let i = 0; i < (data.topics ?? []).length; i++) {
|
|
879
1126
|
const t = data.topics[i];
|
|
880
|
-
const title = t.title ?? t.label ?? "
|
|
1127
|
+
const title = t.title ?? t.label ?? "n/a";
|
|
881
1128
|
const segs = t.segment_count ?? 0;
|
|
882
1129
|
const chs = t.channel_count ?? 0;
|
|
883
1130
|
const score = t.score ?? 0;
|
|
@@ -887,8 +1134,16 @@ export function formatBriefing(data) {
|
|
|
887
1134
|
lines.push(` ${truncate(summary, 250)}`);
|
|
888
1135
|
for (const c of (t.citations ?? []).slice(0, 2)) {
|
|
889
1136
|
const label = [c.speaker_name, c.channel_name].filter(Boolean).join(" on ");
|
|
1137
|
+
const ids = [];
|
|
1138
|
+
if (c.segment_id != null)
|
|
1139
|
+
ids.push(`seg:${c.segment_id}`);
|
|
1140
|
+
if (c.content_id)
|
|
1141
|
+
ids.push(`episode:${c.content_id}`);
|
|
1142
|
+
if (c.publish_date)
|
|
1143
|
+
ids.push(`date:${fmtDate(c.publish_date)}`);
|
|
1144
|
+
const pointer = c.segment_id != null ? `get_segment seg:${c.segment_id}` : undefined;
|
|
890
1145
|
if (c.text)
|
|
891
|
-
lines.push(` "${
|
|
1146
|
+
lines.push(` "${truncateWithPointer(c.text, pointer)}" — ${label}${ids.length ? ` (${ids.join(" ")})` : ""}`);
|
|
892
1147
|
}
|
|
893
1148
|
lines.push("");
|
|
894
1149
|
}
|
|
@@ -896,18 +1151,20 @@ export function formatBriefing(data) {
|
|
|
896
1151
|
if (events.length) {
|
|
897
1152
|
lines.push("**Key events:**");
|
|
898
1153
|
for (const ev of events.slice(0, 5)) {
|
|
899
|
-
lines.push(`- ${ev.title ?? ev.description ?? "
|
|
1154
|
+
lines.push(`- ${ev.title ?? ev.description ?? "n/a"}`);
|
|
900
1155
|
}
|
|
901
1156
|
lines.push("");
|
|
902
1157
|
}
|
|
903
1158
|
const speakers = data.key_speakers ?? [];
|
|
904
1159
|
if (speakers.length) {
|
|
905
|
-
const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "
|
|
1160
|
+
const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"}, ${s.segment_count ?? "n/a"} seg)`);
|
|
906
1161
|
lines.push(`**Key speakers:** ${parts.join(", ")}\n`);
|
|
907
1162
|
}
|
|
908
1163
|
if (data.synthesis)
|
|
909
|
-
lines.push(data.synthesis);
|
|
910
|
-
|
|
1164
|
+
lines.push(rewriteSegCitations(data.synthesis));
|
|
1165
|
+
const firstTopic = data.topics?.[0];
|
|
1166
|
+
const hint = nextCallHint(firstTopic?.title ? `analyze_topic query:"${firstTopic.title}"` : undefined);
|
|
1167
|
+
return (lines.join("\n") || "No briefing data available.") + hint;
|
|
911
1168
|
}
|
|
912
1169
|
export function formatDiscourseSummary(data) {
|
|
913
1170
|
const lines = [];
|
|
@@ -935,7 +1192,7 @@ export function formatDiscourseSummary(data) {
|
|
|
935
1192
|
if (speakers.length) {
|
|
936
1193
|
lines.push("\n**Key Speakers:**");
|
|
937
1194
|
for (const s of speakers.slice(0, 10)) {
|
|
938
|
-
lines.push(`- ${s.name ?? "
|
|
1195
|
+
lines.push(`- ${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`);
|
|
939
1196
|
}
|
|
940
1197
|
}
|
|
941
1198
|
return lines.join("\n") || "No discourse data available.";
|
|
@@ -954,45 +1211,48 @@ export function formatTopics(topics, totalClusters, partition, query) {
|
|
|
954
1211
|
return `No topics matched "${query}" above the relevance threshold.`;
|
|
955
1212
|
}
|
|
956
1213
|
const lines = [
|
|
957
|
-
`**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "
|
|
1214
|
+
`**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "n/a"})\n`,
|
|
958
1215
|
];
|
|
959
1216
|
for (let i = 0; i < filtered.length; i++) {
|
|
960
1217
|
const t = filtered[i];
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1218
|
+
const tokens = [`cluster_id:${t.cluster_id ?? "n/a"}`];
|
|
1219
|
+
if (t.category)
|
|
1220
|
+
tokens.push(`category:${t.category}`);
|
|
1221
|
+
if (t.relevance != null)
|
|
1222
|
+
tokens.push(`sim:${t.relevance.toFixed(2)}`);
|
|
1223
|
+
lines.push(`${i + 1}. **${t.title ?? "n/a"}** — ${tokens.join(" ")}`);
|
|
965
1224
|
lines.push(` ${t.segment_count ?? 0} segments across ${t.channel_count ?? 0} channels`);
|
|
966
1225
|
const speakers = t.top_speakers ?? [];
|
|
967
1226
|
if (speakers.length) {
|
|
968
|
-
lines.push(`
|
|
1227
|
+
lines.push(` voices: ${speakers.slice(0, 3).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
|
|
969
1228
|
}
|
|
970
1229
|
const topSeg = (t.top_segments ?? [])[0];
|
|
971
1230
|
if (topSeg?.text) {
|
|
972
|
-
|
|
1231
|
+
const pointer = topSeg.segment_id != null ? `get_segment seg:${topSeg.segment_id}` : undefined;
|
|
1232
|
+
lines.push(` "${truncateWithPointer(topSeg.text, pointer)}" — ${topSeg.channel_name ?? ""} (seg:${topSeg.segment_id ?? "n/a"})`);
|
|
973
1233
|
}
|
|
974
1234
|
lines.push("");
|
|
975
1235
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
return lines.join("\n");
|
|
1236
|
+
const firstSeg = filtered.map((t) => (t.top_segments ?? [])[0]?.segment_id).find((id) => id != null);
|
|
1237
|
+
const hint = nextCallHint(filtered[0]?.title ? `analyze_topic query:"${filtered[0].title}"` : undefined, firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
1238
|
+
return lines.join("\n") + hint;
|
|
979
1239
|
}
|
|
980
1240
|
// ---------------------------------------------------------------------------
|
|
981
1241
|
// Ad formatters
|
|
982
1242
|
// ---------------------------------------------------------------------------
|
|
983
1243
|
export function formatAdStats(data) {
|
|
984
1244
|
const lines = ["# Ad Analytics Overview\n"];
|
|
985
|
-
lines.push(`**${
|
|
1245
|
+
lines.push(`**${data.total_ad_hashes ?? 0}** confirmed ad sentences`);
|
|
986
1246
|
lines.push(`**${data.total_brands ?? 0}** advertisers (+ ${(data.total_advertisers ?? 0) - (data.total_brands ?? 0)} network promos)`);
|
|
987
|
-
lines.push(`**${
|
|
988
|
-
lines.push(`**${
|
|
1247
|
+
lines.push(`**${data.ad_reads_in_window ?? 0}** ad reads in last ${data.time_window_days ?? 30}d`);
|
|
1248
|
+
lines.push(`**${data.episodes_with_ads_in_window ?? 0}** episodes with ads`);
|
|
989
1249
|
lines.push(`**${data.avg_ad_read_duration_seconds ?? 0}s** avg ad read duration`);
|
|
990
1250
|
const byCat = data.ads_by_category ?? data.by_category ?? {};
|
|
991
1251
|
if (Object.keys(byCat).length) {
|
|
992
1252
|
lines.push("\n**By Category:**");
|
|
993
1253
|
const sorted = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
|
|
994
1254
|
for (const [cat, count] of sorted) {
|
|
995
|
-
lines.push(`- ${cat}: ${count
|
|
1255
|
+
lines.push(`- ${cat}: ${count}`);
|
|
996
1256
|
}
|
|
997
1257
|
}
|
|
998
1258
|
const top = data.top_advertisers ?? [];
|
|
@@ -1000,11 +1260,13 @@ export function formatAdStats(data) {
|
|
|
1000
1260
|
lines.push("\n**Top Advertisers:**");
|
|
1001
1261
|
for (let i = 0; i < top.length; i++) {
|
|
1002
1262
|
const a = top[i];
|
|
1003
|
-
lines.push(`${i + 1}. **${a.name}**
|
|
1004
|
-
|
|
1263
|
+
lines.push(`${i + 1}. **${a.name}** — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1264
|
+
`episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
|
|
1005
1265
|
}
|
|
1006
1266
|
}
|
|
1007
|
-
|
|
1267
|
+
const firstId = top.find((a) => a.id != null)?.id;
|
|
1268
|
+
const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : "get_ad_analytics view:advertisers");
|
|
1269
|
+
return lines.join("\n") + hint;
|
|
1008
1270
|
}
|
|
1009
1271
|
export function formatAdvertiserList(data) {
|
|
1010
1272
|
const advs = data.advertisers ?? [];
|
|
@@ -1015,29 +1277,33 @@ export function formatAdvertiserList(data) {
|
|
|
1015
1277
|
for (let i = 0; i < advs.length; i++) {
|
|
1016
1278
|
const a = advs[i];
|
|
1017
1279
|
const aliases = a.aliases?.length ? ` (aka ${a.aliases.slice(0, 3).join(", ")})` : "";
|
|
1018
|
-
lines.push(`${i + 1}. **${a.name}**${aliases}
|
|
1019
|
-
|
|
1280
|
+
lines.push(`${i + 1}. **${a.name}**${aliases} — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1281
|
+
`episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
|
|
1020
1282
|
}
|
|
1021
|
-
|
|
1283
|
+
const paging = pagingNote({ shown: advs.length, total });
|
|
1284
|
+
const firstId = advs.find((a) => a.id != null)?.id;
|
|
1285
|
+
const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : undefined);
|
|
1286
|
+
return lines.join("\n") + paging + hint;
|
|
1022
1287
|
}
|
|
1023
1288
|
export function formatAdvertiserDetail(data) {
|
|
1024
1289
|
if (data.error)
|
|
1025
1290
|
return `Error: ${data.error}`;
|
|
1026
1291
|
const lines = [`# ${data.name}`];
|
|
1292
|
+
lines.push(`advertiser:${data.id ?? "n/a"}`);
|
|
1027
1293
|
if (data.category)
|
|
1028
1294
|
lines.push(`**Category:** ${data.category}`);
|
|
1029
1295
|
if (data.aliases?.length)
|
|
1030
1296
|
lines.push(`**Also known as:** ${data.aliases.join(", ")}`);
|
|
1031
1297
|
if (data.website)
|
|
1032
1298
|
lines.push(`**Website:** ${data.website}`);
|
|
1033
|
-
lines.push(`**Episodes:** ${data.total_episode_count
|
|
1299
|
+
lines.push(`**Episodes:** ${data.total_episode_count} | **Channels:** ${data.total_channel_count}`);
|
|
1034
1300
|
if (data.first_seen)
|
|
1035
|
-
lines.push(`**Active:**
|
|
1301
|
+
lines.push(`**Active:** date:${fmtDate(data.first_seen)} — date:${fmtDate(data.last_seen)}`);
|
|
1036
1302
|
const channels = data.top_channels ?? [];
|
|
1037
1303
|
if (channels.length) {
|
|
1038
1304
|
lines.push("\n**Top Channels:**");
|
|
1039
1305
|
for (const ch of channels.slice(0, 10)) {
|
|
1040
|
-
lines.push(`- ${ch.name}
|
|
1306
|
+
lines.push(`- ${ch.name} — channel_id:${ch.channel_id ?? "n/a"} platform:${ch.platform ?? "n/a"} episodes:${ch.episode_count}`);
|
|
1041
1307
|
}
|
|
1042
1308
|
}
|
|
1043
1309
|
const samples = data.sample_reads ?? [];
|
|
@@ -1045,113 +1311,135 @@ export function formatAdvertiserDetail(data) {
|
|
|
1045
1311
|
lines.push("\n**Recent Ad Reads:**");
|
|
1046
1312
|
for (const s of samples) {
|
|
1047
1313
|
const dur = s.duration_seconds ? ` (${s.duration_seconds}s)` : "";
|
|
1048
|
-
lines.push(`-
|
|
1314
|
+
lines.push(`- date:${fmtDate(s.date)} ${s.episode_title ?? "n/a"}${dur}`);
|
|
1315
|
+
// Ad-read text has no per-read retrieval tool — never cut it silently.
|
|
1049
1316
|
if (s.text)
|
|
1050
|
-
lines.push(` > ${s.text
|
|
1317
|
+
lines.push(` > ${s.text}`);
|
|
1051
1318
|
}
|
|
1052
1319
|
}
|
|
1053
|
-
|
|
1320
|
+
const firstCh = channels.find((ch) => ch.channel_id != null)?.channel_id;
|
|
1321
|
+
const hint = nextCallHint(firstCh != null ? `get_ad_analytics view:channel channel_id:${firstCh}` : undefined);
|
|
1322
|
+
return lines.join("\n") + hint;
|
|
1054
1323
|
}
|
|
1055
1324
|
export function formatChannelAds(data) {
|
|
1056
1325
|
if (data.error)
|
|
1057
1326
|
return `Error: ${data.error}`;
|
|
1058
1327
|
const chName = data.channel?.name ?? data.channel_name ?? "Channel";
|
|
1059
1328
|
const lines = [`# Ads on ${chName}`];
|
|
1060
|
-
lines.push(`**${
|
|
1329
|
+
lines.push(`**${data.total_ad_reads ?? 0}** ad reads in last ${data.time_window_days ?? 30}d\n`);
|
|
1061
1330
|
for (let i = 0; i < data.advertisers.length; i++) {
|
|
1062
1331
|
const a = data.advertisers[i];
|
|
1063
1332
|
const promo = a.is_network_promo ? " [network]" : "";
|
|
1064
|
-
lines.push(`${i + 1}. **${a.name}**${promo}
|
|
1065
|
-
|
|
1333
|
+
lines.push(`${i + 1}. **${a.name}**${promo} — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1334
|
+
`episodes:${a.episode_count} reads:${a.read_count}`);
|
|
1066
1335
|
}
|
|
1067
|
-
|
|
1336
|
+
const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
|
|
1337
|
+
const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
|
|
1338
|
+
return (lines.join("\n") || "No ad data for this channel.") + hint;
|
|
1068
1339
|
}
|
|
1069
1340
|
export function formatAdTrends(data) {
|
|
1070
1341
|
if (!data.advertisers?.length)
|
|
1071
1342
|
return "No trending data available.";
|
|
1072
1343
|
const lines = [`# Ad Trends (${data.comparison ?? ""})\n`];
|
|
1073
1344
|
for (const a of data.advertisers) {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
`${a.current_episodes} eps (was ${a.prior_episodes}, ${a.change_pct > 0 ? "+" : ""}${a.change_pct.toFixed(0)}%)`);
|
|
1345
|
+
lines.push(`- **${a.name}** — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1346
|
+
`${trendToken(a.trend, a.change_pct)} episodes:${a.current_episodes} (was ${a.prior_episodes})`);
|
|
1077
1347
|
}
|
|
1078
|
-
|
|
1348
|
+
const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
|
|
1349
|
+
const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
|
|
1350
|
+
return lines.join("\n") + hint;
|
|
1079
1351
|
}
|
|
1080
1352
|
// ---------------------------------------------------------------------------
|
|
1081
1353
|
// Claim Detail
|
|
1082
1354
|
// ---------------------------------------------------------------------------
|
|
1355
|
+
function observationLine(o) {
|
|
1356
|
+
const date = fmtDate(o.publish_date);
|
|
1357
|
+
const meta = [o.speaker_name ?? "speaker:unresolved (diarization cluster)", o.channel_name, `date:${date}`]
|
|
1358
|
+
.filter(Boolean).join(", ");
|
|
1359
|
+
const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
|
|
1360
|
+
const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
|
|
1361
|
+
return [
|
|
1362
|
+
`- "${truncateWithPointer(o.chunk_text, pointer)}" — ${meta}`,
|
|
1363
|
+
` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`,
|
|
1364
|
+
];
|
|
1365
|
+
}
|
|
1083
1366
|
export function formatClaimDetail(detail, observations, related) {
|
|
1084
1367
|
const c = detail.claim;
|
|
1085
1368
|
const lines = [];
|
|
1086
|
-
lines.push(`# Claim
|
|
1087
|
-
lines.push(`**${c.summary}
|
|
1088
|
-
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1369
|
+
lines.push(`# Claim claim:${c.claim_id}`);
|
|
1370
|
+
lines.push(`**${c.summary}**`);
|
|
1371
|
+
lines.push(`(synthesized claim — observations carry verbatim quotes)\n`);
|
|
1372
|
+
const tokens = [`stance:${c.stance ?? "n/a"}`, `topic:${c.topic ?? "n/a"}`];
|
|
1373
|
+
const lt = langToken(c.language);
|
|
1374
|
+
if (lt)
|
|
1375
|
+
tokens.push(lt);
|
|
1376
|
+
lines.push(tokens.join(" "));
|
|
1377
|
+
lines.push(`obs:${c.observation_count} ${episodeTokens(c)} channels:${c.channel_count}`);
|
|
1378
|
+
const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "n/a");
|
|
1379
|
+
if (span.length === 2)
|
|
1380
|
+
lines.push(`first_seen:${span[0]} last_seen:${span[1]}`);
|
|
1095
1381
|
if (c.entities?.length) {
|
|
1096
|
-
lines.push(`
|
|
1382
|
+
lines.push(`entities:${c.entities.join(",")}`);
|
|
1097
1383
|
}
|
|
1384
|
+
if (c.language && c.language !== "en")
|
|
1385
|
+
lines.push(NON_ENGLISH_NOTE);
|
|
1098
1386
|
lines.push("");
|
|
1099
1387
|
if (c.top_speakers?.length) {
|
|
1100
1388
|
lines.push("**Speakers:**");
|
|
1101
1389
|
for (const s of c.top_speakers.slice(0, 10)) {
|
|
1102
|
-
|
|
1103
|
-
lines.push(`- ${s.name}${idStr} — ${s.observation_count} observations`);
|
|
1390
|
+
lines.push(`- ${s.name} — speaker:${s.identity_id ?? "n/a"} obs:${s.observation_count}`);
|
|
1104
1391
|
}
|
|
1105
1392
|
lines.push("");
|
|
1106
1393
|
}
|
|
1107
1394
|
if (c.top_channels?.length) {
|
|
1108
1395
|
lines.push("**Channels:**");
|
|
1109
1396
|
for (const ch of c.top_channels.slice(0, 10)) {
|
|
1110
|
-
lines.push(`- ${ch.name} —
|
|
1397
|
+
lines.push(`- ${ch.name} — channel_id:${ch.channel_id} obs:${ch.observation_count}`);
|
|
1111
1398
|
}
|
|
1112
1399
|
lines.push("");
|
|
1113
1400
|
}
|
|
1114
1401
|
const obs = observations?.observations ?? c.sample_observations ?? [];
|
|
1402
|
+
let firstObsSeg;
|
|
1115
1403
|
if (obs.length) {
|
|
1116
1404
|
const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
|
|
1117
1405
|
lines.push(label);
|
|
1118
1406
|
for (const o of obs.slice(0, 20)) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
lines.push(
|
|
1122
|
-
|
|
1123
|
-
|
|
1407
|
+
if (firstObsSeg == null && o.segment_id != null)
|
|
1408
|
+
firstObsSeg = o.segment_id;
|
|
1409
|
+
lines.push(...observationLine(o));
|
|
1410
|
+
}
|
|
1411
|
+
if (observations && observations.total > Math.min(obs.length, 20)) {
|
|
1412
|
+
lines.push(`showing ${Math.min(obs.length, 20)} of ${observations.total} — page:${(observations.page ?? 1) + 1} for more`);
|
|
1124
1413
|
}
|
|
1125
1414
|
lines.push("");
|
|
1126
1415
|
}
|
|
1127
1416
|
if (related?.claims?.length) {
|
|
1128
1417
|
lines.push("**Related Claims:**");
|
|
1129
1418
|
for (const r of related.claims) {
|
|
1130
|
-
|
|
1131
|
-
lines.push(`- ${r.summary} — ${r.observation_count} observations${sim}`);
|
|
1419
|
+
lines.push(claimLine(r));
|
|
1132
1420
|
}
|
|
1133
1421
|
lines.push("");
|
|
1134
1422
|
}
|
|
1135
|
-
|
|
1423
|
+
const hint = nextCallHint(firstObsSeg != null ? `get_segment seg:${firstObsSeg}` : undefined, `claim_graph claim_id:${c.claim_id}`);
|
|
1424
|
+
return lines.join("\n") + hint;
|
|
1136
1425
|
}
|
|
1137
1426
|
// ---------------------------------------------------------------------------
|
|
1138
1427
|
// Timeline
|
|
1139
1428
|
// ---------------------------------------------------------------------------
|
|
1140
|
-
|
|
1429
|
+
/** MCP timeline: `date<TAB>count` rows (spec §5 — bar glyphs retired). */
|
|
1430
|
+
export function formatTimeline(data, footer = "") {
|
|
1141
1431
|
const buckets = data.buckets ?? [];
|
|
1142
1432
|
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;
|
|
1433
|
+
return "No data found for this topic in the given time range." + footer;
|
|
1146
1434
|
const lines = [
|
|
1147
1435
|
`**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
|
|
1436
|
+
"date\tcount",
|
|
1148
1437
|
];
|
|
1149
1438
|
for (const b of buckets) {
|
|
1150
|
-
|
|
1151
|
-
const bar = "█".repeat(barLen);
|
|
1152
|
-
const countStr = String(b.count).padStart(4);
|
|
1153
|
-
lines.push(`${b.start} ${countStr} ${bar}`);
|
|
1439
|
+
lines.push(`${b.start}\t${b.count}`);
|
|
1154
1440
|
}
|
|
1155
|
-
|
|
1441
|
+
const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
|
|
1442
|
+
const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
|
|
1443
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
1156
1444
|
}
|
|
1157
1445
|
//# sourceMappingURL=format.js.map
|