@signal4/s4 2.4.0 → 2.6.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 +15 -0
- package/bin/signal4-mcp-http.js +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +584 -99
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +56 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +108 -12
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +67 -18
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +637 -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 +57 -17
- 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 +53 -31
- package/dist/handlers/reports.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +63 -37
- 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 +140 -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");
|
|
153
|
+
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Footers (spec §7): provenance footer → paging note → next-call hint
|
|
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;
|
|
75
177
|
}
|
|
76
178
|
// ---------------------------------------------------------------------------
|
|
77
|
-
// Quote dedup
|
|
179
|
+
// Quote dedup — episode + timestamp + full-text hash (spec / audit M8)
|
|
78
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,140 @@ 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
|
+
export function formatClaims(data, footer = "", offset = 0) {
|
|
213
354
|
const claims = data.claims ?? [];
|
|
214
355
|
if (!claims.length)
|
|
215
|
-
return "No claims found matching your search criteria.";
|
|
356
|
+
return "No claims found matching your search criteria." + footer;
|
|
216
357
|
const total = data.total_results ?? claims.length;
|
|
217
358
|
const ms = data.processing_time_ms ?? 0;
|
|
218
|
-
const mode = data.search_mode ?? "
|
|
359
|
+
const mode = data.search_mode ?? "n/a";
|
|
219
360
|
const lines = [`**${total} claims** (showing ${claims.length}, ${mode}, ${ms.toFixed(0)}ms)\n`];
|
|
220
361
|
for (let i = 0; i < claims.length; i++) {
|
|
221
362
|
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})` : ""));
|
|
363
|
+
lines.push(claimLine(c, i + 1));
|
|
234
364
|
if (c.entities.length) {
|
|
235
|
-
lines.push(`
|
|
365
|
+
lines.push(` entities:${c.entities.join(",")}`);
|
|
236
366
|
}
|
|
237
367
|
const speakers = c.top_speakers ?? [];
|
|
238
368
|
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(", ")}`);
|
|
369
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers)}`);
|
|
244
370
|
}
|
|
245
371
|
const channels = c.top_channels ?? [];
|
|
246
372
|
if (channels.length) {
|
|
247
|
-
const parts = channels.slice(0, 4).map((ch) => `${ch.name} (${ch.observation_count})`);
|
|
248
|
-
lines.push(`
|
|
373
|
+
const parts = channels.slice(0, 4).map((ch) => `${ch.name} (channel_id:${ch.channel_id}, ${ch.observation_count})`);
|
|
374
|
+
lines.push(` channels: ${parts.join(", ")}`);
|
|
249
375
|
}
|
|
250
376
|
const obs = c.sample_observations ?? [];
|
|
251
377
|
if (obs.length) {
|
|
252
378
|
const o = obs[0];
|
|
253
379
|
const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
|
|
254
380
|
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}`);
|
|
381
|
+
const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
|
|
382
|
+
lines.push(` sample: "${truncateWithPointer(o.chunk_text, pointer)}" — ${label}`);
|
|
383
|
+
lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`);
|
|
260
384
|
}
|
|
261
385
|
lines.push("");
|
|
262
386
|
}
|
|
263
|
-
if (
|
|
264
|
-
lines.push(
|
|
265
|
-
}
|
|
266
|
-
|
|
387
|
+
if (anyNonEnglish(claims))
|
|
388
|
+
lines.push(NON_ENGLISH_NOTE, "");
|
|
389
|
+
const paging = pagingNote({ shown: claims.length, total, offset });
|
|
390
|
+
const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
|
|
391
|
+
return withFooters(lines.join("\n"), footer, paging, hint);
|
|
267
392
|
}
|
|
268
393
|
// ---------------------------------------------------------------------------
|
|
269
394
|
// Claims analysis (/api/v1/analysis/claims/*)
|
|
270
395
|
// ---------------------------------------------------------------------------
|
|
271
|
-
export function formatClaimsTopicAnalysis(data, topic) {
|
|
396
|
+
export function formatClaimsTopicAnalysis(data, topic, footer = "") {
|
|
272
397
|
if (!data.themes?.length) {
|
|
273
|
-
return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic
|
|
398
|
+
return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.` + footer;
|
|
274
399
|
}
|
|
275
400
|
const m = data.quantitative_metrics ?? {};
|
|
276
401
|
const lines = [`# Claims analysis: "${topic}"`, ""];
|
|
@@ -284,52 +409,70 @@ export function formatClaimsTopicAnalysis(data, topic) {
|
|
|
284
409
|
].filter(Boolean);
|
|
285
410
|
if (summaryParts.length)
|
|
286
411
|
lines.push(summaryParts.join(" · "), "");
|
|
412
|
+
let firstClaimId;
|
|
287
413
|
for (const theme of data.themes) {
|
|
288
414
|
lines.push(`## ${theme.name} — ${theme.claim_count} claims, ${theme.total_observations} obs across ${theme.channel_count} channels`);
|
|
289
415
|
if (theme.entities?.length)
|
|
290
|
-
lines.push(`
|
|
416
|
+
lines.push(`entities:${theme.entities.slice(0, 8).join(",")}`);
|
|
291
417
|
for (const c of theme.claims) {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
418
|
+
const tokens = [`claim:${c.claim_id ?? "n/a"}`];
|
|
419
|
+
if (firstClaimId == null && c.claim_id != null)
|
|
420
|
+
firstClaimId = c.claim_id;
|
|
421
|
+
if (c.stance)
|
|
422
|
+
tokens.push(`stance:${c.stance}`);
|
|
423
|
+
const detail = [`obs:${c.observation_count}`, `channels:${c.channel_count}`];
|
|
424
|
+
if (c.first_seen)
|
|
425
|
+
detail.push(`first_seen:${fmtDate(c.first_seen)}`);
|
|
426
|
+
if (c.last_seen)
|
|
427
|
+
detail.push(`last_seen:${fmtDate(c.last_seen)}`);
|
|
428
|
+
lines.push(`- **${c.summary}** — ${tokens.join(" ")}`);
|
|
429
|
+
lines.push(` ${detail.join(" ")}`);
|
|
430
|
+
const speakers = (c.top_speakers ?? []).slice(0, 3);
|
|
296
431
|
if (speakers.length)
|
|
297
|
-
lines.push(`
|
|
432
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
|
|
298
433
|
}
|
|
299
434
|
lines.push("");
|
|
300
435
|
}
|
|
301
|
-
|
|
436
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
437
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
302
438
|
}
|
|
303
|
-
|
|
304
|
-
surging: "🚀", rising: "↗", steady: "→", declining: "↘", fading: "📉", new: "✨", no_data: "∅",
|
|
305
|
-
};
|
|
306
|
-
export function formatClaimsTrend(data, topic) {
|
|
439
|
+
export function formatClaimsTrend(data, topic, footer = "") {
|
|
307
440
|
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
|
|
441
|
+
return `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
|
|
309
442
|
}
|
|
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
443
|
const lines = [
|
|
313
|
-
`# Claim trend: "${topic}" — ${
|
|
444
|
+
`# Claim trend: "${topic}" — ${trendToken(data.direction, data.change_pct)}`,
|
|
314
445
|
"",
|
|
315
|
-
`
|
|
446
|
+
`recent (${data.recent.days}d): ${data.recent.claims} claims` +
|
|
316
447
|
(data.recent.new_claims != null ? ` (${data.recent.new_claims} new)` : "") +
|
|
317
448
|
`, ${data.recent.observations} obs, ${data.recent.per_day}/day`,
|
|
318
|
-
`
|
|
449
|
+
`baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
|
|
319
450
|
];
|
|
320
|
-
|
|
451
|
+
const hint = nextCallHint(`search_claims topic:"${topic}"`, `get_timeline topic:"${topic}"`);
|
|
452
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
321
453
|
}
|
|
322
|
-
export function formatClaimsContradictions(data, speaker, topic) {
|
|
454
|
+
export function formatClaimsContradictions(data, speaker, topic, footer = "") {
|
|
323
455
|
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
|
|
456
|
+
return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.` + footer;
|
|
325
457
|
}
|
|
326
|
-
const lines = [
|
|
458
|
+
const lines = [
|
|
459
|
+
`# Contradiction check: ${speaker} on "${topic}"`, "",
|
|
460
|
+
`Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, "",
|
|
461
|
+
];
|
|
462
|
+
let firstClaimId;
|
|
463
|
+
// Contradiction claim texts are synthesized summaries (never quote-marked)
|
|
464
|
+
// and are NEVER truncated (spec §4/§6).
|
|
465
|
+
const side = (label, s) => {
|
|
466
|
+
if (firstClaimId == null && s.claim_id != null)
|
|
467
|
+
firstClaimId = s.claim_id;
|
|
468
|
+
return ` • ${label} — claim:${s.claim_id ?? "n/a"} stance:${s.stance} date:${fmtDate(s.date)} obs:${s.observations}\n ${s.claim}`;
|
|
469
|
+
};
|
|
327
470
|
if (data.contradictions.length) {
|
|
328
471
|
lines.push("## Contradictions");
|
|
329
472
|
for (const c of data.contradictions) {
|
|
330
473
|
lines.push(`- [${c.severity}] ${c.description}`);
|
|
331
|
-
lines.push(
|
|
332
|
-
lines.push(
|
|
474
|
+
lines.push(side("earlier", c.earlier));
|
|
475
|
+
lines.push(side("later", c.later));
|
|
333
476
|
}
|
|
334
477
|
lines.push("");
|
|
335
478
|
}
|
|
@@ -339,170 +482,181 @@ export function formatClaimsContradictions(data, speaker, topic) {
|
|
|
339
482
|
if (data.consistent_positions.length) {
|
|
340
483
|
lines.push("## Consistent positions");
|
|
341
484
|
for (const p of data.consistent_positions) {
|
|
342
|
-
lines.push(`-
|
|
485
|
+
lines.push(`- **${p.claim}** — claim:${p.claim_id ?? "n/a"} stance:${p.stance} obs:${p.observations}`);
|
|
486
|
+
if (firstClaimId == null && p.claim_id != null)
|
|
487
|
+
firstClaimId = p.claim_id;
|
|
343
488
|
}
|
|
489
|
+
lines.push("");
|
|
344
490
|
}
|
|
345
|
-
|
|
491
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
492
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
493
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
346
494
|
}
|
|
347
|
-
export function formatClaimsComparison(data, topic) {
|
|
495
|
+
export function formatClaimsComparison(data, topic, footer = "") {
|
|
348
496
|
const lines = [`# Speaker comparison on claims: "${topic}"`, ""];
|
|
497
|
+
let firstClaimId;
|
|
349
498
|
for (const sp of data.speakers) {
|
|
350
499
|
lines.push(`## ${sp.name} — ${sp.claim_count} claims${sp.framing ? ` (${sp.framing})` : ""}`);
|
|
351
500
|
if (sp.position_summary)
|
|
352
|
-
lines.push(sp.position_summary);
|
|
501
|
+
lines.push(rewriteSegCitations(sp.position_summary));
|
|
353
502
|
const sb = Object.entries(sp.stance_breakdown ?? {});
|
|
354
503
|
if (sb.length)
|
|
355
|
-
lines.push(`
|
|
504
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
356
505
|
for (const kc of (sp.key_claims ?? []).slice(0, 3)) {
|
|
357
|
-
|
|
506
|
+
// Key-claim texts are synthesized and never truncated (spec §4/§6).
|
|
507
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
508
|
+
if (firstClaimId == null && kc.claim_id != null)
|
|
509
|
+
firstClaimId = kc.claim_id;
|
|
358
510
|
}
|
|
359
511
|
lines.push("");
|
|
360
512
|
}
|
|
361
513
|
if (data.agreements?.length) {
|
|
362
|
-
lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
|
|
514
|
+
lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
|
|
363
515
|
}
|
|
364
516
|
if (data.disagreements?.length) {
|
|
365
|
-
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
|
|
517
|
+
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
|
|
366
518
|
}
|
|
367
|
-
|
|
519
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
520
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
521
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
368
522
|
}
|
|
369
|
-
export function formatChannelDossier(data) {
|
|
523
|
+
export function formatChannelDossier(data, footer = "") {
|
|
370
524
|
const label = data.channels.join(", ");
|
|
371
525
|
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
|
|
526
|
+
return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.` + footer;
|
|
373
527
|
}
|
|
374
528
|
const lines = [`# Channel dossier: ${label} on "${data.topic}"`, ""];
|
|
375
529
|
lines.push(`${data.claims_analyzed} claims analyzed${data.total_claims_available ? ` (${data.total_claims_available} available)` : ""}`);
|
|
376
530
|
const sb = Object.entries(data.stance_breakdown ?? {});
|
|
377
531
|
if (sb.length)
|
|
378
|
-
lines.push(`
|
|
532
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
379
533
|
lines.push("");
|
|
380
534
|
if (data.themes.length) {
|
|
381
535
|
lines.push("## Themes");
|
|
382
536
|
for (const t of data.themes) {
|
|
383
537
|
lines.push(`- **${t.name}** — ${t.claim_count} claims, ${t.total_observations} obs`);
|
|
384
538
|
if (t.summary)
|
|
385
|
-
lines.push(` ${
|
|
539
|
+
lines.push(` ${rewriteSegCitations(t.summary)}`);
|
|
386
540
|
}
|
|
387
541
|
lines.push("");
|
|
388
542
|
}
|
|
389
543
|
if (data.key_speakers.length) {
|
|
390
|
-
lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (${s.claim_count})`).join(", ")}`, "");
|
|
544
|
+
lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"}, ${s.claim_count})`).join(", ")}`, "");
|
|
391
545
|
}
|
|
546
|
+
let firstClaimId;
|
|
392
547
|
if (data.key_claims.length) {
|
|
393
548
|
lines.push("## Key claims");
|
|
394
549
|
for (const kc of data.key_claims) {
|
|
395
|
-
|
|
550
|
+
if (firstClaimId == null)
|
|
551
|
+
firstClaimId = kc.claim_id;
|
|
552
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
396
553
|
}
|
|
397
554
|
lines.push("");
|
|
398
555
|
}
|
|
399
556
|
if (data.trajectory.length > 1) {
|
|
400
|
-
lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}
|
|
557
|
+
lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`, "");
|
|
401
558
|
}
|
|
402
|
-
|
|
559
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
560
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
561
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
562
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
403
563
|
}
|
|
404
|
-
export function formatChannelClaimsComparison(data, topic) {
|
|
564
|
+
export function formatChannelClaimsComparison(data, topic, footer = "") {
|
|
405
565
|
const lines = [`# Channel comparison on claims: "${topic}"`, ""];
|
|
566
|
+
let firstClaimId;
|
|
406
567
|
for (const ch of data.channels) {
|
|
407
568
|
lines.push(`## ${ch.name} — ${ch.claim_count} claims${ch.framing ? ` (${ch.framing})` : ""}`);
|
|
408
569
|
if (ch.position_summary)
|
|
409
|
-
lines.push(ch.position_summary);
|
|
570
|
+
lines.push(rewriteSegCitations(ch.position_summary));
|
|
410
571
|
const sb = Object.entries(ch.stance_breakdown ?? {});
|
|
411
572
|
if (sb.length)
|
|
412
|
-
lines.push(`
|
|
573
|
+
lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
|
|
413
574
|
if (ch.key_speakers?.length) {
|
|
414
|
-
lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => s.name).join(", ")}`);
|
|
575
|
+
lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
|
|
415
576
|
}
|
|
416
577
|
for (const kc of (ch.key_claims ?? []).slice(0, 3)) {
|
|
417
|
-
lines.push(`-
|
|
578
|
+
lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
|
|
579
|
+
if (firstClaimId == null && kc.claim_id != null)
|
|
580
|
+
firstClaimId = kc.claim_id;
|
|
418
581
|
}
|
|
419
582
|
lines.push("");
|
|
420
583
|
}
|
|
421
584
|
if (data.agreements?.length) {
|
|
422
|
-
lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
|
|
585
|
+
lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
|
|
423
586
|
}
|
|
424
587
|
if (data.disagreements?.length) {
|
|
425
|
-
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
|
|
588
|
+
lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
|
|
426
589
|
}
|
|
427
590
|
if (data.speaker_overlap?.length) {
|
|
428
591
|
lines.push("## Shared speakers");
|
|
429
592
|
for (const o of data.speaker_overlap) {
|
|
430
593
|
lines.push(`- ${o.channels.join(" & ")}: ${o.shared_speakers.join(", ")}`);
|
|
431
594
|
}
|
|
595
|
+
lines.push("");
|
|
432
596
|
}
|
|
433
|
-
|
|
597
|
+
lines.push("(synthesized claim — observations carry verbatim quotes)");
|
|
598
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
599
|
+
const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
|
|
600
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
434
601
|
}
|
|
435
602
|
// ---------------------------------------------------------------------------
|
|
436
603
|
// Claims graph (entity-centric, hierarchy/relations, leaderboard)
|
|
437
604
|
// ---------------------------------------------------------------------------
|
|
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
605
|
export function formatEntitySearch(data) {
|
|
456
606
|
if (!data.entities.length) {
|
|
457
607
|
return `No entities found matching "${data.query}". Try a shorter or differently-spelled query.`;
|
|
458
608
|
}
|
|
459
609
|
const lines = [`# Entities matching "${data.query}"`, ""];
|
|
460
610
|
for (const e of data.entities) {
|
|
461
|
-
const
|
|
611
|
+
const tokens = [`entity:${e.id}`];
|
|
462
612
|
if (e.entity_type)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
613
|
+
tokens.push(`type:${e.entity_type}`);
|
|
614
|
+
tokens.push(`claims:${e.claim_count}`, `channels:${e.channel_count}`);
|
|
615
|
+
if (e.first_seen_date)
|
|
616
|
+
tokens.push(`first_seen:${fmtDate(e.first_seen_date)}`);
|
|
617
|
+
if (e.last_seen_date)
|
|
618
|
+
tokens.push(`last_seen:${fmtDate(e.last_seen_date)}`);
|
|
619
|
+
lines.push(`- **${e.name}** — ${tokens.join(" ")}`);
|
|
469
620
|
if (e.aliases?.length)
|
|
470
|
-
lines.push(`
|
|
621
|
+
lines.push(` aliases:${e.aliases.slice(0, 5).join(",")}`);
|
|
471
622
|
}
|
|
472
|
-
|
|
473
|
-
return lines.join("\n");
|
|
623
|
+
const hint = nextCallHint(`entity_claims entity:"${data.entities[0].name}"`);
|
|
624
|
+
return lines.join("\n") + hint;
|
|
474
625
|
}
|
|
475
|
-
export function formatEntityClaims(data) {
|
|
626
|
+
export function formatEntityClaims(data, footer = "", offset = 0) {
|
|
476
627
|
if (!data.entity_id) {
|
|
477
|
-
return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching
|
|
628
|
+
return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.` + footer;
|
|
478
629
|
}
|
|
479
630
|
const lines = [`# Claims about ${data.entity}`];
|
|
480
|
-
const
|
|
631
|
+
const tokens = [`entity:${data.entity_id}`];
|
|
481
632
|
if (data.entity_type)
|
|
482
|
-
|
|
633
|
+
tokens.push(`type:${data.entity_type}`);
|
|
483
634
|
if (data.claim_count_rollup != null)
|
|
484
|
-
|
|
635
|
+
tokens.push(`claims:${data.claim_count_rollup}`);
|
|
485
636
|
if (data.channel_count != null)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
lines.push(meta.join(" · "));
|
|
637
|
+
tokens.push(`channels:${data.channel_count}`);
|
|
638
|
+
lines.push(tokens.join(" "));
|
|
489
639
|
if (data.aliases?.length)
|
|
490
|
-
lines.push(`
|
|
640
|
+
lines.push(`aliases:${data.aliases.slice(0, 6).join(",")}`);
|
|
491
641
|
if (data.stance_distribution?.length) {
|
|
492
642
|
const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
|
|
493
643
|
const parts = data.stance_distribution
|
|
494
|
-
.map((s) => `${s.stance}
|
|
495
|
-
lines.push(`\
|
|
644
|
+
.map((s) => `${s.stance}:${s.count} (${Math.round((s.count / total) * 100)}%)`);
|
|
645
|
+
lines.push(`\nstance: ${parts.join(" ")}`);
|
|
496
646
|
}
|
|
497
|
-
lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims
|
|
647
|
+
lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims})`);
|
|
498
648
|
data.claims.forEach((c, i) => lines.push(claimLine(c, i + 1)));
|
|
499
|
-
if (data.
|
|
500
|
-
lines.push(
|
|
501
|
-
|
|
649
|
+
if (anyNonEnglish(data.claims))
|
|
650
|
+
lines.push("", NON_ENGLISH_NOTE);
|
|
651
|
+
const paging = pagingNote({ shown: data.claims.length, total: data.total_claims, offset });
|
|
652
|
+
const hint = data.claims.length
|
|
653
|
+
? nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`)
|
|
654
|
+
: "";
|
|
655
|
+
return withFooters(lines.join("\n"), footer, paging, hint);
|
|
502
656
|
}
|
|
503
657
|
export function formatClaimGraph(data) {
|
|
504
658
|
const lines = [`# Claim graph`, "", `**${data.claim.summary}**`,
|
|
505
|
-
`
|
|
659
|
+
`claim:${data.claim.claim_id} obs:${data.claim.observation_count} channels:${data.claim.channel_count}`, ""];
|
|
506
660
|
if (data.parent) {
|
|
507
661
|
lines.push("## Broader claim (parent)", claimLine(data.parent), "");
|
|
508
662
|
}
|
|
@@ -519,21 +673,27 @@ export function formatClaimGraph(data) {
|
|
|
519
673
|
if (!data.parent && !data.children?.length && !data.siblings?.length) {
|
|
520
674
|
lines.push("_No parent, children, or sibling claims found. This claim stands alone in the hierarchy._");
|
|
521
675
|
}
|
|
522
|
-
|
|
676
|
+
const next = data.children?.[0] ?? data.siblings?.[0] ?? data.parent;
|
|
677
|
+
const hint = nextCallHint(`get_claim claim:${data.claim.claim_id} include_observations:true`, next ? `claim_graph claim_id:${next.claim_id}` : undefined);
|
|
678
|
+
return lines.join("\n") + hint;
|
|
523
679
|
}
|
|
524
|
-
export function formatTopClaims(data) {
|
|
680
|
+
export function formatTopClaims(data, footer = "") {
|
|
525
681
|
if (!data.claims?.length) {
|
|
526
|
-
return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations
|
|
682
|
+
return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.` + footer;
|
|
527
683
|
}
|
|
528
|
-
const
|
|
684
|
+
const contested = data.metric === "contested";
|
|
685
|
+
const label = contested ? "Most contested claims" : "Most widely-spread claims";
|
|
529
686
|
const lines = [`# ${label}`, ""];
|
|
530
687
|
data.claims.forEach((c, i) => {
|
|
531
|
-
lines.push(claimLine(c, i + 1));
|
|
532
|
-
const speakers = (c.top_speakers ?? []).slice(0, 3)
|
|
688
|
+
lines.push(claimLine(c, i + 1, { assertDeny: contested }));
|
|
689
|
+
const speakers = (c.top_speakers ?? []).slice(0, 3);
|
|
533
690
|
if (speakers.length)
|
|
534
|
-
lines.push(`
|
|
691
|
+
lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
|
|
535
692
|
});
|
|
536
|
-
|
|
693
|
+
if (anyNonEnglish(data.claims))
|
|
694
|
+
lines.push("", NON_ENGLISH_NOTE);
|
|
695
|
+
const hint = nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`);
|
|
696
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
537
697
|
}
|
|
538
698
|
// ---------------------------------------------------------------------------
|
|
539
699
|
// Speaker
|
|
@@ -561,31 +721,31 @@ export function formatSpeaker(data) {
|
|
|
561
721
|
lines.push(`**Stats:** ${stats.join(", ")}`);
|
|
562
722
|
const sid = data.speaker_id ?? data.id;
|
|
563
723
|
if (sid)
|
|
564
|
-
lines.push(`
|
|
724
|
+
lines.push(`speaker:${sid}`);
|
|
565
725
|
const topChannels = data.top_channels ?? [];
|
|
566
726
|
if (topChannels.length) {
|
|
567
727
|
lines.push("\n**Top Channels:**");
|
|
568
728
|
for (const ch of topChannels.slice(0, 8)) {
|
|
569
|
-
lines.push(`- ${ch.name ?? "
|
|
729
|
+
lines.push(`- ${ch.name ?? "n/a"} — channel_id:${ch.channel_id ?? "n/a"} appearances:${ch.count ?? 0}`);
|
|
570
730
|
}
|
|
571
731
|
}
|
|
572
732
|
const related = data.related_speakers ?? [];
|
|
573
733
|
if (related.length) {
|
|
574
734
|
lines.push("\n**Co-appearances:**");
|
|
575
735
|
for (const r of related.slice(0, 10)) {
|
|
576
|
-
const count = r.appearance_count ?? r.co_appearances ?? "
|
|
577
|
-
lines.push(`- ${r.name ?? "
|
|
736
|
+
const count = r.appearance_count ?? r.co_appearances ?? "n/a";
|
|
737
|
+
lines.push(`- ${r.name ?? "n/a"} — speaker:${r.speaker_id ?? r.identity_id ?? "n/a"} count:${count}`);
|
|
578
738
|
}
|
|
579
739
|
}
|
|
580
740
|
const recent = data.recent_episodes ?? [];
|
|
581
741
|
if (recent.length) {
|
|
582
742
|
lines.push("\n**Recent Episodes:**");
|
|
583
743
|
for (const ep of recent.slice(0, 8)) {
|
|
584
|
-
|
|
585
|
-
lines.push(`- [${date}] ${ep.channel_name ?? "?"} — ${ep.title ?? "?"}`);
|
|
744
|
+
lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.channel_name ?? "n/a"} — ${ep.title ?? "n/a"}`);
|
|
586
745
|
}
|
|
587
746
|
}
|
|
588
|
-
|
|
747
|
+
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);
|
|
748
|
+
return lines.join("\n") + hint;
|
|
589
749
|
}
|
|
590
750
|
// ---------------------------------------------------------------------------
|
|
591
751
|
// Channel
|
|
@@ -602,12 +762,10 @@ export function formatChannel(data) {
|
|
|
602
762
|
meta.push(`**Language:** ${data.language}`);
|
|
603
763
|
if (meta.length)
|
|
604
764
|
lines.push(meta.join(" | "));
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
lines.push(`**Political lean:** ${lean.toFixed(2)} (${label})`);
|
|
610
|
-
}
|
|
765
|
+
const cid = data.channel_id ?? data.id;
|
|
766
|
+
lines.push(`channel_id:${cid ?? "n/a"}`);
|
|
767
|
+
if (data.political_lean != null)
|
|
768
|
+
lines.push(leanToken(data.political_lean));
|
|
611
769
|
if (data.description)
|
|
612
770
|
lines.push(`\n${data.description.slice(0, 500)}`);
|
|
613
771
|
if (data.primary_url)
|
|
@@ -622,52 +780,58 @@ export function formatChannel(data) {
|
|
|
622
780
|
if (parts.length)
|
|
623
781
|
lines.push(`\n**Ranking:** ${parts.join(", ")}`);
|
|
624
782
|
for (const cp of (ranking.chart_positions ?? []).slice(0, 5)) {
|
|
625
|
-
lines.push(`- Chart: ${cp.platform ?? "
|
|
783
|
+
lines.push(`- Chart: ${cp.platform ?? "n/a"} ${cp.country ?? "n/a"}/${cp.category ?? "n/a"} #${cp.rank ?? "n/a"}`);
|
|
626
784
|
}
|
|
627
785
|
}
|
|
628
786
|
const speakers = data.regular_speakers ?? data.speakers ?? [];
|
|
629
787
|
if (speakers.length) {
|
|
630
788
|
lines.push("\n**Speakers:**");
|
|
631
789
|
for (const s of speakers.slice(0, 10)) {
|
|
632
|
-
const count = s.appearance_count ?? s.count ?? "
|
|
633
|
-
lines.push(`- ${s.name ?? "
|
|
790
|
+
const count = s.appearance_count ?? s.count ?? "n/a";
|
|
791
|
+
lines.push(`- ${s.name ?? "n/a"} — speaker:${s.speaker_id ?? s.identity_id ?? "n/a"} appearances:${count}`);
|
|
634
792
|
}
|
|
635
793
|
}
|
|
636
794
|
const recent = data.recent_episodes ?? [];
|
|
637
795
|
if (recent.length) {
|
|
638
796
|
lines.push("\n**Recent Episodes:**");
|
|
639
797
|
for (const ep of recent.slice(0, 8)) {
|
|
640
|
-
lines.push(`-
|
|
798
|
+
lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.title ?? "n/a"}`);
|
|
641
799
|
}
|
|
642
800
|
}
|
|
643
|
-
|
|
801
|
+
const hint = nextCallHint(cid != null ? `search_quotes channel_id:${cid}` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
|
|
802
|
+
return lines.join("\n") + hint;
|
|
644
803
|
}
|
|
645
804
|
// ---------------------------------------------------------------------------
|
|
646
805
|
// Segment
|
|
647
806
|
// ---------------------------------------------------------------------------
|
|
648
807
|
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
|
-
|
|
808
|
+
const segId = data.segment_id;
|
|
809
|
+
const spk = data.speaker ?? {};
|
|
810
|
+
const spkName = data.speaker_name ?? spk.name;
|
|
811
|
+
const resolved = spk.identity_id != null;
|
|
812
|
+
const clip = clipUrl(data.content_id, data.start_time ?? undefined, data.end_time ?? undefined, data.clip_url);
|
|
813
|
+
const speakerTokens = [];
|
|
814
|
+
if (resolved)
|
|
815
|
+
speakerTokens.push(`speaker:${spk.identity_id}`);
|
|
816
|
+
else if (!spkName)
|
|
817
|
+
speakerTokens.push("speaker:unresolved (diarization cluster)");
|
|
818
|
+
if (spk.confidence !== undefined || resolved)
|
|
819
|
+
speakerTokens.push(confToken(spk.confidence));
|
|
820
|
+
if (spk.appearance_type)
|
|
821
|
+
speakerTokens.push(`appearance:${spk.appearance_type}`);
|
|
658
822
|
const lines = [
|
|
659
|
-
`**Segment ${segId}**`,
|
|
660
|
-
`- **Speaker:** ${
|
|
661
|
-
`- **Channel:** ${
|
|
662
|
-
`- **Episode:** ${
|
|
663
|
-
`- **Date:**
|
|
823
|
+
`**Segment ${segId ?? "n/a"}**`,
|
|
824
|
+
`- **Speaker:** ${spkName ?? "unresolved (diarization cluster)"}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
|
|
825
|
+
`- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
|
|
826
|
+
`- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
|
|
827
|
+
`- **Date:** date:${fmtDate(data.episode_date)}`,
|
|
664
828
|
];
|
|
665
|
-
if (
|
|
666
|
-
lines.push(`- **Time:** ${
|
|
667
|
-
|
|
668
|
-
lines.push(`- **Audio clip:** ${clip}`);
|
|
829
|
+
if (data.start_time != null)
|
|
830
|
+
lines.push(`- **Time:** ${fmtTimeRange(data.start_time, data.end_time)}`);
|
|
831
|
+
lines.push(`- ${clipLine(data.content_type, clip)}`);
|
|
669
832
|
lines.push(`\n> ${data.text ?? ""}`);
|
|
670
|
-
|
|
833
|
+
const hint = nextCallHint(segId != null ? `get_thread segment_id:${segId}` : undefined, data.content_id ? `get_episode content_id:${data.content_id}` : undefined);
|
|
834
|
+
return lines.join("\n") + hint;
|
|
671
835
|
}
|
|
672
836
|
// ---------------------------------------------------------------------------
|
|
673
837
|
// Analysis
|
|
@@ -683,7 +847,7 @@ export function formatAnalysis(data) {
|
|
|
683
847
|
const lines = ["**Analysis complete**\n"];
|
|
684
848
|
const centrality = q.discourse_centrality;
|
|
685
849
|
if (centrality?.label) {
|
|
686
|
-
lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "
|
|
850
|
+
lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "n/a"}/5)`);
|
|
687
851
|
}
|
|
688
852
|
const parts = [];
|
|
689
853
|
if (q.total_segments)
|
|
@@ -704,19 +868,45 @@ export function formatAnalysis(data) {
|
|
|
704
868
|
const count = (t.segment_count ?? t.count);
|
|
705
869
|
const countStr = count ? ` (${count} segments)` : "";
|
|
706
870
|
lines.push(`${i + 1}. ${name}${countStr}`);
|
|
707
|
-
const desc = (t.summary ?? t.description);
|
|
871
|
+
const desc = rewriteSegCitations((t.summary ?? t.description));
|
|
708
872
|
if (desc)
|
|
709
|
-
lines.push(` ${desc
|
|
873
|
+
lines.push(` ${desc}`);
|
|
710
874
|
}
|
|
711
875
|
lines.push("");
|
|
712
876
|
}
|
|
713
877
|
if (data.summary)
|
|
714
|
-
lines.push(data.summary);
|
|
715
|
-
|
|
878
|
+
lines.push(rewriteSegCitations(data.summary));
|
|
879
|
+
const firstSeg = (data.segment_ids ?? [])[0];
|
|
880
|
+
const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
881
|
+
return lines.join("\n") + hint;
|
|
716
882
|
}
|
|
717
883
|
// ---------------------------------------------------------------------------
|
|
718
884
|
// Frame comparison (left vs right)
|
|
719
885
|
// ---------------------------------------------------------------------------
|
|
886
|
+
// LLM theme summaries can still hit the token ceiling and stop mid-sentence or
|
|
887
|
+
// mid-citation ("...to leave {seg_4225"). Drop any trailing incomplete {seg_...}
|
|
888
|
+
// citation, then cut back to the last complete sentence and flag the trim.
|
|
889
|
+
function trimTruncatedSummary(text) {
|
|
890
|
+
if (!text)
|
|
891
|
+
return text;
|
|
892
|
+
let s = text.trimEnd();
|
|
893
|
+
let trimmed = false;
|
|
894
|
+
// Dangling open citation at the end (an unclosed "{...")
|
|
895
|
+
const lastOpen = s.lastIndexOf("{");
|
|
896
|
+
if (lastOpen !== -1 && s.indexOf("}", lastOpen) === -1) {
|
|
897
|
+
s = s.slice(0, lastOpen).trimEnd();
|
|
898
|
+
trimmed = true;
|
|
899
|
+
}
|
|
900
|
+
// Mid-sentence cut: back up to the last sentence/citation boundary.
|
|
901
|
+
if (!/[.!?…}"”')\]]$/.test(s)) {
|
|
902
|
+
const m = s.match(/^[\s\S]*[.!?…}]/);
|
|
903
|
+
if (m) {
|
|
904
|
+
s = m[0];
|
|
905
|
+
trimmed = true;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return trimmed ? `${s} [summary truncated]` : s;
|
|
909
|
+
}
|
|
720
910
|
export function formatFrameComparison(left, right, topic) {
|
|
721
911
|
if (left.error && right.error)
|
|
722
912
|
return `Error: left=${left.error}, right=${right.error}`;
|
|
@@ -726,6 +916,7 @@ export function formatFrameComparison(left, right, topic) {
|
|
|
726
916
|
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
917
|
}
|
|
728
918
|
const lines = [`**Frame comparison: ${topic}**\n`];
|
|
919
|
+
let firstSeg;
|
|
729
920
|
function sideBlock(label, data) {
|
|
730
921
|
if (data.error) {
|
|
731
922
|
lines.push(`### ${label}\nError: ${data.error}\n`);
|
|
@@ -740,25 +931,34 @@ export function formatFrameComparison(left, right, topic) {
|
|
|
740
931
|
if (q.unique_speakers)
|
|
741
932
|
parts.push(`${q.unique_speakers} speakers`);
|
|
742
933
|
lines.push(`### ${label}${parts.length ? ` (${parts.join(", ")})` : ""}`);
|
|
743
|
-
|
|
934
|
+
// Render EVERY theme, numbered — the synthesis prose refers to themes by
|
|
935
|
+
// number, so truncating the list left it citing "Themes 12–19" that were
|
|
936
|
+
// never displayed. Include per-theme prose and segment citations so a
|
|
937
|
+
// reader can verify any claim (seg:<id> works with get_segment).
|
|
938
|
+
const themes = data.themes ?? [];
|
|
744
939
|
if (themes.length) {
|
|
745
940
|
lines.push("**Key themes:**");
|
|
746
|
-
|
|
941
|
+
themes.forEach((t, i) => {
|
|
747
942
|
const name = (t.name ?? t.theme_name ?? t.label);
|
|
748
|
-
const desc = (t.summary ?? t.description);
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
943
|
+
const desc = rewriteSegCitations(trimTruncatedSummary((t.summary ?? t.description)));
|
|
944
|
+
const segs = t.segment_count ? ` — ${t.segment_count} segments` : "";
|
|
945
|
+
const cites = (t.segment_ids ?? []).slice(0, 4).map((id) => `seg:${id}`).join(" ");
|
|
946
|
+
if (firstSeg == null && (t.segment_ids ?? []).length)
|
|
947
|
+
firstSeg = t.segment_ids[0];
|
|
948
|
+
lines.push(`${i + 1}. **${name ?? `Theme ${i + 1}`}**${segs}${desc ? ": " + desc : ""}${cites ? ` [${cites}]` : ""}`);
|
|
949
|
+
});
|
|
752
950
|
}
|
|
753
951
|
if (data.summary) {
|
|
754
952
|
lines.push("");
|
|
755
|
-
lines.push(data.summary);
|
|
953
|
+
lines.push(rewriteSegCitations(data.summary));
|
|
756
954
|
}
|
|
757
955
|
lines.push("");
|
|
758
956
|
}
|
|
759
957
|
sideBlock("Left-leaning media", left);
|
|
760
958
|
sideBlock("Right-leaning media", right);
|
|
761
|
-
|
|
959
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
960
|
+
const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
961
|
+
return lines.join("\n") + hint;
|
|
762
962
|
}
|
|
763
963
|
// ---------------------------------------------------------------------------
|
|
764
964
|
// Comparison
|
|
@@ -773,11 +973,12 @@ export function formatComparison(data, topic) {
|
|
|
773
973
|
const lines = [`**Speaker comparison: ${topic}**\n`];
|
|
774
974
|
for (const [name, sp] of Object.entries(speakers)) {
|
|
775
975
|
const count = sp.segment_count ?? 0;
|
|
776
|
-
|
|
976
|
+
const gated = sp.gated_out ? `, ${sp.gated_out} weak matches excluded` : "";
|
|
977
|
+
lines.push(`### ${name} (${count} on-topic segments${gated})`);
|
|
777
978
|
if (sp.position_summary)
|
|
778
|
-
lines.push(`**Position:** ${sp.position_summary}`);
|
|
979
|
+
lines.push(`**Position:** ${rewriteSegCitations(sp.position_summary)}`);
|
|
779
980
|
if (sp.framing)
|
|
780
|
-
lines.push(`**Framing:** ${sp.framing}`);
|
|
981
|
+
lines.push(`**Framing:** ${rewriteSegCitations(sp.framing)}`);
|
|
781
982
|
if (sp.tone)
|
|
782
983
|
lines.push(`**Tone:** ${sp.tone}`);
|
|
783
984
|
const claims = sp.key_claims ?? [];
|
|
@@ -785,20 +986,45 @@ export function formatComparison(data, topic) {
|
|
|
785
986
|
lines.push("**Key claims:**");
|
|
786
987
|
for (const c of claims.slice(0, 5)) {
|
|
787
988
|
const text = typeof c === "string" ? c : c.claim ?? String(c);
|
|
788
|
-
|
|
989
|
+
const cid = typeof c === "object" && c ? c.claim_id : undefined;
|
|
990
|
+
const idTok = cid != null ? ` — claim:${cid}` : "";
|
|
991
|
+
lines.push(`- ${rewriteSegCitations(text)}${idTok}`);
|
|
789
992
|
}
|
|
790
993
|
}
|
|
791
994
|
lines.push("");
|
|
792
995
|
}
|
|
793
996
|
if (data.comparison || data.summary) {
|
|
794
|
-
lines.push("
|
|
795
|
-
lines.push(data.comparison ?? data.summary ?? "");
|
|
997
|
+
lines.push("## Synthesis");
|
|
998
|
+
lines.push(rewriteSegCitations(data.comparison ?? data.summary ?? ""));
|
|
999
|
+
lines.push("");
|
|
796
1000
|
}
|
|
1001
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
797
1002
|
return lines.join("\n");
|
|
798
1003
|
}
|
|
799
1004
|
// ---------------------------------------------------------------------------
|
|
800
1005
|
// Dossier
|
|
801
1006
|
// ---------------------------------------------------------------------------
|
|
1007
|
+
// Render the retrieval/provenance line shared by dossier and contradictions:
|
|
1008
|
+
// window searched, dates the evidence spans, match strength, gating stats.
|
|
1009
|
+
function pushRetrievalProvenance(lines, ret) {
|
|
1010
|
+
if (!ret)
|
|
1011
|
+
return;
|
|
1012
|
+
const parts = [];
|
|
1013
|
+
if (ret.window_days)
|
|
1014
|
+
parts.push(`window: last ${ret.window_days} days`);
|
|
1015
|
+
if (ret.date_range?.length === 2)
|
|
1016
|
+
parts.push(`evidence spans ${ret.date_range[0].slice(0, 10)} → ${ret.date_range[1].slice(0, 10)}`);
|
|
1017
|
+
if (ret.similarity_range?.length === 2)
|
|
1018
|
+
parts.push(`relevance ${ret.similarity_range[0].toFixed(2)}–${ret.similarity_range[1].toFixed(2)}`);
|
|
1019
|
+
if (ret.gated_out)
|
|
1020
|
+
parts.push(`${ret.gated_out} weak matches (<${(ret.min_similarity ?? 0.4).toFixed(2)}) excluded`);
|
|
1021
|
+
if (ret.weak_match_count)
|
|
1022
|
+
parts.push(`${ret.weak_match_count} weak matches (<0.40) included`);
|
|
1023
|
+
if (parts.length)
|
|
1024
|
+
lines.push(`_${parts.join(" · ")}_`);
|
|
1025
|
+
if (ret.thin_evidence)
|
|
1026
|
+
lines.push(`_⚠ Thin evidence: fewer than 5 on-topic segments met the relevance threshold — treat conclusions as provisional._`);
|
|
1027
|
+
}
|
|
802
1028
|
export function formatDossier(data, speakerName, topic) {
|
|
803
1029
|
if (data.error)
|
|
804
1030
|
return `Error: ${data.error}`;
|
|
@@ -810,17 +1036,23 @@ export function formatDossier(data, speakerName, topic) {
|
|
|
810
1036
|
if (sp.occupation)
|
|
811
1037
|
lines.push(`*${sp.occupation}*`);
|
|
812
1038
|
if (sp.segment_count)
|
|
813
|
-
lines.push(`${sp.segment_count} segments analyzed
|
|
1039
|
+
lines.push(`${sp.segment_count} segments analyzed`);
|
|
1040
|
+
// Show the working: window searched, dates the evidence actually spans, and
|
|
1041
|
+
// match strength — a dossier built from one recent episode must not pass
|
|
1042
|
+
// for a longitudinal record.
|
|
1043
|
+
pushRetrievalProvenance(lines, sp.retrieval);
|
|
1044
|
+
lines.push("");
|
|
814
1045
|
const contras = data.contradictions;
|
|
815
1046
|
if (contras?.verdict) {
|
|
816
1047
|
lines.push(`**Contradiction check:** ${contras.verdict.replace(/_/g, " ")}`);
|
|
817
1048
|
for (const c of contras.contradictions ?? []) {
|
|
818
|
-
lines.push(`- [${c.severity ?? "
|
|
1049
|
+
lines.push(`- [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
|
|
819
1050
|
}
|
|
820
1051
|
lines.push("");
|
|
821
1052
|
}
|
|
822
1053
|
if (data.summary)
|
|
823
|
-
lines.push(data.summary);
|
|
1054
|
+
lines.push(rewriteSegCitations(data.summary), "");
|
|
1055
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
824
1056
|
return lines.join("\n");
|
|
825
1057
|
}
|
|
826
1058
|
// ---------------------------------------------------------------------------
|
|
@@ -835,13 +1067,15 @@ export function formatContradictions(data, speakerName, topic) {
|
|
|
835
1067
|
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
1068
|
}
|
|
837
1069
|
const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
|
|
1070
|
+
pushRetrievalProvenance(lines, data.speaker?.retrieval);
|
|
838
1071
|
lines.push(`**Verdict:** ${verdict.replace(/_/g, " ")}\n`);
|
|
1072
|
+
// Contradiction evidence is NEVER truncated (spec §4).
|
|
839
1073
|
for (const c of contras.contradictions ?? []) {
|
|
840
|
-
lines.push(`### [${c.severity ?? "
|
|
1074
|
+
lines.push(`### [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
|
|
841
1075
|
if (c.earlier?.claim)
|
|
842
|
-
lines.push(`**Earlier** (
|
|
1076
|
+
lines.push(`**Earlier** (date:${fmtDate(c.earlier.date)}): ${rewriteSegCitations(c.earlier.claim)}`);
|
|
843
1077
|
if (c.later?.claim)
|
|
844
|
-
lines.push(`**Later** (
|
|
1078
|
+
lines.push(`**Later** (date:${fmtDate(c.later.date)}): ${rewriteSegCitations(c.later.claim)}`);
|
|
845
1079
|
if (c.possible_context)
|
|
846
1080
|
lines.push(`*Context:* ${c.possible_context}`);
|
|
847
1081
|
lines.push("");
|
|
@@ -850,18 +1084,22 @@ export function formatContradictions(data, speakerName, topic) {
|
|
|
850
1084
|
if (consistent.length) {
|
|
851
1085
|
lines.push("**Consistent positions:**");
|
|
852
1086
|
for (const pos of consistent)
|
|
853
|
-
lines.push(`- ${pos}`);
|
|
1087
|
+
lines.push(`- ${rewriteSegCitations(pos)}`);
|
|
854
1088
|
lines.push("");
|
|
855
1089
|
}
|
|
856
1090
|
const timeline = data.stance_timeline ?? [];
|
|
857
1091
|
if (timeline.length) {
|
|
858
1092
|
lines.push("**Stance timeline:**");
|
|
859
1093
|
for (const t of timeline.slice(0, 20)) {
|
|
860
|
-
|
|
1094
|
+
const pointer = t.segment_id != null ? `get_segment seg:${t.segment_id}` : undefined;
|
|
1095
|
+
lines.push(`- seg:${t.segment_id ?? "n/a"} stance:${t.stance ?? "n/a"} — ${truncateWithPointer(t.claim, pointer)}`);
|
|
861
1096
|
}
|
|
1097
|
+
lines.push("");
|
|
862
1098
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1099
|
+
lines.push("quotes are model-extracted; verify via cited seg: before publication");
|
|
1100
|
+
// Note: data.summary is deliberately NOT appended — the backend builds it
|
|
1101
|
+
// from the same verdict/contradictions/consistent_positions fields rendered
|
|
1102
|
+
// above, so it duplicated the report as a dangling stub.
|
|
865
1103
|
return lines.join("\n");
|
|
866
1104
|
}
|
|
867
1105
|
// ---------------------------------------------------------------------------
|
|
@@ -871,13 +1109,13 @@ export function formatBriefing(data) {
|
|
|
871
1109
|
const lines = [];
|
|
872
1110
|
const stats = data.statistics ?? {};
|
|
873
1111
|
if (stats.total_episodes || stats.total_channels) {
|
|
874
|
-
lines.push(`**Briefing:** ${stats.total_episodes ?? "
|
|
875
|
-
`${stats.total_channels ?? "
|
|
1112
|
+
lines.push(`**Briefing:** ${stats.total_episodes ?? "n/a"} episodes, ` +
|
|
1113
|
+
`${stats.total_channels ?? "n/a"} channels, ` +
|
|
876
1114
|
`${(stats.total_duration_hours ?? 0).toFixed(0)}h\n`);
|
|
877
1115
|
}
|
|
878
1116
|
for (let i = 0; i < (data.topics ?? []).length; i++) {
|
|
879
1117
|
const t = data.topics[i];
|
|
880
|
-
const title = t.title ?? t.label ?? "
|
|
1118
|
+
const title = t.title ?? t.label ?? "n/a";
|
|
881
1119
|
const segs = t.segment_count ?? 0;
|
|
882
1120
|
const chs = t.channel_count ?? 0;
|
|
883
1121
|
const score = t.score ?? 0;
|
|
@@ -887,8 +1125,16 @@ export function formatBriefing(data) {
|
|
|
887
1125
|
lines.push(` ${truncate(summary, 250)}`);
|
|
888
1126
|
for (const c of (t.citations ?? []).slice(0, 2)) {
|
|
889
1127
|
const label = [c.speaker_name, c.channel_name].filter(Boolean).join(" on ");
|
|
1128
|
+
const ids = [];
|
|
1129
|
+
if (c.segment_id != null)
|
|
1130
|
+
ids.push(`seg:${c.segment_id}`);
|
|
1131
|
+
if (c.content_id)
|
|
1132
|
+
ids.push(`episode:${c.content_id}`);
|
|
1133
|
+
if (c.publish_date)
|
|
1134
|
+
ids.push(`date:${fmtDate(c.publish_date)}`);
|
|
1135
|
+
const pointer = c.segment_id != null ? `get_segment seg:${c.segment_id}` : undefined;
|
|
890
1136
|
if (c.text)
|
|
891
|
-
lines.push(` "${
|
|
1137
|
+
lines.push(` "${truncateWithPointer(c.text, pointer)}" — ${label}${ids.length ? ` (${ids.join(" ")})` : ""}`);
|
|
892
1138
|
}
|
|
893
1139
|
lines.push("");
|
|
894
1140
|
}
|
|
@@ -896,18 +1142,20 @@ export function formatBriefing(data) {
|
|
|
896
1142
|
if (events.length) {
|
|
897
1143
|
lines.push("**Key events:**");
|
|
898
1144
|
for (const ev of events.slice(0, 5)) {
|
|
899
|
-
lines.push(`- ${ev.title ?? ev.description ?? "
|
|
1145
|
+
lines.push(`- ${ev.title ?? ev.description ?? "n/a"}`);
|
|
900
1146
|
}
|
|
901
1147
|
lines.push("");
|
|
902
1148
|
}
|
|
903
1149
|
const speakers = data.key_speakers ?? [];
|
|
904
1150
|
if (speakers.length) {
|
|
905
|
-
const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "
|
|
1151
|
+
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
1152
|
lines.push(`**Key speakers:** ${parts.join(", ")}\n`);
|
|
907
1153
|
}
|
|
908
1154
|
if (data.synthesis)
|
|
909
|
-
lines.push(data.synthesis);
|
|
910
|
-
|
|
1155
|
+
lines.push(rewriteSegCitations(data.synthesis));
|
|
1156
|
+
const firstTopic = data.topics?.[0];
|
|
1157
|
+
const hint = nextCallHint(firstTopic?.title ? `analyze_topic query:"${firstTopic.title}"` : undefined);
|
|
1158
|
+
return (lines.join("\n") || "No briefing data available.") + hint;
|
|
911
1159
|
}
|
|
912
1160
|
export function formatDiscourseSummary(data) {
|
|
913
1161
|
const lines = [];
|
|
@@ -935,7 +1183,7 @@ export function formatDiscourseSummary(data) {
|
|
|
935
1183
|
if (speakers.length) {
|
|
936
1184
|
lines.push("\n**Key Speakers:**");
|
|
937
1185
|
for (const s of speakers.slice(0, 10)) {
|
|
938
|
-
lines.push(`- ${s.name ?? "
|
|
1186
|
+
lines.push(`- ${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`);
|
|
939
1187
|
}
|
|
940
1188
|
}
|
|
941
1189
|
return lines.join("\n") || "No discourse data available.";
|
|
@@ -954,45 +1202,48 @@ export function formatTopics(topics, totalClusters, partition, query) {
|
|
|
954
1202
|
return `No topics matched "${query}" above the relevance threshold.`;
|
|
955
1203
|
}
|
|
956
1204
|
const lines = [
|
|
957
|
-
`**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "
|
|
1205
|
+
`**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "n/a"})\n`,
|
|
958
1206
|
];
|
|
959
1207
|
for (let i = 0; i < filtered.length; i++) {
|
|
960
1208
|
const t = filtered[i];
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1209
|
+
const tokens = [`cluster_id:${t.cluster_id ?? "n/a"}`];
|
|
1210
|
+
if (t.category)
|
|
1211
|
+
tokens.push(`category:${t.category}`);
|
|
1212
|
+
if (t.relevance != null)
|
|
1213
|
+
tokens.push(`sim:${t.relevance.toFixed(2)}`);
|
|
1214
|
+
lines.push(`${i + 1}. **${t.title ?? "n/a"}** — ${tokens.join(" ")}`);
|
|
965
1215
|
lines.push(` ${t.segment_count ?? 0} segments across ${t.channel_count ?? 0} channels`);
|
|
966
1216
|
const speakers = t.top_speakers ?? [];
|
|
967
1217
|
if (speakers.length) {
|
|
968
|
-
lines.push(`
|
|
1218
|
+
lines.push(` voices: ${speakers.slice(0, 3).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
|
|
969
1219
|
}
|
|
970
1220
|
const topSeg = (t.top_segments ?? [])[0];
|
|
971
1221
|
if (topSeg?.text) {
|
|
972
|
-
|
|
1222
|
+
const pointer = topSeg.segment_id != null ? `get_segment seg:${topSeg.segment_id}` : undefined;
|
|
1223
|
+
lines.push(` "${truncateWithPointer(topSeg.text, pointer)}" — ${topSeg.channel_name ?? ""} (seg:${topSeg.segment_id ?? "n/a"})`);
|
|
973
1224
|
}
|
|
974
1225
|
lines.push("");
|
|
975
1226
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
return lines.join("\n");
|
|
1227
|
+
const firstSeg = filtered.map((t) => (t.top_segments ?? [])[0]?.segment_id).find((id) => id != null);
|
|
1228
|
+
const hint = nextCallHint(filtered[0]?.title ? `analyze_topic query:"${filtered[0].title}"` : undefined, firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
|
|
1229
|
+
return lines.join("\n") + hint;
|
|
979
1230
|
}
|
|
980
1231
|
// ---------------------------------------------------------------------------
|
|
981
1232
|
// Ad formatters
|
|
982
1233
|
// ---------------------------------------------------------------------------
|
|
983
1234
|
export function formatAdStats(data) {
|
|
984
1235
|
const lines = ["# Ad Analytics Overview\n"];
|
|
985
|
-
lines.push(`**${
|
|
1236
|
+
lines.push(`**${data.total_ad_hashes ?? 0}** confirmed ad sentences`);
|
|
986
1237
|
lines.push(`**${data.total_brands ?? 0}** advertisers (+ ${(data.total_advertisers ?? 0) - (data.total_brands ?? 0)} network promos)`);
|
|
987
|
-
lines.push(`**${
|
|
988
|
-
lines.push(`**${
|
|
1238
|
+
lines.push(`**${data.ad_reads_in_window ?? 0}** ad reads in last ${data.time_window_days ?? 30}d`);
|
|
1239
|
+
lines.push(`**${data.episodes_with_ads_in_window ?? 0}** episodes with ads`);
|
|
989
1240
|
lines.push(`**${data.avg_ad_read_duration_seconds ?? 0}s** avg ad read duration`);
|
|
990
1241
|
const byCat = data.ads_by_category ?? data.by_category ?? {};
|
|
991
1242
|
if (Object.keys(byCat).length) {
|
|
992
1243
|
lines.push("\n**By Category:**");
|
|
993
1244
|
const sorted = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
|
|
994
1245
|
for (const [cat, count] of sorted) {
|
|
995
|
-
lines.push(`- ${cat}: ${count
|
|
1246
|
+
lines.push(`- ${cat}: ${count}`);
|
|
996
1247
|
}
|
|
997
1248
|
}
|
|
998
1249
|
const top = data.top_advertisers ?? [];
|
|
@@ -1000,11 +1251,13 @@ export function formatAdStats(data) {
|
|
|
1000
1251
|
lines.push("\n**Top Advertisers:**");
|
|
1001
1252
|
for (let i = 0; i < top.length; i++) {
|
|
1002
1253
|
const a = top[i];
|
|
1003
|
-
lines.push(`${i + 1}. **${a.name}**
|
|
1004
|
-
|
|
1254
|
+
lines.push(`${i + 1}. **${a.name}** — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1255
|
+
`episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
|
|
1005
1256
|
}
|
|
1006
1257
|
}
|
|
1007
|
-
|
|
1258
|
+
const firstId = top.find((a) => a.id != null)?.id;
|
|
1259
|
+
const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : "get_ad_analytics view:advertisers");
|
|
1260
|
+
return lines.join("\n") + hint;
|
|
1008
1261
|
}
|
|
1009
1262
|
export function formatAdvertiserList(data) {
|
|
1010
1263
|
const advs = data.advertisers ?? [];
|
|
@@ -1015,29 +1268,33 @@ export function formatAdvertiserList(data) {
|
|
|
1015
1268
|
for (let i = 0; i < advs.length; i++) {
|
|
1016
1269
|
const a = advs[i];
|
|
1017
1270
|
const aliases = a.aliases?.length ? ` (aka ${a.aliases.slice(0, 3).join(", ")})` : "";
|
|
1018
|
-
lines.push(`${i + 1}. **${a.name}**${aliases}
|
|
1019
|
-
|
|
1271
|
+
lines.push(`${i + 1}. **${a.name}**${aliases} — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1272
|
+
`episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
|
|
1020
1273
|
}
|
|
1021
|
-
|
|
1274
|
+
const paging = pagingNote({ shown: advs.length, total });
|
|
1275
|
+
const firstId = advs.find((a) => a.id != null)?.id;
|
|
1276
|
+
const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : undefined);
|
|
1277
|
+
return lines.join("\n") + paging + hint;
|
|
1022
1278
|
}
|
|
1023
1279
|
export function formatAdvertiserDetail(data) {
|
|
1024
1280
|
if (data.error)
|
|
1025
1281
|
return `Error: ${data.error}`;
|
|
1026
1282
|
const lines = [`# ${data.name}`];
|
|
1283
|
+
lines.push(`advertiser:${data.id ?? "n/a"}`);
|
|
1027
1284
|
if (data.category)
|
|
1028
1285
|
lines.push(`**Category:** ${data.category}`);
|
|
1029
1286
|
if (data.aliases?.length)
|
|
1030
1287
|
lines.push(`**Also known as:** ${data.aliases.join(", ")}`);
|
|
1031
1288
|
if (data.website)
|
|
1032
1289
|
lines.push(`**Website:** ${data.website}`);
|
|
1033
|
-
lines.push(`**Episodes:** ${data.total_episode_count
|
|
1290
|
+
lines.push(`**Episodes:** ${data.total_episode_count} | **Channels:** ${data.total_channel_count}`);
|
|
1034
1291
|
if (data.first_seen)
|
|
1035
|
-
lines.push(`**Active:**
|
|
1292
|
+
lines.push(`**Active:** date:${fmtDate(data.first_seen)} — date:${fmtDate(data.last_seen)}`);
|
|
1036
1293
|
const channels = data.top_channels ?? [];
|
|
1037
1294
|
if (channels.length) {
|
|
1038
1295
|
lines.push("\n**Top Channels:**");
|
|
1039
1296
|
for (const ch of channels.slice(0, 10)) {
|
|
1040
|
-
lines.push(`- ${ch.name}
|
|
1297
|
+
lines.push(`- ${ch.name} — channel_id:${ch.channel_id ?? "n/a"} platform:${ch.platform ?? "n/a"} episodes:${ch.episode_count}`);
|
|
1041
1298
|
}
|
|
1042
1299
|
}
|
|
1043
1300
|
const samples = data.sample_reads ?? [];
|
|
@@ -1045,113 +1302,135 @@ export function formatAdvertiserDetail(data) {
|
|
|
1045
1302
|
lines.push("\n**Recent Ad Reads:**");
|
|
1046
1303
|
for (const s of samples) {
|
|
1047
1304
|
const dur = s.duration_seconds ? ` (${s.duration_seconds}s)` : "";
|
|
1048
|
-
lines.push(`-
|
|
1305
|
+
lines.push(`- date:${fmtDate(s.date)} ${s.episode_title ?? "n/a"}${dur}`);
|
|
1306
|
+
// Ad-read text has no per-read retrieval tool — never cut it silently.
|
|
1049
1307
|
if (s.text)
|
|
1050
|
-
lines.push(` > ${s.text
|
|
1308
|
+
lines.push(` > ${s.text}`);
|
|
1051
1309
|
}
|
|
1052
1310
|
}
|
|
1053
|
-
|
|
1311
|
+
const firstCh = channels.find((ch) => ch.channel_id != null)?.channel_id;
|
|
1312
|
+
const hint = nextCallHint(firstCh != null ? `get_ad_analytics view:channel channel_id:${firstCh}` : undefined);
|
|
1313
|
+
return lines.join("\n") + hint;
|
|
1054
1314
|
}
|
|
1055
1315
|
export function formatChannelAds(data) {
|
|
1056
1316
|
if (data.error)
|
|
1057
1317
|
return `Error: ${data.error}`;
|
|
1058
1318
|
const chName = data.channel?.name ?? data.channel_name ?? "Channel";
|
|
1059
1319
|
const lines = [`# Ads on ${chName}`];
|
|
1060
|
-
lines.push(`**${
|
|
1320
|
+
lines.push(`**${data.total_ad_reads ?? 0}** ad reads in last ${data.time_window_days ?? 30}d\n`);
|
|
1061
1321
|
for (let i = 0; i < data.advertisers.length; i++) {
|
|
1062
1322
|
const a = data.advertisers[i];
|
|
1063
1323
|
const promo = a.is_network_promo ? " [network]" : "";
|
|
1064
|
-
lines.push(`${i + 1}. **${a.name}**${promo}
|
|
1065
|
-
|
|
1324
|
+
lines.push(`${i + 1}. **${a.name}**${promo} — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1325
|
+
`episodes:${a.episode_count} reads:${a.read_count}`);
|
|
1066
1326
|
}
|
|
1067
|
-
|
|
1327
|
+
const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
|
|
1328
|
+
const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
|
|
1329
|
+
return (lines.join("\n") || "No ad data for this channel.") + hint;
|
|
1068
1330
|
}
|
|
1069
1331
|
export function formatAdTrends(data) {
|
|
1070
1332
|
if (!data.advertisers?.length)
|
|
1071
1333
|
return "No trending data available.";
|
|
1072
1334
|
const lines = [`# Ad Trends (${data.comparison ?? ""})\n`];
|
|
1073
1335
|
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)}%)`);
|
|
1336
|
+
lines.push(`- **${a.name}** — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
|
|
1337
|
+
`${trendToken(a.trend, a.change_pct)} episodes:${a.current_episodes} (was ${a.prior_episodes})`);
|
|
1077
1338
|
}
|
|
1078
|
-
|
|
1339
|
+
const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
|
|
1340
|
+
const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
|
|
1341
|
+
return lines.join("\n") + hint;
|
|
1079
1342
|
}
|
|
1080
1343
|
// ---------------------------------------------------------------------------
|
|
1081
1344
|
// Claim Detail
|
|
1082
1345
|
// ---------------------------------------------------------------------------
|
|
1346
|
+
function observationLine(o) {
|
|
1347
|
+
const date = fmtDate(o.publish_date);
|
|
1348
|
+
const meta = [o.speaker_name ?? "speaker:unresolved (diarization cluster)", o.channel_name, `date:${date}`]
|
|
1349
|
+
.filter(Boolean).join(", ");
|
|
1350
|
+
const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
|
|
1351
|
+
const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
|
|
1352
|
+
return [
|
|
1353
|
+
`- "${truncateWithPointer(o.chunk_text, pointer)}" — ${meta}`,
|
|
1354
|
+
` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`,
|
|
1355
|
+
];
|
|
1356
|
+
}
|
|
1083
1357
|
export function formatClaimDetail(detail, observations, related) {
|
|
1084
1358
|
const c = detail.claim;
|
|
1085
1359
|
const lines = [];
|
|
1086
|
-
lines.push(`# Claim
|
|
1087
|
-
lines.push(`**${c.summary}
|
|
1088
|
-
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1360
|
+
lines.push(`# Claim claim:${c.claim_id}`);
|
|
1361
|
+
lines.push(`**${c.summary}**`);
|
|
1362
|
+
lines.push(`(synthesized claim — observations carry verbatim quotes)\n`);
|
|
1363
|
+
const tokens = [`stance:${c.stance ?? "n/a"}`, `topic:${c.topic ?? "n/a"}`];
|
|
1364
|
+
const lt = langToken(c.language);
|
|
1365
|
+
if (lt)
|
|
1366
|
+
tokens.push(lt);
|
|
1367
|
+
lines.push(tokens.join(" "));
|
|
1368
|
+
lines.push(`obs:${c.observation_count} ${episodeTokens(c)} channels:${c.channel_count}`);
|
|
1369
|
+
const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "n/a");
|
|
1370
|
+
if (span.length === 2)
|
|
1371
|
+
lines.push(`first_seen:${span[0]} last_seen:${span[1]}`);
|
|
1095
1372
|
if (c.entities?.length) {
|
|
1096
|
-
lines.push(`
|
|
1373
|
+
lines.push(`entities:${c.entities.join(",")}`);
|
|
1097
1374
|
}
|
|
1375
|
+
if (c.language && c.language !== "en")
|
|
1376
|
+
lines.push(NON_ENGLISH_NOTE);
|
|
1098
1377
|
lines.push("");
|
|
1099
1378
|
if (c.top_speakers?.length) {
|
|
1100
1379
|
lines.push("**Speakers:**");
|
|
1101
1380
|
for (const s of c.top_speakers.slice(0, 10)) {
|
|
1102
|
-
|
|
1103
|
-
lines.push(`- ${s.name}${idStr} — ${s.observation_count} observations`);
|
|
1381
|
+
lines.push(`- ${s.name} — speaker:${s.identity_id ?? "n/a"} obs:${s.observation_count}`);
|
|
1104
1382
|
}
|
|
1105
1383
|
lines.push("");
|
|
1106
1384
|
}
|
|
1107
1385
|
if (c.top_channels?.length) {
|
|
1108
1386
|
lines.push("**Channels:**");
|
|
1109
1387
|
for (const ch of c.top_channels.slice(0, 10)) {
|
|
1110
|
-
lines.push(`- ${ch.name} —
|
|
1388
|
+
lines.push(`- ${ch.name} — channel_id:${ch.channel_id} obs:${ch.observation_count}`);
|
|
1111
1389
|
}
|
|
1112
1390
|
lines.push("");
|
|
1113
1391
|
}
|
|
1114
1392
|
const obs = observations?.observations ?? c.sample_observations ?? [];
|
|
1393
|
+
let firstObsSeg;
|
|
1115
1394
|
if (obs.length) {
|
|
1116
1395
|
const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
|
|
1117
1396
|
lines.push(label);
|
|
1118
1397
|
for (const o of obs.slice(0, 20)) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
lines.push(
|
|
1122
|
-
|
|
1123
|
-
|
|
1398
|
+
if (firstObsSeg == null && o.segment_id != null)
|
|
1399
|
+
firstObsSeg = o.segment_id;
|
|
1400
|
+
lines.push(...observationLine(o));
|
|
1401
|
+
}
|
|
1402
|
+
if (observations && observations.total > Math.min(obs.length, 20)) {
|
|
1403
|
+
lines.push(`showing ${Math.min(obs.length, 20)} of ${observations.total} — page:${(observations.page ?? 1) + 1} for more`);
|
|
1124
1404
|
}
|
|
1125
1405
|
lines.push("");
|
|
1126
1406
|
}
|
|
1127
1407
|
if (related?.claims?.length) {
|
|
1128
1408
|
lines.push("**Related Claims:**");
|
|
1129
1409
|
for (const r of related.claims) {
|
|
1130
|
-
|
|
1131
|
-
lines.push(`- ${r.summary} — ${r.observation_count} observations${sim}`);
|
|
1410
|
+
lines.push(claimLine(r));
|
|
1132
1411
|
}
|
|
1133
1412
|
lines.push("");
|
|
1134
1413
|
}
|
|
1135
|
-
|
|
1414
|
+
const hint = nextCallHint(firstObsSeg != null ? `get_segment seg:${firstObsSeg}` : undefined, `claim_graph claim_id:${c.claim_id}`);
|
|
1415
|
+
return lines.join("\n") + hint;
|
|
1136
1416
|
}
|
|
1137
1417
|
// ---------------------------------------------------------------------------
|
|
1138
1418
|
// Timeline
|
|
1139
1419
|
// ---------------------------------------------------------------------------
|
|
1140
|
-
|
|
1420
|
+
/** MCP timeline: `date<TAB>count` rows (spec §5 — bar glyphs retired). */
|
|
1421
|
+
export function formatTimeline(data, footer = "") {
|
|
1141
1422
|
const buckets = data.buckets ?? [];
|
|
1142
1423
|
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;
|
|
1424
|
+
return "No data found for this topic in the given time range." + footer;
|
|
1146
1425
|
const lines = [
|
|
1147
1426
|
`**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
|
|
1427
|
+
"date\tcount",
|
|
1148
1428
|
];
|
|
1149
1429
|
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}`);
|
|
1430
|
+
lines.push(`${b.start}\t${b.count}`);
|
|
1154
1431
|
}
|
|
1155
|
-
|
|
1432
|
+
const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
|
|
1433
|
+
const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
|
|
1434
|
+
return withFooters(lines.join("\n"), footer, "", hint);
|
|
1156
1435
|
}
|
|
1157
1436
|
//# sourceMappingURL=format.js.map
|