Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 89b8136e0ec9de9bfb911c82a111d43c21aecb02
parent ffc06c3ce5907daa1d97cfdebc2cb4bd96dd4b59
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  8 Jul 2026 20:46:32 -0400

Ask chat: hand a search's results to the AI as pinned grounding

Add an "Ask AI about these results" button to the search results that
opens /ask grounded in exactly the videos found, instead of the assistant
deciding its own search. Strict by default (answer only from the pinned
set, skipping gather via precomputedGrounding); a per-chat toggle lets the
assistant also search, seeding runAskTurn with the handed-off results.

- common/lib/aiHandoff.ts: SearchHandoff type + buildSearchHandoff (maps
  ResultGroup[] -> RetrievedVideo-shaped videos, capped, truncation flag).
- TranscriptSearch: the button -> sessionStorage handoff + router.push.
- useAskChat: pinned + strictGrounding state, one-shot handoff read on
  mount, persisted with the convo; send() branches strict/expand.
- searchAgent: seedVideos accumulator seed for expand; precomputedGrounding
  carries truncated.
- PinnedResultsPanel + AskChat: pinned grounding UI, strict/expand toggle,
  Clear, grounding-aware empty state.

Tests: 6 unit (aiHandoff), 3 e2e (ask-chat pinned strict/expand/clear),
1 e2e (query-tree handoff navigation). tsc + all suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 54++++++++++++++++++++++++++++++++++++++++++++++--------
Acommon/lib/aiHandoff.test.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/aiHandoff.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mexport/app/ask/AskChat.tsx | 52+++++++++++++++++++++++++++++++++++++++++++---------
Aexport/app/ask/PinnedResultsPanel.tsx | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/ask/useAskChat.ts | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mexport/app/lib/searchAgent.ts | 34+++++++++++++++++++++++++++-------
Mexport/e2e/ask-chat.spec.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/query-tree.spec.ts | 23+++++++++++++++++++++++
10 files changed, 690 insertions(+), 35 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { usePlayer } from "./PlayerProvider"; import { VirtualRow } from "./VirtualRow"; @@ -69,6 +70,7 @@ import { ymdToInput, inputToYmd } from "../lib/ymd"; import type { DisplaySummary, Platform } from "../lib/transcripts"; import { makeId, splitId } from "./originId"; import { sortGroups, type ChannelGroup } from "../lib/channelGroups"; +import { AI_HANDOFF_KEY, buildSearchHandoff } from "../lib/aiHandoff"; type Summary = DisplaySummary; @@ -1348,6 +1350,32 @@ export default function TranscriptSearch() { return m; }, [committedRoot]); + const router = useRouter(); + // Hand the current result set to the /ask chat as its grounding: serialize the + // matched videos + snippets, stash them for the chat to pick up, and navigate. + const askAboutResults = useCallback(() => { + const queries = Array.from(leavesById.values()) + .map((l) => l.query.trim()) + .filter(Boolean); + const bySlug = new Map(summaries.map((s) => [s.slug, s])); + const handoff = buildSearchHandoff( + resultGroups, + (slug) => { + const s = bySlug.get(slug); + return s ? { id: s.id, webpageUrl: s.webpageUrl } : undefined; + }, + queries, + { searchCapped: capped }, + ); + if (handoff.videos.length === 0) return; + try { + sessionStorage.setItem(AI_HANDOFF_KEY, JSON.stringify(handoff)); + } catch { + /* storage unavailable — the chat just won't receive the pin */ + } + router.push("/ask/"); + }, [leavesById, resultGroups, summaries, capped, router]); + const [resultsCopied, setResultsCopied] = useState(false); const resultsCopiedResetRef = useRef<number | null>(null); const copyResultsContext = useCallback(async () => { @@ -1848,14 +1876,24 @@ export default function TranscriptSearch() { </button> </span> {hasActiveQuery && resultGroups.length > 0 && ( - <button - type="button" - onClick={copyResultsContext} - title="Copy these results as context to paste into an AI chat" - className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground" - > - {resultsCopied ? "✓ Copied" : "Copy for AI"} - </button> + <span className="inline-flex items-center gap-2"> + <button + type="button" + onClick={copyResultsContext} + title="Copy these results as context to paste into an AI chat" + className="inline-flex items-center rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground" + > + {resultsCopied ? "✓ Copied" : "Copy for AI"} + </button> + <button + type="button" + onClick={askAboutResults} + title="Open the AI chat grounded in these search results" + className="inline-flex items-center rounded-md bg-primary px-2.5 py-1 text-xs font-normal text-primary-foreground hover:opacity-90" + > + Ask AI about these results + </button> + </span> )} </h2> diff --git a/common/lib/aiHandoff.test.ts b/common/lib/aiHandoff.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildSearchHandoff, type HandoffGroup } from "./aiHandoff"; +import type { LayerHit } from "../components/searchPipeline"; + +function hit(start: number, text: string): LayerHit { + return { leafId: "l1", scope: "transcripts", start, text }; +} + +function group(slug: string, hits: LayerHit[]): HandoffGroup { + return { slug, title: `Title ${slug}`, channel: "Chan", uploadDate: "20200101", hits }; +} + +const refs: Record<string, { id: string; webpageUrl?: string }> = { + "chan/aaa": { id: "aaa", webpageUrl: "https://x/aaa" }, + "chan/bbb": { id: "bbb", webpageUrl: "https://x/bbb" }, +}; +const lookup = (slug: string) => refs[slug]; + +test("buildSearchHandoff maps groups to videos with ids, urls, and clocks", () => { + const h = buildSearchHandoff( + [group("chan/aaa", [hit(65, "hello world"), hit(0, "meta")])], + lookup, + ["graham platner"], + ); + assert.equal(h.label, "graham platner"); + assert.equal(h.totalVideos, 1); + assert.equal(h.truncated, false); + assert.equal(h.videos.length, 1); + const v = h.videos[0]; + assert.equal(v.key, "chan/aaa"); + assert.equal(v.videoId, "aaa"); + assert.equal(v.url, "https://x/aaa"); + assert.deepEqual(v.snippets[0], { clock: "1:05", seconds: 65, text: "hello world" }); + // start 0 → "0:00" clock, whitespace collapsed. + assert.equal(v.snippets[1].clock, "0:00"); +}); + +test("buildSearchHandoff joins multiple queries with AND, falls back when empty", () => { + assert.equal( + buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, ["one", " two "]).label, + "one AND two", + ); + assert.equal( + buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, []).label, + "your search", + ); +}); + +test("buildSearchHandoff caps videos and hits, flagging truncation", () => { + const groups = Array.from({ length: 25 }, (_, i) => + group(`chan/${i}`, Array.from({ length: 10 }, (_, j) => hit(j, `h${j}`))), + ); + const h = buildSearchHandoff(groups, lookup, ["q"], { + maxVideos: 5, + maxHitsPerVideo: 3, + }); + assert.equal(h.videos.length, 5); + assert.equal(h.totalVideos, 25); + assert.equal(h.videos[0].snippets.length, 3); + assert.equal(h.truncated, true); // both video cap and per-video hit overflow +}); + +test("buildSearchHandoff marks truncated when the search itself was capped", () => { + const h = buildSearchHandoff([group("chan/aaa", [hit(1, "a")])], lookup, ["q"], { + searchCapped: true, + }); + assert.equal(h.truncated, true); +}); + +test("buildSearchHandoff falls back to slug when no summary ref is found", () => { + const h = buildSearchHandoff([group("chan/unknown", [hit(1, "a")])], lookup, ["q"]); + assert.equal(h.videos[0].videoId, "chan/unknown"); + assert.equal(h.videos[0].url, undefined); +}); + +test("buildSearchHandoff with no groups yields an empty, untruncated handoff", () => { + const h = buildSearchHandoff([], lookup, ["q"]); + assert.equal(h.videos.length, 0); + assert.equal(h.totalVideos, 0); + assert.equal(h.truncated, false); +}); diff --git a/common/lib/aiHandoff.ts b/common/lib/aiHandoff.ts @@ -0,0 +1,109 @@ +import type { LayerHit } from "../components/searchPipeline"; + +// Hand-off channel: a completed transcript search's results, serialized so the +// /ask chat can pick them up and answer *grounded in exactly those results* +// instead of running its own search. The search page writes a SearchHandoff to +// sessionStorage under this key and navigates to /ask; the chat reads it once on +// mount, pins it, and clears the key. +// +// This lives in `common` (shared by both search + chat) so it cannot import the +// export-only `RetrievedVideo`. HandoffVideo is deliberately shape-compatible +// with `RetrievedVideo`, so the chat treats `videos` as grounding with no adapter. +export const AI_HANDOFF_KEY = "ytdlp-tb:ai:handoff"; + +export type HandoffSnippet = { clock: string; seconds: number; text: string }; + +export type HandoffVideo = { + key: string; + videoId: string; + title: string; + channel: string; + siteTitle?: string; + uploadDate: string; + url?: string; + snippets: HandoffSnippet[]; +}; + +export type SearchHandoff = { + // Human-readable summary of the search that produced these (the joined leaf + // queries), shown in the pinned panel. + label: string; + videos: HandoffVideo[]; + // Total matching videos before capping — so the UI can say "showing first N". + totalVideos: number; + // Capped videos, per-video hit overflow, or the search itself hit its batch cap. + truncated: boolean; +}; + +// The minimal view of a search result group the builder needs. +export type HandoffGroup = { + slug: string; + title: string; + channel: string; + uploadDate: string; + hits: LayerHit[]; +}; + +// Per-slug metadata joined in from the summaries (video id + canonical URL, and +// the source site title in hub mode). +export type HandoffSummaryRef = { + id: string; + webpageUrl?: string; + siteTitle?: string; +}; + +function hms(s: number): string { + const n = Math.max(0, Math.floor(s)); + const h = Math.floor(n / 3600); + const m = Math.floor((n % 3600) / 60); + const ss = n % 60; + const mm = String(m).padStart(2, "0"); + const sss = String(ss).padStart(2, "0"); + return h > 0 ? `${h}:${mm}:${sss}` : `${m}:${sss}`; +} + +// Turn a completed search's result groups into a SearchHandoff. Bounded (default +// 20 videos, 8 snippets each) so the grounding stays a reasonable token size; +// `truncated` records whether anything was dropped. +export function buildSearchHandoff( + groups: HandoffGroup[], + lookup: (slug: string) => HandoffSummaryRef | undefined, + queries: string[], + opts: { + maxVideos?: number; + maxHitsPerVideo?: number; + searchCapped?: boolean; + } = {}, +): SearchHandoff { + const maxVideos = opts.maxVideos ?? 20; + const maxHits = opts.maxHitsPerVideo ?? 8; + const shown = groups.slice(0, maxVideos); + let hitOverflow = false; + + const videos: HandoffVideo[] = shown.map((g) => { + const ref = lookup(g.slug); + const hits = g.hits.slice(0, maxHits); + if (g.hits.length > hits.length) hitOverflow = true; + return { + key: g.slug, + videoId: ref?.id ?? g.slug, + title: g.title, + channel: g.channel, + siteTitle: ref?.siteTitle, + uploadDate: g.uploadDate, + url: ref?.webpageUrl, + snippets: hits.map((h) => ({ + clock: hms(h.start), + seconds: h.start, + text: h.text.trim().replace(/\s+/g, " "), + })), + }; + }); + + const cleaned = queries.map((q) => q.trim()).filter(Boolean); + const label = cleaned.length ? cleaned.join(" AND ") : "your search"; + const truncated = + groups.length > shown.length || hitOverflow || !!opts.searchCapped; + + return { label, videos, totalVideos: groups.length, truncated }; +} diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Hand a search's results straight to the "Ask AI" chat.** The search results header gains an **Ask AI about these results** button (next to *Copy for AI*) that opens the chat grounded in *exactly* the videos you found, instead of the assistant deciding its own search. By default it answers **only** from those results (fast and predictable); a per-chat toggle — *Answer only from these results* — lets the assistant also search the archive, using your results as a starting point. The pinned set is shown with the search that produced it, survives reloads, and can be detached (**Clear**) or replaced with a new hand-off. See `common/lib/aiHandoff.ts`, `common/components/TranscriptSearch.tsx`, `export/app/ask/{useAskChat.ts,PinnedResultsPanel.tsx,AskChat.tsx}`, `export/app/lib/searchAgent.ts`, and `export/e2e/{ask-chat,query-tree}.spec.ts`. + ## [0.7.5] - 2026-07-07 - **The "Ask AI" chat now uses your curated name aliases when it searches.** Asking about a term with a known alias (e.g. *Graham Platner*, which AI transcription often mangles to "Grand Platina") now applies the alias's regex, so misspelled mentions are found instead of missed. Previously the chat matched aliases one word at a time and a multi-word alias never fired; it now matches the whole search phrase, and each prompt explicitly tells the model to search the full aliased term (not a fragment like "Graham") and why. See `export/app/lib/askRetrieval.ts` (`buildSearchRoot`) and `export/app/lib/askConversation.ts` (`renderAliasGlossary`). - **The chat conversation is now saved, resumable, and editable.** Your conversation and the excerpts it gathered are kept in your browser, so a reload no longer loses them, and a *New chat* button clears them. If a request fails partway (e.g. a provider 503), the turn shows a **Retry** button that re-runs it **reusing the excerpts already found** — no re-searching. And a new **Context** panel lets you view and prune exactly what gets sent to the model (conversation + excerpts) as editable text, for a leaner, cheaper starting point — mid-conversation or as the seed for a fresh session. See `export/app/ask/{useAskChat.ts,ContextPanel.tsx,MessageBubble.tsx}`, `export/app/lib/{searchAgent,askConversation}.ts`, and `export/e2e/ask-chat.spec.ts`. diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx @@ -5,6 +5,7 @@ import { ArrowDownIcon, PlusIcon } from "lucide-react"; import { useAskChat } from "./useAskChat"; import { ProviderSettings } from "./ProviderSettings"; import { ContextPanel } from "./ContextPanel"; +import { PinnedResultsPanel } from "./PinnedResultsPanel"; import { MessageBubble } from "./MessageBubble"; import { Composer } from "./Composer"; @@ -49,6 +50,14 @@ export default function AskChat() { const channelCount = channels.length; const suggestions = useMemo(() => { + if (s.pinned) { + return [ + "Summarize these results.", + "Make a timeline from these results.", + "What are the key points across these?", + "What's the disagreement between them?", + ]; + } const base = [ "What are the main topics discussed?", "Summarize the most recent developments.", @@ -56,7 +65,7 @@ export default function AskChat() { ]; if (channels[0]) base.unshift(`What does ${channels[0].name} focus on?`); return base.slice(0, 4); - }, [channels]); + }, [channels, s.pinned]); return ( <div className="flex flex-col gap-5"> @@ -83,12 +92,14 @@ export default function AskChat() { </p> )} - {(messages.length > 0 || s.contextOverride) && ( + {(messages.length > 0 || s.contextOverride || s.pinned) && ( <div className="flex items-center justify-between"> <span className="text-xs text-muted-foreground/70"> {s.contextOverride ? "Continuing from an edited context." - : "Conversation is saved in this browser."} + : s.pinned + ? "Grounded in your search results." + : "Conversation is saved in this browser."} </span> <button type="button" @@ -101,6 +112,16 @@ export default function AskChat() { </div> )} + {s.pinned && ( + <PinnedResultsPanel + pinned={s.pinned} + strictGrounding={s.strictGrounding} + busy={busy} + onSetStrict={s.setStrictGrounding} + onClear={s.clearPinned} + /> + )} + {(messages.length > 0 || s.contextOverride) && ( <ContextPanel contextText={s.contextText} @@ -120,12 +141,25 @@ export default function AskChat() { {messages.length === 0 ? ( <div className="flex flex-col gap-3"> <p className="text-sm text-muted-foreground"> - Ask a question about the transcripts - {channelCount > 0 - ? ` (${channelCount} channel${channelCount === 1 ? "" : "s"} indexed)` - : ""} - . The assistant searches for what it needs — refining as it reads — - and answers with citations. + {s.pinned ? ( + <> + Ask about the {s.pinned.videos.length} result + {s.pinned.videos.length === 1 ? "" : "s"} you handed off from + search + {s.strictGrounding + ? " — the assistant answers only from them, with citations." + : " — the assistant starts from them and may search for more."} + </> + ) : ( + <> + Ask a question about the transcripts + {channelCount > 0 + ? ` (${channelCount} channel${channelCount === 1 ? "" : "s"} indexed)` + : ""} + . The assistant searches for what it needs — refining as it + reads — and answers with citations. + </> + )} </p> <div className="flex flex-wrap gap-2"> {suggestions.map((q) => ( diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDownIcon, PinIcon, XIcon } from "lucide-react"; +import type { SearchHandoff } from "yt-dlp-transcript-common/lib/aiHandoff"; + +// Shows the search results handed off from the search page as the chat's pinned +// grounding: what they are, whether the assistant may look beyond them (the +// strict/expand toggle), and a way to detach them. +export function PinnedResultsPanel({ + pinned, + strictGrounding, + busy, + onSetStrict, + onClear, +}: { + pinned: SearchHandoff; + strictGrounding: boolean; + busy: boolean; + onSetStrict: (on: boolean) => void; + onClear: () => void; +}) { + const [showList, setShowList] = useState(false); + const n = pinned.videos.length; + + return ( + <div className="flex flex-col gap-3 rounded-lg border border-brand/40 bg-brand-soft/40 px-4 py-3"> + <div className="flex items-start gap-2"> + <PinIcon className="mt-0.5 size-4 shrink-0 text-brand" /> + <div className="flex min-w-0 flex-col gap-0.5"> + <span className="text-sm font-medium text-foreground"> + Grounded in {n} result{n === 1 ? "" : "s"} from your search + </span> + <span className="truncate font-mono text-xs text-muted-foreground"> + {pinned.label} + {pinned.truncated ? ` · showing first ${n}` : ""} + </span> + </div> + <button + type="button" + onClick={onClear} + disabled={busy} + title="Detach these results and let the assistant search normally" + className="ml-auto inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50" + > + <XIcon className="size-3.5" /> Clear + </button> + </div> + + <label className="flex cursor-pointer items-start gap-2 text-xs text-muted-foreground"> + <input + type="checkbox" + checked={strictGrounding} + onChange={(e) => onSetStrict(e.target.checked)} + disabled={busy} + className="mt-0.5 accent-brand" + /> + <span> + <span className="font-medium text-foreground"> + Answer only from these results + </span> + <br /> + {strictGrounding + ? "The assistant won't run its own searches." + : "The assistant may also search the archive for more."} + </span> + </label> + + <div> + <button + type="button" + onClick={() => setShowList((v) => !v)} + className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground" + > + <ChevronDownIcon + className={`size-3.5 transition-transform ${showList ? "rotate-180" : ""}`} + /> + {showList ? "Hide" : "Show"} the {n} video{n === 1 ? "" : "s"} + </button> + {showList && ( + <ul className="mt-2 flex flex-col gap-1 border-t border-border pt-2"> + {pinned.videos.map((v) => ( + <li key={v.key} className="truncate text-xs text-muted-foreground"> + <span className="text-foreground">{v.title}</span> + {v.channel ? ` — ${v.channel}` : ""} + </li> + ))} + </ul> + )} + </div> + </div> + ); +} diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts @@ -2,10 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; +import { + AI_HANDOFF_KEY, + type SearchHandoff, +} from "yt-dlp-transcript-common/lib/aiHandoff"; import { PROVIDERS, type Provider } from "../lib/askProvider"; import { runAskTurn, type AgentEvent, type AgentMode } from "../lib/searchAgent"; +import type { RetrievedVideo } from "../lib/askRetrieval"; import { buildApiMessages, + buildGroundedContent, parseContext, serializeContext, type SearchStep, @@ -20,7 +26,14 @@ const K_CONVO = "ytdlp-tb:ai:conversation"; const keyFor = (p: Provider) => `ytdlp-tb:ai:key:${p}`; const modelFor = (p: Provider) => `ytdlp-tb:ai:model:${p}`; -type PersistedConvo = { messages: UiMessage[]; contextOverride: string | null }; +type PersistedConvo = { + messages: UiMessage[]; + contextOverride: string | null; + // A search's handed-off results, pinned as the chat's grounding. + pinned?: SearchHandoff | null; + // When pinned: answer only from those results (skip the AI's own search). + strictGrounding?: boolean; +}; // State + turn orchestration for the /ask chat. Owns the conversation (persisted // across reloads), the retrieval agent turns (gather → answer), a Retry path that @@ -42,6 +55,12 @@ export function useAskChat() { // A human-pruned context that seeds the conversation: prepended to the replayed // history on every turn. Set by applying an edit in the context panel. const [contextOverride, setContextOverride] = useState<string | null>(null); + // A search's results handed off from the search page, pinned as this chat's + // grounding. When set, questions answer over these instead of the AI searching. + const [pinned, setPinned] = useState<SearchHandoff | null>(null); + // Pinned mode only: answer strictly from the pinned results (skip gather) vs. + // use them as a starting point the AI may expand with its own searches. + const [strictGrounding, setStrictGroundingState] = useState(true); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const abortRef = useRef<AbortController | null>(null); @@ -52,6 +71,10 @@ export function useAskChat() { messagesRef.current = messages; const contextOverrideRef = useRef(contextOverride); contextOverrideRef.current = contextOverride; + const pinnedRef = useRef(pinned); + pinnedRef.current = pinned; + const strictRef = useRef(strictGrounding); + strictRef.current = strictGrounding; // Restore saved preferences + (if remembered) the provider's key/model, plus a // persisted conversation. @@ -72,12 +95,37 @@ export function useAskChat() { } setMarkdownOnState(localStorage.getItem(K_MARKDOWN) !== "0"); loadProviderCreds(p, rememberSaved); - const rawConvo = localStorage.getItem(K_CONVO); - if (rawConvo) { - const parsed = JSON.parse(rawConvo) as PersistedConvo; - if (Array.isArray(parsed.messages)) setMessages(parsed.messages); - if (typeof parsed.contextOverride === "string") { - setContextOverride(parsed.contextOverride); + // A search hand-off (sessionStorage, one-shot) takes precedence: start a + // fresh chat pinned to those results. + let handoff: SearchHandoff | null = null; + try { + const rawHandoff = sessionStorage.getItem(AI_HANDOFF_KEY); + if (rawHandoff) { + handoff = JSON.parse(rawHandoff) as SearchHandoff; + sessionStorage.removeItem(AI_HANDOFF_KEY); + } + } catch { + /* no / corrupt handoff */ + } + if (handoff && Array.isArray(handoff.videos) && handoff.videos.length) { + setPinned(handoff); + setStrictGroundingState(true); + setMessages([]); + setContextOverride(null); + } else { + const rawConvo = localStorage.getItem(K_CONVO); + if (rawConvo) { + const parsed = JSON.parse(rawConvo) as PersistedConvo; + if (Array.isArray(parsed.messages)) setMessages(parsed.messages); + if (typeof parsed.contextOverride === "string") { + setContextOverride(parsed.contextOverride); + } + if (parsed.pinned && Array.isArray(parsed.pinned.videos)) { + setPinned(parsed.pinned); + } + if (typeof parsed.strictGrounding === "boolean") { + setStrictGroundingState(parsed.strictGrounding); + } } } } catch { @@ -93,10 +141,15 @@ export function useAskChat() { if (persistTimer.current) clearTimeout(persistTimer.current); persistTimer.current = setTimeout(() => { try { - if (messages.length === 0 && !contextOverride) { + if (messages.length === 0 && !contextOverride && !pinned) { localStorage.removeItem(K_CONVO); } else { - const payload: PersistedConvo = { messages, contextOverride }; + const payload: PersistedConvo = { + messages, + contextOverride, + pinned, + strictGrounding, + }; localStorage.setItem(K_CONVO, JSON.stringify(payload)); } } catch { @@ -106,7 +159,7 @@ export function useAskChat() { return () => { if (persistTimer.current) clearTimeout(persistTimer.current); }; - }, [messages, contextOverride]); + }, [messages, contextOverride, pinned, strictGrounding]); function loadProviderCreds(p: Provider, rememberOn: boolean) { if (rememberOn) { @@ -181,7 +234,9 @@ export function useAskChat() { precomputedGrounding?: { videos: UiMessage["sources"]; groundedContent: string; + truncated?: boolean; }; + seedVideos?: RetrievedVideo[]; }) => { const { question, prior, userIndex, assistantIndex } = args; setBusy(true); @@ -251,10 +306,12 @@ export function useAskChat() { signal: ac.signal, onEvent, historyOverride, + seedVideos: args.seedVideos, precomputedGrounding: args.precomputedGrounding?.groundedContent ? { videos: args.precomputedGrounding.videos ?? [], groundedContent: args.precomputedGrounding.groundedContent, + truncated: args.precomputedGrounding.truncated, } : undefined, }); @@ -301,13 +358,42 @@ export function useAskChat() { const prior = messagesRef.current; const userIndex = prior.length; const assistantIndex = prior.length + 1; + + // Pinned search results ground the turn. Strict → every question answers + // only from them (skip gather entirely). Expand → seed the AI's search with + // them on the first turn, then let it search freely (the pin stays in history). + const pin = pinnedRef.current; + let precomputedGrounding: + | { videos: RetrievedVideo[]; groundedContent: string; truncated?: boolean } + | undefined; + let seedVideos: RetrievedVideo[] | undefined; + if (pin && pin.videos.length) { + const videos = pin.videos as RetrievedVideo[]; + if (strictRef.current) { + precomputedGrounding = { + videos, + groundedContent: buildGroundedContent(question, videos), + truncated: pin.truncated, + }; + } else if (prior.length === 0) { + seedVideos = videos; + } + } + setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: question }, { role: "assistant", content: "", phase: "gathering", searchSteps: [] }, ]); - await runTurn({ question, prior, userIndex, assistantIndex }); + await runTurn({ + question, + prior, + userIndex, + assistantIndex, + precomputedGrounding, + seedVideos, + }); }, [input, busy, apiKey, summariesReady, provider, model, remember, runTurn]); // Re-run a failed assistant turn. Reuses the grounding captured before the @@ -349,8 +435,19 @@ export function useAskChat() { if (busy) return; setMessages([]); setContextOverride(null); + setPinned(null); + setStrictGroundingState(true); }; + // Detach the pinned search results → back to a normal AI-decides-search chat. + const clearPinned = () => { + if (busy) return; + setPinned(null); + setStrictGroundingState(true); + }; + + const setStrictGrounding = (on: boolean) => setStrictGroundingState(on); + // The effective context that will be sent next turn, serialized for the panel: // the override (if any) prepended to the replayed conversation. const contextText = useMemo(() => { @@ -407,6 +504,11 @@ export function useAskChat() { contextOverride, applyContext, clearContextOverride, + // pinned search results + pinned, + strictGrounding, + setStrictGrounding, + clearPinned, }; } diff --git a/export/app/lib/searchAgent.ts b/export/app/lib/searchAgent.ts @@ -165,10 +165,20 @@ export type RunAskTurnOptions = { budget?: number; signal?: AbortSignal; onEvent: (e: AgentEvent) => void; - // Retry path: reuse a previous attempt's grounding and SKIP the whole gather - // phase — only (re)stream the answer. Set when retrying a turn whose gather - // succeeded but whose answer stream failed (e.g. a 503 mid-answer). - precomputedGrounding?: { videos: RetrievedVideo[]; groundedContent: string }; + // Retry path AND pinned-strict grounding: reuse pre-supplied grounding and SKIP + // the whole gather phase — only (re)stream the answer. Set when retrying a turn + // whose gather succeeded but whose answer stream failed (e.g. a 503 mid-answer), + // or when answering strictly from a search's handed-off results. `truncated` + // carries through when the supplied set was capped. + precomputedGrounding?: { + videos: RetrievedVideo[]; + groundedContent: string; + truncated?: boolean; + }; + // Pinned-expand grounding: seed the gathered-videos set with these before the + // model runs its own searches, so the final grounding is the handed-off results + // merged with whatever the model finds. + seedVideos?: RetrievedVideo[]; // Human-edited context: replaces the history reconstructed from `prior` (used // when the user has pruned the context in the context panel). historyOverride?: ChatMessage[]; @@ -214,7 +224,12 @@ export async function runAskTurn( signal, onDelta: (chunk) => onEvent({ type: "delta", text: chunk }), }); - return { answer, videos: pv, groundedContent, truncated: false }; + return { + answer, + videos: pv, + groundedContent, + truncated: opts.precomputedGrounding.truncated ?? false, + }; } const hasPriorGrounding = prior.some( @@ -222,6 +237,10 @@ export async function runAskTurn( ); const videos = new Map<string, RetrievedVideo>(); + // Pinned-expand: start from the handed-off results, then let the model add to + // them. Deduped by key; still capped below at MAX_CONTEXT_VIDEOS. + const seedVideos = opts.seedVideos ?? []; + for (const v of seedVideos) videos.set(v.key, v); const queries: string[] = []; let truncated = false; @@ -285,8 +304,9 @@ export async function runAskTurn( } // Safety net: never answer a fresh question with zero grounding just because a - // model ignored the protocol / declined to search. - if (queries.length === 0 && !hasPriorGrounding) { + // model ignored the protocol / declined to search. Seeded results already count + // as grounding, so don't force a search when we started from a handed-off set. + if (queries.length === 0 && !hasPriorGrounding && seedVideos.length === 0) { await runSearch(question); } diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts @@ -231,6 +231,157 @@ test.describe("ask chat", () => { await expect(page.getByText("point one", { exact: false }).first()).toBeVisible(); }); + // A search hand-off: the payload the search page writes to sessionStorage. + const HANDOFF = { + label: "graham platner", + videos: [ + { + key: "v1", + videoId: "v1", + title: "Alpha talk", + channel: "Chan", + uploadDate: "20200101", + url: "https://x/v1", + snippets: [{ clock: "0:05", seconds: 5, text: "alpha excerpt one" }], + }, + { + key: "v2", + videoId: "v2", + title: "Beta talk", + channel: "Chan", + uploadDate: "20200102", + snippets: [{ clock: "1:00", seconds: 60, text: "beta excerpt two" }], + }, + ], + totalVideos: 2, + truncated: false, + }; + + async function seedHandoff(page: Page) { + // Runs before any page script, so the mount effect finds the pin. + await page.addInitScript((h) => { + sessionStorage.setItem("ytdlp-tb:ai:handoff", JSON.stringify(h)); + }, HANDOFF); + } + + async function keyIn(page: Page) { + await page.getByRole("button", { name: "Scripted" }).click(); + await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test"); + } + + test("pinned strict grounding answers from the handed-off results, no search", async ({ + page, + }) => { + await installRoutes(page); + let gatherCalls = 0; + let answerSawExcerpts = false; + await page.route("https://api.anthropic.com/**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: CORS }); + return; + } + const body = route.request().postDataJSON() as { + system?: string; + messages?: { role: string; content: unknown }[]; + }; + const system = body.system ?? ""; + if (system.includes("Markdown")) { + answerSawExcerpts = JSON.stringify(body.messages ?? []).includes( + "alpha excerpt one", + ); + await route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "text/event-stream" }, + body: sse("Grounded answer [1]"), + }); + return; + } + // Any non-answer call would be the gather phase — it must NOT happen. + gatherCalls += 1; + await route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "text/event-stream" }, + body: sse("SEARCH: alpha"), + }); + }); + await seedHandoff(page); + await page.goto("/ask/"); + await keyIn(page); + + await expect(page.getByText(/Grounded in 2 results/)).toBeVisible(); + await ask(page, "summarize these"); + await expect(page.getByText(/Grounded answer/)).toBeVisible(); + expect(gatherCalls).toBe(0); + expect(answerSawExcerpts).toBe(true); + }); + + test("expanding the pin lets the assistant also search the archive", async ({ + page, + }) => { + await installRoutes(page); + let gatherCalls = 0; + await page.route("https://api.anthropic.com/**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: CORS }); + return; + } + const body = route.request().postDataJSON() as { + system?: string; + messages?: { role: string; content: unknown }[]; + }; + const system = body.system ?? ""; + const msgs = body.messages ?? []; + const lastUser = [...msgs].reverse().find((m) => m.role === "user"); + const lastText = + typeof lastUser?.content === "string" ? lastUser.content : ""; + if (system.includes("Markdown")) { + await route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "text/event-stream" }, + body: sse("Expanded answer [1]"), + }); + return; + } + gatherCalls += 1; + const text = lastText.includes('Results for "') ? "DONE" : "SEARCH: alpha"; + await route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "text/event-stream" }, + body: sse(text), + }); + }); + await seedHandoff(page); + await page.goto("/ask/"); + await keyIn(page); + + await expect(page.getByText(/Grounded in 2 results/)).toBeVisible(); + // Turn strict OFF → the assistant may search. + await page.getByLabel("Answer only from these results").uncheck(); + await ask(page, "what else is there"); + await expect(page.getByText(/Expanded answer/)).toBeVisible(); + // A gather search ran, and the seeded video is among the citations. + expect(gatherCalls).toBeGreaterThan(0); + await expect(page.getByText("Alpha talk", { exact: false }).first()).toBeVisible(); + }); + + test("clearing the pin returns to a normal AI-decides-search chat", async ({ + page, + }) => { + await installRoutes(page); + await mockAnthropic(page); + await seedHandoff(page); + await page.goto("/ask/"); + await keyIn(page); + + await expect(page.getByText(/Grounded in 2 results/)).toBeVisible(); + await page.getByRole("button", { name: "Clear" }).click(); + await expect(page.getByText(/Grounded in 2 results/)).toHaveCount(0); + // Back to the normal empty-state copy. + await expect( + page.getByText(/The assistant searches for what it needs/), + ).toBeVisible(); + }); + test("editing the context changes what the next turn sends", async ({ page, }) => { diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts @@ -484,6 +484,29 @@ test.describe("composite search — query tree", () => { ).toHaveCount(2, { timeout: 15_000 }); }); + test("'Ask AI about these results' hands the search off to /ask, pinned", async ({ + page, + }) => { + const tree: SGroup = { + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }; + await page.goto(`/?qt=${qt(tree)}`); + await expectResultSlugs(page, [ + TRANSCRIPT_ONLY_SLUG, + CHAT_SMALL_SLUG, + CHAT_LARGE_SLUG, + ]); + await page + .getByRole("button", { name: "Ask AI about these results" }) + .click(); + // Landed on /ask with the three results pinned as grounding. + await expect(page).toHaveURL(/\/ask\//); + await expect(page.getByText(/Grounded in 3 results/)).toBeVisible(); + await expect(page.getByText(/alpha/).first()).toBeVisible(); + }); + test("builder UI: + Add layer adds a second leaf and commits on Search", async ({ page, }) => {