app/lib/search/ask.server.ts
363 lines and 45 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| askAvailable | 38 to 40 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askStream | 43 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| teeForCache | 56 to 62 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| parseSseAnswer | 65 to 94 | 9 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| replayCachedAnswer | 96 to 107 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| start | 102 to 105 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listAllAskItems | 118 to 158 | 16 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPage | 123 to 124 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:124> | 124 to 124 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:136> | 136 to 136 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publishableForAsk | 161 to 168 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:165> | 165 to 166 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askPublishable | 170 to 172 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askItemFor | 179 to 187 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| deleteAskItemsWhere | 190 to 198 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| syncAskCorpus | 201 to 226 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| paperItemKeys | 229 to 231 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:230> | 230 to 230 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| uploadPaperTwins | 234 to 246 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:236> | 236 to 236 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:237> | 237 to 237 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| fetchText | 239 to 243 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| upload | 244 to 244 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| syncAskPost | 249 to 278 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:264> | 264 to 264 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:273> | 273 to 273 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askIndexStatus | 288 to 302 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:297> | 297 to 297 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:298> | 298 to 298 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askStatusReader | 310 to 324 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:312> | 312 to 323 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:313> | 313 to 321 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askDriftCount | 330 to 370 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:337> | 337 to 337 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:340> | 340 to 343 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:345> | 345 to 347 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:346> | 346 to 346 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:355> | 355 to 356 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:358> | 358 to 359 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| removeAskPost | 375 to 380 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| pruneAskCorpus | 383 to 389 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:385> | 385 to 385 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| syncAskPaper | 395 to 412 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:404> | 404 to 404 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:407> | 407 to 407 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/db/publications.ts, app/lib/error-message.mjs, app/lib/health/verdicts.mjs, app/lib/publications/paths.mjs, app/lib/search/ask-guard.server.ts, app/lib/search/ask-keys.mjs, app/lib/search/ask-prompt.mjs, app/lib/search/ask-twins.mjs, app/lib/search/records.mjs, app/lib/search/search.server.ts, app/lib/search/sse.mjs, app/lib/search/visibility.mjs, app/lib/timing.ts. Imported by app/lib/admin/posts-actions.server.ts, app/lib/editor/publish.server.ts, app/lib/health/checks.server.ts, app/lib/operator/sync-tools.server.ts, app/lib/publications/save.server.ts, app/routes/admin._index.tsx, app/routes/admin.posts._index.tsx, app/routes/admin.tsx, app/routes/search.ask.ts, app/routes/search.tsx.
Source
// With AI_SEARCH unbound no Ask affordance renders and /search is pure D1: that off switch is a requirement.
import { createContext } from "react-router";
import {
dropCachedDrift,
invalidateAnswerCache,
readCachedDrift,
writeCachedDrift,
} from "./ask-guard.server";
import { KEY_SEPARATOR, keyForUrl, ownsAskKey } from "./ask-keys.mjs";
import { uploadTwins, withRetry } from "./ask-twins.mjs";
import { answerDelta, takeSseFrames } from "./sse.mjs";
import { setDrift } from "~/lib/health/verdicts.mjs";
import { isPubliclyVisible, statusForDraft } from "./visibility.mjs";
import { askCorpusRecords, askExpectedUrls } from "./search.server";
import { listPublishedPublicationTwins } from "~/db/publications";
import { paperPath } from "~/lib/publications/paths.mjs";
import { timed, type Timings } from "~/lib/timing";
import { recordsForPosts } from "./records.mjs";
import { errorMessage } from "~/lib/error-message.mjs";
import {
answerLeaksPrompt,
askMessages,
citedSlugs,
guardAnswerStream,
replayFrames,
} from "./ask-prompt.mjs";
export { answerLeaksPrompt, citedSlugs, guardAnswerStream };
const ASK_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
/** Bounds the answer, not the corpus: past it, retrieval widens the net without widening the answer. */
const MAX_CHUNKS = 6;
/** A property check, not a try/catch: only a removed binding, not an erroring instance, should hide Ask. */
export function askAvailable(env: Env): boolean {
return Boolean(env.AI_SEARCH);
}
/** The raw upstream SSE is handed through untouched: the client already parses SSE. */
export async function askStream(env: Env, question: string): Promise<ReadableStream> {
return env.AI_SEARCH.chatCompletions({
// The last message is the retrieval query; decorating it costs the whole search.
messages: askMessages(question),
model: ASK_MODEL,
stream: true,
ai_search_options: {
max_num_results: MAX_CHUNKS,
},
});
}
/** The cache copy is parsed inside waitUntil, so caching costs time-to-first-token nothing. */
export function teeForCache(upstream: ReadableStream): {
toReader: ReadableStream;
captured: Promise<{ answer: string; chunks: unknown[] } | null>;
} {
const [toReader, toCache] = upstream.tee();
return { toReader, captured: parseSseAnswer(toCache) };
}
/** Same frame handling as the client, or a cached replay would not match what the reader first saw. */
async function parseSseAnswer(
stream: ReadableStream,
): Promise<{ answer: string; chunks: unknown[] } | null> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
let answer = "";
let chunks: unknown[] = [];
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const taken = takeSseFrames(buffer);
buffer = taken.rest;
for (const { event, data } of taken.frames) {
if (event === "chunks") {
if (Array.isArray(data)) chunks = data;
continue;
}
answer += answerDelta(data) ?? "";
}
}
} catch {
return null;
}
return answer.trim().length > 0 ? { answer, chunks } : null;
}
export function replayCachedAnswer(cached: {
answer: string;
chunks: unknown[];
}): ReadableStream {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const frame of replayFrames(cached)) controller.enqueue(encoder.encode(frame));
controller.close();
},
});
}
interface CorpusSyncResult {
uploaded: number;
keys: string[];
/** Non-empty means the sync did NOT converge. */
failed: Array<{ key: string; error: string }>;
cacheDropped: number;
}
/** items.list() is paged: a bare call returns only the first page, and a prune cannot delete what it cannot see. */
async function listAllAskItems(env: Env, timings?: Timings) {
const all: Awaited<ReturnType<typeof env.AI_SEARCH.items.list>>["result"] = [];
// 50 is the API maximum.
const perPage = 50;
// One mark per page, so round trips are counted rather than inferred from the index size.
const listPage = (page: number) =>
timed(timings, "ask_list_page", () => env.AI_SEARCH.items.list({ page, per_page: perPage }));
// The first page says how many items there are, so the rest are fetched together instead of one round trip after
// another. The loop below still runs after them: it ends at once when they were all of it, and carries on page by
// page when the count was wrong, so a listing is never taken for complete on the count alone.
let page = 1;
const first = await listPage(1);
all.push(...(first.result ?? []));
const total = first.result_info?.total_count;
let last = first;
if (typeof total === "number" && (first.result ?? []).length === perPage && total > perPage) {
const lastPage = Math.ceil(total / perPage);
const rest = await Promise.all(Array.from({ length: lastPage - 1 }, (_, i) => listPage(i + 2)));
for (const listed of rest) all.push(...(listed.result ?? []));
page = lastPage;
last = rest[rest.length - 1] ?? first;
}
for (;;) {
const batch = last.result ?? [];
if (batch.length < perPage) break;
if (typeof total === "number" && all.length >= total) break;
// Backstop against a server that never shrinks a page. Thrown, not a break: a truncated listing
// would make every unlisted item look absent to the prune and the drift count.
if (page > 200) {
throw new Error(
`AI Search listing did not end after ${page} pages of ${perPage} (${all.length} items); ` +
`refusing to act on a listing that may be truncated.`,
);
}
page += 1;
last = await listPage(page);
all.push(...(last.result ?? []));
}
return all;
}
/** AI Search has no per-item status to filter on, so nothing unpublished may be uploaded at all. */
function publishableForAsk<T extends { draft?: boolean; publishAt?: string | null }>(
posts: readonly T[],
): T[] {
const now = Date.now();
return posts.filter((p) =>
isPubliclyVisible({ status: statusForDraft(p.draft), publishAt: p.publishAt }, now),
);
}
function askPublishable(post: { draft?: boolean; publishAt?: string | null }): boolean {
return publishableForAsk([post]).length === 1;
}
/**
* One search record as an Ask item. Fails closed: a url containing the separator would produce a
* key that resolves to the wrong URL. The heading is in the text: the body alone loses what the
* section is about.
*/
function askItemFor(record: Record<string, any>) {
if (record.url.includes(KEY_SEPARATOR)) {
throw new Error(
`record url contains the key separator "${KEY_SEPARATOR}" and cannot be ` +
`uploaded safely: ${record.url}`,
);
}
return { key: keyForUrl(record.url), body: `# ${record.title}\n\n${record.body}\n` };
}
/** Lists every item, deletes the ones whose key matches, and returns their keys. */
async function deleteAskItemsWhere(env: Env, matches: (key: string) => boolean) {
const removed: string[] = [];
for (const item of await listAllAskItems(env)) {
if (!matches(item.key)) continue;
await env.AI_SEARCH.items.delete(item.id);
removed.push(item.key);
}
return removed;
}
/** Uploaded rather than crawled: the crawler would index the apex (the legacy site) and lose heading granularity. */
export async function syncAskCorpus(env: Env): Promise<CorpusSyncResult> {
// From D1 with visibilityClause composed in the SQL, so drafts and future posts never enter the index.
const records = await askCorpusRecords(env);
const keys: string[] = [];
for (const record of records) {
const { key, body } = askItemFor(record);
await env.AI_SEARCH.items.upload(key, body);
keys.push(key);
}
// Papers from their twins, not search_docs: uploading both would let the shorter win questions the longer answers.
const twins = await uploadPaperTwins(env);
keys.push(...twins.keys);
// The corpus changed, so cached answers may now be untrue.
const dropped = await invalidateAnswerCache(env);
await dropCachedDrift(env);
return {
uploaded: keys.length - twins.failed.length,
keys,
failed: twins.failed,
cacheDropped: dropped,
};
}
/** The published papers' Ask keys, from D1: a draft paper is not public, so it is not asked about. */
async function paperItemKeys(env: Env): Promise<string[]> {
return (await listPublishedPublicationTwins(env)).map(({ slug }) => keyForUrl(paperPath(slug)));
}
/** Each published twin from D1, the row the twin route serves, so this indexes exactly what the site says. */
async function uploadPaperTwins(env: Env) {
const rows = await listPublishedPublicationTwins(env);
const bySlug = new Map(rows.map((row) => [row.slug, row.markdown]));
const twins = rows.map(({ slug }) => ({ key: keyForUrl(paperPath(slug)), path: slug }));
return uploadTwins(twins, {
fetchText: async (slug: string) => {
const markdown = bySlug.get(slug);
if (markdown === undefined) throw new Error(`twin ${slug} has no published row`);
return markdown;
},
upload: (key: string, body: string) => env.AI_SEARCH.items.upload(key, body),
});
}
/** Scoped to this post's keys, which is sound because sections are a pure function of one post's markdown. */
export async function syncAskPost(
env: Env,
post: Parameters<typeof recordsForPosts>[0][number],
): Promise<{ uploaded: number; removed: number; failed: string[] }> {
// A draft uploads nothing, and the empty live set makes the prune below remove what it had.
const records = askPublishable(post) ? recordsForPosts([post]) : [];
const live = new Set<string>();
// A failed key still joins live: the prune deletes every key not in it, which would delete the indexed copy.
const failed: string[] = [];
for (const record of records) {
// Outside the retry: a bad url would produce the same wrong key every time.
const { key, body } = askItemFor(record);
try {
await withRetry(() => env.AI_SEARCH.items.upload(key, body), { attempts: 2 });
} catch (error) {
console.error(JSON.stringify({ alert: "ask-upload-failed-twice", key, detail: errorMessage(error) }));
failed.push(key);
}
live.add(key);
}
const ours = ownsAskKey(post.slug);
const removed = await deleteAskItemsWhere(env, (key) => ours(key) && !live.has(key));
await invalidateAnswerCache(env);
await dropCachedDrift(env);
return { uploaded: records.length - failed.length, removed: removed.length, failed };
}
export interface AskIndexStatus {
expected: number;
present: number;
missing: string[];
stale: string[];
}
/** Both directions: an item the corpus does not know is as much a defect as a missing one. */
export async function askIndexStatus(env: Env, timings?: Timings): Promise<AskIndexStatus> {
// publishableForAsk must stay in step with the SQL predicate here; check:ask-guards binds the two.
// The papers are read with the rest, not after it: without them the badge would report them stale and the repair
// button would delete them, and a read that waited for the listing was a round trip of pure waiting.
const [expectedUrls, listed, paperKeys] = await Promise.all([
askExpectedUrls(env),
listAllAskItems(env, timings),
paperItemKeys(env),
]);
const expected = new Set([...expectedUrls.map((u) => keyForUrl(u)), ...paperKeys]);
const present = new Set(listed.map((item) => item.key));
const { extra, ...drift } = setDrift(expected, present);
return { ...drift, stale: extra };
}
/** On the context because a parent cannot read a child's loader data; a getter, so a route that never asks never pays. */
type AskStatusReader = () => Promise<AskIndexStatus | null>;
export const askStatusContext = createContext<AskStatusReader>();
/** Null rather than throwing: an enhancement must not take an admin page down. */
export function askStatusReader(env: Env, timings?: Timings): AskStatusReader {
let pending: Promise<AskIndexStatus | null> | undefined;
return () => {
pending ??= (async () => {
if (!askAvailable(env)) return null;
try {
return await askIndexStatus(env, timings);
} catch (error) {
console.error(JSON.stringify({ alert: "ask-index-status-failed", detail: errorMessage(error) }));
return null;
}
})();
return pending;
};
}
/**
* On a hit nothing touches AI Search: keep the early return before any index use. Null, not zero, on
* failure. A late listing is handed to waitUntil, since a floating write never lands after the response.
*/
export async function askDriftCount(
env: Env,
ctx: ExecutionContext,
timings?: Timings,
): Promise<number | null> {
if (!askAvailable(env)) return null;
const cached = await timed(timings, "drift_cache_read", () => readCachedDrift(env));
if (cached !== null) return cached;
const listing = askIndexStatus(env, timings).catch((error) => {
console.error(JSON.stringify({ alert: "ask-drift-count-failed", detail: errorMessage(error) }));
return null;
});
const budget = new Promise<undefined>((resolve) => {
setTimeout(() => resolve(undefined), DRIFT_BUDGET_MS);
});
const status = await Promise.race([listing, budget]);
if (status === undefined) {
// The catch stays: a rejecting waitUntil is no better than a floating rejection.
ctx.waitUntil(
listing
.then((late) =>
late ? writeCachedDrift(env, late.missing.length + late.stale.length) : null,
)
.catch((error) =>
console.error(JSON.stringify({ alert: "ask-drift-cache-write-failed", detail: errorMessage(error) })),
),
);
return null;
}
if (!status) return null;
const drift = status.missing.length + status.stale.length;
await writeCachedDrift(env, drift);
return drift;
}
/** Measured: admits all but the one pathological listing this budget exists for. */
const DRIFT_BUDGET_MS = 1000;
export async function removeAskPost(env: Env, slug: string): Promise<number> {
const removed = await deleteAskItemsWhere(env, ownsAskKey(slug));
await invalidateAnswerCache(env);
await dropCachedDrift(env);
return removed.length;
}
/** Upload is an upsert, so a renamed or deleted post leaves its old item answering forever. */
export async function pruneAskCorpus(env: Env, liveKeys: string[]): Promise<string[]> {
const live = new Set(liveKeys);
const removed = await deleteAskItemsWhere(env, (key) => !live.has(key));
// Only on an actual removal; dropping the key otherwise would cost the next reader a listing.
if (removed.length > 0) await dropCachedDrift(env);
return removed;
}
/**
* One paper's twin in or out of the Ask index: uploaded when `markdown` is given, removed when it is null
* (a draft, or a paper that is gone). Scoped to this paper's key, as syncAskPost is to a post's.
*/
export async function syncAskPaper(
env: Env,
slug: string,
markdown: string | null,
): Promise<{ uploaded: number; removed: number }> {
const key = keyForUrl(paperPath(slug));
let uploaded = 0;
let removed = 0;
if (markdown !== null) {
await withRetry(() => env.AI_SEARCH.items.upload(key, markdown), { attempts: 2 });
uploaded = 1;
} else {
removed = (await deleteAskItemsWhere(env, (itemKey) => itemKey === key)).length;
}
await invalidateAnswerCache(env);
await dropCachedDrift(env);
return { uploaded, removed };
}