@signal4/s4 2.6.0 → 2.7.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 +33 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +317 -175
- package/dist/cli.js.map +1 -1
- package/dist/cli_args.d.ts +24 -0
- package/dist/cli_args.d.ts.map +1 -0
- package/dist/cli_args.js +130 -0
- package/dist/cli_args.js.map +1 -0
- package/dist/client.d.ts +80 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +128 -24
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +81 -2
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +209 -47
- package/dist/format.js.map +1 -1
- package/dist/handler_types.d.ts +10 -0
- package/dist/handler_types.d.ts.map +1 -1
- package/dist/handler_types.js +25 -0
- package/dist/handler_types.js.map +1 -1
- package/dist/handlers/ads.js +4 -4
- package/dist/handlers/ads.js.map +1 -1
- package/dist/handlers/analyze.d.ts.map +1 -1
- package/dist/handlers/analyze.js +22 -6
- package/dist/handlers/analyze.js.map +1 -1
- package/dist/handlers/bookmarks.js +2 -2
- package/dist/handlers/bookmarks.js.map +1 -1
- package/dist/handlers/claim_detail.js +2 -2
- package/dist/handlers/claim_detail.js.map +1 -1
- package/dist/handlers/claims.d.ts.map +1 -1
- package/dist/handlers/claims.js +81 -26
- package/dist/handlers/claims.js.map +1 -1
- package/dist/handlers/count.d.ts +7 -0
- package/dist/handlers/count.d.ts.map +1 -0
- package/dist/handlers/count.js +146 -0
- package/dist/handlers/count.js.map +1 -0
- package/dist/handlers/explore.d.ts.map +1 -1
- package/dist/handlers/explore.js +53 -14
- package/dist/handlers/explore.js.map +1 -1
- package/dist/handlers/registry.d.ts.map +1 -1
- package/dist/handlers/registry.js +5 -1
- package/dist/handlers/registry.js.map +1 -1
- package/dist/handlers/reports.d.ts +1 -0
- package/dist/handlers/reports.d.ts.map +1 -1
- package/dist/handlers/reports.js +47 -11
- package/dist/handlers/reports.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +70 -22
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/timeline.d.ts.map +1 -1
- package/dist/handlers/timeline.js +14 -8
- package/dist/handlers/timeline.js.map +1 -1
- package/dist/http.js +25 -3
- package/dist/http.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/provenance.d.ts +29 -2
- package/dist/provenance.d.ts.map +1 -1
- package/dist/provenance.js +63 -4
- package/dist/provenance.js.map +1 -1
- package/dist/server_core.d.ts +8 -1
- package/dist/server_core.d.ts.map +1 -1
- package/dist/server_core.js +90 -8
- package/dist/server_core.js.map +1 -1
- package/dist/speed.d.ts +40 -0
- package/dist/speed.d.ts.map +1 -0
- package/dist/speed.js +196 -0
- package/dist/speed.js.map +1 -0
- package/dist/surface.d.ts +7 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +18 -0
- package/dist/surface.js.map +1 -0
- package/dist/types.d.ts +155 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/format.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* any port. Canonical tokens, truncation markers, and footers all live
|
|
7
7
|
* here — do not invent per-tool vocabulary in handlers.
|
|
8
8
|
*/
|
|
9
|
+
import { hint as surfaceHint, getSurface } from "./surface.js";
|
|
9
10
|
const PUBLIC_URL = "https://api.signal4.ca";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Political lean — single canonical bucket table (spec §5), shared with the
|
|
@@ -99,6 +100,65 @@ export function confToken(confidence) {
|
|
|
99
100
|
return "conf:unverified";
|
|
100
101
|
return `conf:${Math.round(confidence * 100)}%`;
|
|
101
102
|
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Speaker attribution
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* How a speaker renders, gated on the episode's host/guest roster (core#559).
|
|
108
|
+
*
|
|
109
|
+
* There are four states, and a consumer must be able to tell them apart:
|
|
110
|
+
*
|
|
111
|
+
* corroborated **Glenn Beck** — the roster backs the name
|
|
112
|
+
* unconfirmed **Unconfirmed — possibly Glenn Beck** — roster exists, name absent
|
|
113
|
+
* unchecked **Unconfirmed — possibly Glenn Beck** — no roster to check against
|
|
114
|
+
* no identity **Unresolved speaker** — a distinct voice, no name
|
|
115
|
+
*
|
|
116
|
+
* The hedge word leads so that a skim reads it. Putting the name first and the
|
|
117
|
+
* caveat in a token loses the caveat: an LLM consumer summarising the result
|
|
118
|
+
* writes "Glenn Beck said X" either way.
|
|
119
|
+
*
|
|
120
|
+
* The two hedged tiers deliberately share a label and differ in their token.
|
|
121
|
+
* They are the same instruction to the reader ("do not cite this name") for
|
|
122
|
+
* two different reasons, and the reason is machine-readable where it belongs.
|
|
123
|
+
*
|
|
124
|
+
* Fails closed: a backend that emits `identity_id` without `attribution` (any
|
|
125
|
+
* build older than core#559) is treated as `unchecked`. A stale deploy must not
|
|
126
|
+
* be able to turn assertions back on.
|
|
127
|
+
*/
|
|
128
|
+
export function speakerAttribution(spk) {
|
|
129
|
+
const s = spk ?? {};
|
|
130
|
+
const id = s.identity_id;
|
|
131
|
+
const name = s.name ?? s.primary_name;
|
|
132
|
+
if (id == null) {
|
|
133
|
+
// No identity — a diarization cluster. Any name string sitting here is a
|
|
134
|
+
// placeholder ("Unknown", "SPEAKER_01"), never something to print as a
|
|
135
|
+
// speaker: nothing links it to a person we can name.
|
|
136
|
+
return {
|
|
137
|
+
label: "Unresolved speaker",
|
|
138
|
+
tokens: ["speaker:unresolved (diarization cluster)"],
|
|
139
|
+
hedged: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const shown = name ?? `Speaker ${id}`;
|
|
143
|
+
const attribution = s.attribution ?? "unchecked";
|
|
144
|
+
if (attribution === "corroborated") {
|
|
145
|
+
const by = s.corroborated_by ? ` (${s.corroborated_by})` : "";
|
|
146
|
+
return { label: shown, tokens: [`speaker:${id}`, `attribution:corroborated${by}`], hedged: false };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
label: `Unconfirmed — possibly ${shown}`,
|
|
150
|
+
tokens: [`speaker:${id}`, `attribution:${attribution}`],
|
|
151
|
+
hedged: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Footer shown whenever a result set contains a hedged speaker name.
|
|
155
|
+
* Without it, "Unconfirmed — possibly X" is a label with no defined meaning. */
|
|
156
|
+
export const ATTRIBUTION_NOTE = "\n\n_Speaker names: `attribution:corroborated` = the identity is on that " +
|
|
157
|
+
"episode's host/guest list — safe to cite. `unconfirmed` = the episode has a " +
|
|
158
|
+
"host/guest list and this name is not on it. `unchecked` = no list exists for " +
|
|
159
|
+
"that episode. Do not cite an Unconfirmed name as the speaker; check it with " +
|
|
160
|
+
"`get_episode` (returns hosts/guests) or `get_segment_sentences`. `confidence` " +
|
|
161
|
+
"is not calibrated and is not a substitute for this._";
|
|
102
162
|
/** `trend:surging (+340%)` / `trend:new` / `trend:steady (n/a)` (spec §5). */
|
|
103
163
|
export function trendToken(direction, changePct) {
|
|
104
164
|
if (direction === "new")
|
|
@@ -143,7 +203,7 @@ export function truncateWithPointer(text, pointer, maxlen = QUOTE_BUDGET) {
|
|
|
143
203
|
const cut = clean.slice(0, maxlen);
|
|
144
204
|
const last = cut.lastIndexOf(" ");
|
|
145
205
|
const body = last > maxlen * 0.5 ? cut.slice(0, last) : cut;
|
|
146
|
-
return `${body}…[truncated — full text: ${pointer}]`;
|
|
206
|
+
return `${body}…[truncated — full text: ${surfaceHint(pointer)}]`;
|
|
147
207
|
}
|
|
148
208
|
/** Rewrite LLM `{seg_N}` / `{{seg_N}}` citations to the spec token `seg:N`. */
|
|
149
209
|
export function rewriteSegCitations(text) {
|
|
@@ -157,10 +217,10 @@ export function rewriteSegCitations(text) {
|
|
|
157
217
|
/** `showing 20 of 249 — offset:20 for more` (spec §7.2). */
|
|
158
218
|
export function pagingNote(opts) {
|
|
159
219
|
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
|
|
220
|
+
return surfaceHint(`\nshowing ${opts.shown} of ${opts.total ?? "n/a"} (count_capped — true total unknown) — offset:${(opts.offset ?? 0) + opts.shown} for more`);
|
|
161
221
|
}
|
|
162
222
|
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
|
|
223
|
+
return surfaceHint(`\nshowing ${opts.shown} of ${opts.total} — offset:${(opts.offset ?? 0) + opts.shown} for more`);
|
|
164
224
|
}
|
|
165
225
|
return "";
|
|
166
226
|
}
|
|
@@ -169,7 +229,7 @@ export function nextCallHint(...calls) {
|
|
|
169
229
|
const real = calls.filter((c) => !!c);
|
|
170
230
|
if (!real.length)
|
|
171
231
|
return "";
|
|
172
|
-
return `\n→ ${real.join(" · ")}
|
|
232
|
+
return surfaceHint(`\n→ ${real.join(" · ")}`);
|
|
173
233
|
}
|
|
174
234
|
/** Assemble body + footers in spec order. */
|
|
175
235
|
function withFooters(body, footer, paging, hint) {
|
|
@@ -233,7 +293,11 @@ export function emptyStateSuggestions(params) {
|
|
|
233
293
|
suggestions.push("Verify the speaker name with find_speakers");
|
|
234
294
|
}
|
|
235
295
|
else {
|
|
236
|
-
|
|
296
|
+
// A caller who named their own dates does not want to hear about `days` —
|
|
297
|
+
// suggesting it implies we ignored the dates they passed (#554).
|
|
298
|
+
if (params.explicitRange)
|
|
299
|
+
suggestions.push("Try an earlier start_date, or full_history=true with a topic + speaker filter for the whole archive");
|
|
300
|
+
else if (params.days && params.days < 30)
|
|
237
301
|
suggestions.push("Try a wider window: days=30");
|
|
238
302
|
else if (params.days && params.days < 90)
|
|
239
303
|
suggestions.push("Try a wider window: days=90");
|
|
@@ -250,8 +314,13 @@ export function emptyStateSuggestions(params) {
|
|
|
250
314
|
let msg = `No matching content for "${query}"`;
|
|
251
315
|
if (params.channel_name)
|
|
252
316
|
msg += ` on channel "${params.channel_name}"`;
|
|
317
|
+
// Say the window we actually searched. "in the last 30 days" printed under a
|
|
318
|
+
// query that passed start_date=2025-01-01 is a lie about the search, and the
|
|
319
|
+
// one that made a client re-run correct searches (#554).
|
|
253
320
|
if (params.fullHistory)
|
|
254
321
|
msg += ` in the full corpus history`;
|
|
322
|
+
else if (params.explicitRange && params.startDate)
|
|
323
|
+
msg += ` between ${params.startDate} and ${params.endDate ?? "today"}`;
|
|
255
324
|
else if (params.days)
|
|
256
325
|
msg += ` in the last ${params.days} days`;
|
|
257
326
|
msg += ".";
|
|
@@ -262,49 +331,63 @@ export function emptyStateSuggestions(params) {
|
|
|
262
331
|
// ---------------------------------------------------------------------------
|
|
263
332
|
// Quotes
|
|
264
333
|
// ---------------------------------------------------------------------------
|
|
334
|
+
/** Quote excerpt budget in the compact search view. */
|
|
335
|
+
export const SEARCH_QUOTE_BUDGET = 300;
|
|
336
|
+
/** Real total if the API gave one; null/undefined means "unknown" and is never printed as a count. */
|
|
337
|
+
export function realTotal(data) {
|
|
338
|
+
return typeof data.total_results === "number" ? data.total_results : null;
|
|
339
|
+
}
|
|
340
|
+
/** `Showing 10 of 249 · sim ≥0.35 · more: --offset 10` — honest about unknown totals. */
|
|
341
|
+
export function resultsHeader(opts) {
|
|
342
|
+
const more = opts.hasMore ?? (opts.total != null && opts.total > opts.offset + opts.consumed);
|
|
343
|
+
const parts = [`Showing ${opts.shown}${opts.total != null ? ` of ${opts.total}` : ""}${opts.noun ? ` ${opts.noun}` : ""}`];
|
|
344
|
+
if (opts.minSimilarity != null)
|
|
345
|
+
parts.push(`sim ≥${opts.minSimilarity}`);
|
|
346
|
+
if (more)
|
|
347
|
+
parts.push(surfaceHint(`more: offset:${opts.offset + opts.consumed}`));
|
|
348
|
+
return parts.join(" · ");
|
|
349
|
+
}
|
|
265
350
|
export function formatQuotes(data, footer = "", offset = 0) {
|
|
266
351
|
const quotes = dedupQuotes(data.quotes ?? []);
|
|
267
352
|
if (!quotes.length)
|
|
268
353
|
return "No quotes found matching your search criteria." + footer;
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
354
|
+
const lines = [resultsHeader({
|
|
355
|
+
shown: quotes.length, total: realTotal(data), offset, consumed: (data.quotes ?? []).length,
|
|
356
|
+
hasMore: data.has_more, minSimilarity: data.min_similarity,
|
|
357
|
+
}) + "\n"];
|
|
358
|
+
// Set when any quote renders a hedged name, so the footer explaining what
|
|
359
|
+
// "Unconfirmed — possibly X" means appears only where it is needed.
|
|
360
|
+
let anyHedged = false;
|
|
272
361
|
for (let i = 0; i < quotes.length; i++) {
|
|
273
362
|
const q = quotes[i];
|
|
274
363
|
const spk = q.speaker ?? {};
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
364
|
+
const attr = speakerAttribution(spk);
|
|
365
|
+
if (attr.hedged)
|
|
366
|
+
anyHedged = true;
|
|
278
367
|
const sim = q.similarity;
|
|
279
368
|
const date = fmtDate(q.episode_date ?? q.publish_date);
|
|
280
369
|
const segId = q.segment_id;
|
|
281
370
|
const tStart = q.timestamp_start ?? q.start_time;
|
|
282
|
-
const
|
|
283
|
-
const tokens = [spkToken, confToken(spk.confidence)];
|
|
371
|
+
const tokens = [...attr.tokens, confToken(spk.confidence)];
|
|
284
372
|
if (spk.appearance_type)
|
|
285
373
|
tokens.push(`appearance:${spk.appearance_type}`);
|
|
286
374
|
if (sim != null)
|
|
287
375
|
tokens.push(`sim:${sim.toFixed(2)}`);
|
|
288
376
|
tokens.push(`date:${date}`);
|
|
289
377
|
if (tStart != null)
|
|
290
|
-
tokens.push(
|
|
378
|
+
tokens.push(`@${fmtTime(tStart)}`);
|
|
291
379
|
const lt = langToken(q.language);
|
|
292
380
|
if (lt)
|
|
293
381
|
tokens.push(lt);
|
|
294
|
-
if (q.channel_id != null)
|
|
295
|
-
tokens.push(`channel_id:${q.channel_id}`);
|
|
296
382
|
tokens.push(`channel:${q.channel_name ?? "n/a"}`);
|
|
297
|
-
lines.push(`${i + 1}. **${
|
|
383
|
+
lines.push(`${i + 1}. **${attr.label}** — ${tokens.join(" ")}`);
|
|
298
384
|
lines.push(` seg:${segId ?? "n/a"} episode:${q.content_id ?? "n/a"}`);
|
|
299
|
-
const clip = clipUrl(q.content_id, tStart ?? undefined, tEnd ?? undefined, q.clip_url);
|
|
300
|
-
lines.push(` ${clipLine(q.content_type, clip)}`);
|
|
301
385
|
const pointer = segId != null ? `get_segment seg:${segId}` : undefined;
|
|
302
|
-
lines.push(` > ${truncateWithPointer(q.text, pointer)}\n`);
|
|
386
|
+
lines.push(` > ${truncateWithPointer(q.text, pointer, SEARCH_QUOTE_BUDGET)}\n`);
|
|
303
387
|
}
|
|
304
388
|
const first = quotes[0];
|
|
305
389
|
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
|
-
|
|
307
|
-
return withFooters(lines.join("\n"), footer, paging, hint);
|
|
390
|
+
return withFooters(lines.join("\n"), footer, "", hint) + (anyHedged ? ATTRIBUTION_NOTE : "");
|
|
308
391
|
}
|
|
309
392
|
// ---------------------------------------------------------------------------
|
|
310
393
|
// Claims
|
|
@@ -350,14 +433,23 @@ function claimSpeakerParts(speakers, n = 4) {
|
|
|
350
433
|
.map((s) => `${s.name} (speaker:${s.identity_id ?? "n/a"}, ${s.observation_count})`)
|
|
351
434
|
.join(", ");
|
|
352
435
|
}
|
|
436
|
+
/** Notice prepended when the backend reports the speaker_name filter did not
|
|
437
|
+
* resolve (speaker_resolved === false). Absent field → no notice. */
|
|
438
|
+
export function speakerResolutionNote(data) {
|
|
439
|
+
if (data.speaker_resolved !== false)
|
|
440
|
+
return "";
|
|
441
|
+
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";
|
|
442
|
+
}
|
|
353
443
|
export function formatClaims(data, footer = "", offset = 0) {
|
|
354
444
|
const claims = data.claims ?? [];
|
|
445
|
+
const note = speakerResolutionNote(data);
|
|
355
446
|
if (!claims.length)
|
|
356
|
-
return "No claims found matching your search criteria." + footer;
|
|
357
|
-
const total = data
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
447
|
+
return note + "No claims found matching your search criteria." + footer;
|
|
448
|
+
const total = realTotal(data);
|
|
449
|
+
const lines = [resultsHeader({
|
|
450
|
+
shown: claims.length, total, offset, consumed: claims.length,
|
|
451
|
+
hasMore: data.has_more, minSimilarity: data.min_similarity, noun: "claims",
|
|
452
|
+
}) + "\n"];
|
|
361
453
|
for (let i = 0; i < claims.length; i++) {
|
|
362
454
|
const c = claims[i];
|
|
363
455
|
lines.push(claimLine(c, i + 1));
|
|
@@ -376,19 +468,17 @@ export function formatClaims(data, footer = "", offset = 0) {
|
|
|
376
468
|
const obs = c.sample_observations ?? [];
|
|
377
469
|
if (obs.length) {
|
|
378
470
|
const o = obs[0];
|
|
379
|
-
const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
|
|
380
471
|
const label = [o.speaker_name, o.channel_name, fmtDate(o.publish_date)].filter(Boolean).join(", ");
|
|
381
472
|
const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
|
|
382
473
|
lines.push(` sample: "${truncateWithPointer(o.chunk_text, pointer)}" — ${label}`);
|
|
383
|
-
lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"}
|
|
474
|
+
lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"}`);
|
|
384
475
|
}
|
|
385
476
|
lines.push("");
|
|
386
477
|
}
|
|
387
478
|
if (anyNonEnglish(claims))
|
|
388
479
|
lines.push(NON_ENGLISH_NOTE, "");
|
|
389
|
-
const paging = pagingNote({ shown: claims.length, total, offset });
|
|
390
480
|
const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
|
|
391
|
-
return withFooters(lines.join("\n"), footer,
|
|
481
|
+
return withFooters(note + lines.join("\n"), footer, "", hint);
|
|
392
482
|
}
|
|
393
483
|
// ---------------------------------------------------------------------------
|
|
394
484
|
// Claims analysis (/api/v1/analysis/claims/*)
|
|
@@ -437,8 +527,9 @@ export function formatClaimsTopicAnalysis(data, topic, footer = "") {
|
|
|
437
527
|
return withFooters(lines.join("\n"), footer, "", hint);
|
|
438
528
|
}
|
|
439
529
|
export function formatClaimsTrend(data, topic, footer = "") {
|
|
530
|
+
const note = speakerResolutionNote(data);
|
|
440
531
|
if (data.direction === "no_data") {
|
|
441
|
-
return `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
|
|
532
|
+
return note + `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
|
|
442
533
|
}
|
|
443
534
|
const lines = [
|
|
444
535
|
`# Claim trend: "${topic}" — ${trendToken(data.direction, data.change_pct)}`,
|
|
@@ -449,7 +540,7 @@ export function formatClaimsTrend(data, topic, footer = "") {
|
|
|
449
540
|
`baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
|
|
450
541
|
];
|
|
451
542
|
const hint = nextCallHint(`search_claims topic:"${topic}"`, `get_timeline topic:"${topic}"`);
|
|
452
|
-
return withFooters(lines.join("\n"), footer, "", hint);
|
|
543
|
+
return withFooters(note + lines.join("\n"), footer, "", hint);
|
|
453
544
|
}
|
|
454
545
|
export function formatClaimsContradictions(data, speaker, topic, footer = "") {
|
|
455
546
|
if (data.verdict === "no_data") {
|
|
@@ -807,21 +898,28 @@ export function formatChannel(data) {
|
|
|
807
898
|
export function formatSegment(data) {
|
|
808
899
|
const segId = data.segment_id;
|
|
809
900
|
const spk = data.speaker ?? {};
|
|
810
|
-
|
|
811
|
-
|
|
901
|
+
// get_segment returns the primary speaker flat (speaker_name /
|
|
902
|
+
// canonical_speaker_id / attribution) and the per-speaker list separately;
|
|
903
|
+
// other producers of SegmentDetail nest it. Accept both, preferring the flat
|
|
904
|
+
// fields, which is the precedence formatSegment already had for the name.
|
|
905
|
+
const primary = {
|
|
906
|
+
...spk,
|
|
907
|
+
name: data.speaker_name ?? spk.name,
|
|
908
|
+
identity_id: spk.identity_id ?? data.canonical_speaker_id,
|
|
909
|
+
attribution: spk.attribution ?? data.attribution,
|
|
910
|
+
corroborated_by: spk.corroborated_by ?? data.corroborated_by,
|
|
911
|
+
};
|
|
912
|
+
const attr = speakerAttribution(primary);
|
|
913
|
+
const resolved = primary.identity_id != null;
|
|
812
914
|
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)");
|
|
915
|
+
const speakerTokens = [...attr.tokens];
|
|
818
916
|
if (spk.confidence !== undefined || resolved)
|
|
819
917
|
speakerTokens.push(confToken(spk.confidence));
|
|
820
918
|
if (spk.appearance_type)
|
|
821
919
|
speakerTokens.push(`appearance:${spk.appearance_type}`);
|
|
822
920
|
const lines = [
|
|
823
921
|
`**Segment ${segId ?? "n/a"}**`,
|
|
824
|
-
`- **Speaker:** ${
|
|
922
|
+
`- **Speaker:** ${attr.label}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
|
|
825
923
|
`- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
|
|
826
924
|
`- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
|
|
827
925
|
`- **Date:** date:${fmtDate(data.episode_date)}`,
|
|
@@ -831,7 +929,7 @@ export function formatSegment(data) {
|
|
|
831
929
|
lines.push(`- ${clipLine(data.content_type, clip)}`);
|
|
832
930
|
lines.push(`\n> ${data.text ?? ""}`);
|
|
833
931
|
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;
|
|
932
|
+
return lines.join("\n") + hint + (attr.hedged ? ATTRIBUTION_NOTE : "");
|
|
835
933
|
}
|
|
836
934
|
// ---------------------------------------------------------------------------
|
|
837
935
|
// Analysis
|
|
@@ -1417,20 +1515,84 @@ export function formatClaimDetail(detail, observations, related) {
|
|
|
1417
1515
|
// ---------------------------------------------------------------------------
|
|
1418
1516
|
// Timeline
|
|
1419
1517
|
// ---------------------------------------------------------------------------
|
|
1420
|
-
|
|
1518
|
+
export const TIMELINE_MAX_ROWS = 60;
|
|
1519
|
+
function isoWeekStart(d) {
|
|
1520
|
+
const t = new Date(d.slice(0, 10) + "T00:00:00Z");
|
|
1521
|
+
const dow = (t.getUTCDay() + 6) % 7; // Monday = 0
|
|
1522
|
+
t.setUTCDate(t.getUTCDate() - dow);
|
|
1523
|
+
return t.toISOString().slice(0, 10);
|
|
1524
|
+
}
|
|
1525
|
+
/** Aggregate day buckets to week, then month, until at most `max` rows remain. */
|
|
1526
|
+
export function aggregateBuckets(buckets, granularity, max = TIMELINE_MAX_ROWS) {
|
|
1527
|
+
let rows = buckets.map((b) => ({ start: b.start.slice(0, 10), count: b.count }));
|
|
1528
|
+
let g = granularity;
|
|
1529
|
+
const fold = (key) => {
|
|
1530
|
+
const m = new Map();
|
|
1531
|
+
for (const r of rows) {
|
|
1532
|
+
const k = key(r.start);
|
|
1533
|
+
m.set(k, (m.get(k) ?? 0) + r.count);
|
|
1534
|
+
}
|
|
1535
|
+
rows = [...m.entries()].map(([start, count]) => ({ start, count })).sort((a, b) => a.start.localeCompare(b.start));
|
|
1536
|
+
};
|
|
1537
|
+
if (rows.length > max && g === "day") {
|
|
1538
|
+
fold(isoWeekStart);
|
|
1539
|
+
g = "week";
|
|
1540
|
+
}
|
|
1541
|
+
// Only fine buckets fold. month/quarter/year come bucketed by the server on calendar
|
|
1542
|
+
// boundaries; re-keying them here would relabel a quarter as a month.
|
|
1543
|
+
if (rows.length > max && g === "week") {
|
|
1544
|
+
fold((d) => d.slice(0, 7));
|
|
1545
|
+
g = "month";
|
|
1546
|
+
}
|
|
1547
|
+
return { rows, granularity: g };
|
|
1548
|
+
}
|
|
1549
|
+
/** MCP timeline: one summary line, then `date<TAB>count` rows (zero runs collapsed, ≤60 rows). */
|
|
1421
1550
|
export function formatTimeline(data, footer = "") {
|
|
1422
|
-
const
|
|
1423
|
-
if (!
|
|
1551
|
+
const raw = data.buckets ?? [];
|
|
1552
|
+
if (!raw.length)
|
|
1424
1553
|
return "No data found for this topic in the given time range." + footer;
|
|
1554
|
+
const { rows: buckets, granularity } = aggregateBuckets(raw, data.granularity);
|
|
1555
|
+
const total = buckets.reduce((n, b) => n + b.count, 0);
|
|
1556
|
+
const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
|
|
1557
|
+
const mean = total / buckets.length;
|
|
1425
1558
|
const lines = [
|
|
1426
|
-
`**"${data.topic}"** — ${data.total} segments
|
|
1559
|
+
`**"${data.topic}"** — ${data.total ?? total} segments${data.episodes != null ? ` · ${data.episodes} episodes · ${data.channels ?? 0} channels` : ""} · peak ${peak.start} (${peak.count}) · mean ${mean.toFixed(1)}/${granularity} · by ${granularity}${granularity !== data.granularity ? ` (aggregated from ${data.granularity})` : ""} · semantic estimate at ≥${data.min_similarity ?? 0.35}\n`,
|
|
1427
1560
|
"date\tcount",
|
|
1428
1561
|
];
|
|
1562
|
+
let zeroRun = [];
|
|
1563
|
+
const flush = () => {
|
|
1564
|
+
if (zeroRun.length > 3)
|
|
1565
|
+
lines.push(`${zeroRun[0].start}..${zeroRun[zeroRun.length - 1].start}\t0 (${zeroRun.length} empty)`);
|
|
1566
|
+
else
|
|
1567
|
+
for (const z of zeroRun)
|
|
1568
|
+
lines.push(`${z.start}\t0`);
|
|
1569
|
+
zeroRun = [];
|
|
1570
|
+
};
|
|
1429
1571
|
for (const b of buckets) {
|
|
1572
|
+
if (b.count === 0) {
|
|
1573
|
+
zeroRun.push(b);
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
flush();
|
|
1430
1577
|
lines.push(`${b.start}\t${b.count}`);
|
|
1431
1578
|
}
|
|
1432
|
-
|
|
1579
|
+
flush();
|
|
1580
|
+
for (const note of data.notes ?? [])
|
|
1581
|
+
lines.push(`_Note: ${note}._`);
|
|
1433
1582
|
const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
|
|
1434
1583
|
return withFooters(lines.join("\n"), footer, "", hint);
|
|
1435
1584
|
}
|
|
1585
|
+
/** Token-light collection listing: header, `key | channels | description`, aliases. */
|
|
1586
|
+
export function formatCollections(data) {
|
|
1587
|
+
const cols = (data.collections ?? []).filter((c) => c.enabled !== false);
|
|
1588
|
+
const surface = getSurface();
|
|
1589
|
+
const flag = surface === "cli" ? "--collection" : "collection";
|
|
1590
|
+
const lines = [`${cols.length} collections · omit ${flag} to search ${data.default ?? "everything"}`];
|
|
1591
|
+
for (const c of cols)
|
|
1592
|
+
lines.push(`${c.key} | ${c.channels ?? "?"} channels | ${truncate(c.description ?? c.label ?? "", 90)}`);
|
|
1593
|
+
const al = Object.entries(data.aliases ?? {});
|
|
1594
|
+
if (al.length)
|
|
1595
|
+
lines.push(`aliases: ${al.map(([a, k]) => `${a}=${k}`).join(", ")}`);
|
|
1596
|
+
return lines.join("\n");
|
|
1597
|
+
}
|
|
1436
1598
|
//# sourceMappingURL=format.js.map
|