From 856ab0667afd0cafe8370f7f6b7f08a913c93ae3 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 30 Sep 2026 15:31:10 -0400 Subject: [PATCH] feat(api): add docs assistant endpoint with OpenRouter integration --- apps/api/.dev.vars.example | 1 + apps/api/src/ask.ts | 290 +++++++++++++++++++++++++++++++++++++ apps/api/src/index.ts | 3 + apps/api/wrangler.jsonc | 2 + 4 files changed, 296 insertions(+) create mode 100644 apps/api/src/ask.ts diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example index e927b90..6b5a6fd 100644 --- a/apps/api/.dev.vars.example +++ b/apps/api/.dev.vars.example @@ -1,3 +1,4 @@ GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw WEBHOOK_SECRET=random_hex_matching_the_repo_webhook LINK_KEY=random_hex_signing_one_tap_links +OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant diff --git a/apps/api/src/ask.ts b/apps/api/src/ask.ts new file mode 100644 index 0000000..d1889a4 --- /dev/null +++ b/apps/api/src/ask.ts @@ -0,0 +1,290 @@ +/** + * POST /ask — the docs assistant's backend. + * + * The docs are a static GitHub Pages site, and Blume's own assistant route is + * server-rendered (it reads the provider key per request), so the site points + * `ai.assistant.endpoint` here instead. This speaks the Blume island's wire + * contract (`blume/components/islands/hooks.ts`): + * + * - request: `{ messages: [{ role, content }], page: { path } }` as JSON, + * `path` the base-stripped route the reader is on; + * - response: the answer as a plain UTF-8 text stream; any non-2xx shows the + * island's error notice, and a 503 whose body starts "The assistant is not + * configured" shows that body verbatim. + * + * Validation matches Blume's generated route: 1–40 user/assistant messages of + * string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and + * content ever reach the model, so the endpoint cannot be repurposed as an + * open LLM proxy with a caller's own system prompt. + * + * Grounding reads what the site already publishes: `llms-full.txt`, every + * page's Markdown under `# Title` / `Source: `. The page the reader is on + * goes in first, then the best keyword matches, within Blume's default budget + * (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system + * prompt — answer only from the excerpts, cite pages as `[Title](/route)`. + * + * The model call is OpenRouter's OpenAI-compatible chat endpoint over plain + * `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with + * `reasoning.effort: "none"`; its SSE deltas are re-emitted as bare text. The + * reader closing the panel aborts the request, which aborts the model call. + */ + +const MODEL = "qwen/qwen3.8-27b:free"; + +const BODY_LIMIT = 64 * 1024; +const MAX_MESSAGES = 40; +const MAX_CHARS = 24_000; + +const MAX_RESULTS = 6; +const EXCERPT_CHARS = 2000; +const CONTEXT_BUDGET = 10_000; +/** Lead-in kept before the matched region of a long page. */ +const EXCERPT_LEAD = 160; +/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */ +const CORPUS_TTL_MS = 5 * 60_000; + +const SYSTEM_PROMPT = + "You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route)."; + +/** Words that match everything and rank nothing. */ +const STOPWORDS = new Set( + "a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split( + " ", + ), +); + +interface AskMessage { + role: "user" | "assistant"; + content: string; +} + +interface Section { + title: string; + route: string; + body: string; + /** Lower-cased title + body, searched per query term. */ + text: string; + titleText: string; +} + +// ── corpus ─────────────────────────────────────────────────────── + +let corpus: { at: number; sections: Section[] } | undefined; + +/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */ +async function sections(docsUrl: string): Promise { + if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections; + const res = await fetch(`${docsUrl}/llms-full.txt`, { + cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true }, + }); + if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`); + const full = await res.text(); + const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)]; + const out = heads.map((head, i): Section => { + const start = head.index + head[0].length; + const end = heads[i + 1]?.index ?? full.length; + const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim(); + const title = head[1]!.trim(); + return { + title, + route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"), + body, + text: `${title}\n${body}`.toLowerCase(), + titleText: title.toLowerCase(), + }; + }); + corpus = { at: Date.now(), sections: out }; + return out; +} + +// ── retrieval ──────────────────────────────────────────────────── + +function terms(text: string): string[] { + return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter( + (t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)), + ); +} + +function occurrences(haystack: string, needle: string): number { + let count = 0; + for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++; + return count; +} + +/** + * The pages to inject, best first: term frequency damped by log and weighted + * by rarity across pages (idf), a title hit counting triple — so "217" or + * "sliding window" lands on the page named that rather than every page that + * mentions it in passing. + */ +function rank(all: Section[], query: string[]): Section[] { + const idf = new Map( + query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]), + ); + return all + .map((section) => { + let score = 0; + for (const t of query) { + const tf = occurrences(section.text, t); + if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0)); + } + return { section, score }; + }) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_RESULTS) + .map((r) => r.section); +} + +/** A page whole when it fits, else `chars` from just before its first query hit. */ +function excerpt(section: Section, query: string[], chars: number): string { + if (section.body.length <= chars) return section.body; + const lower = section.body.toLowerCase(); + const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1); + const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0; + return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`; +} + +/** The system prompt: the current page, then the ranked pages, within the budget. */ +async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise { + const all = await sections(env.DOCS_URL); + // A follow-up ("and in Python?") leans on the question before it. + const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content); + const query = terms(asked.join(" ")); + const current = path ? all.find((s) => s.route === path) : undefined; + const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)]; + const parts: string[] = []; + let budget = CONTEXT_BUDGET; + for (const section of picked) { + if (budget < 200) break; + const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget)); + parts.push(`## ${section.title} (${section.route})\n\n${text}`); + budget -= text.length; + } + return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT; +} + +// ── request handling ───────────────────────────────────────────── + +/** The validated conversation, or a reason it was turned away. */ +function readMessages(body: unknown): AskMessage[] | undefined { + if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return; + const raw: unknown[] = body.messages; + if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return; + const messages: AskMessage[] = []; + for (const m of raw) { + if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return; + if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return; + messages.push({ role: m.role, content: m.content }); + } + return messages; +} + +function readPath(body: unknown): string | undefined { + if (!body || typeof body !== "object" || !("page" in body)) return; + const page = body.page; + if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return; + return page.path.replace(/(.)\/+$/, "$1"); +} + +/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */ +function textStream(sse: ReadableStream): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + return sse.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + let event: unknown; + try { + event = JSON.parse(data); + } catch { + continue; + } + if (event && typeof event === "object" && "error" in event) { + // After the 200: the stream just ends, and the island shows its + // error notice for an empty answer. + console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`); + continue; + } + if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue; + const choice: unknown = event.choices[0]; + if (!choice || typeof choice !== "object" || !("delta" in choice)) continue; + const delta = choice.delta; + if (!delta || typeof delta !== "object" || !("content" in delta)) continue; + const content = delta.content; + if (typeof content === "string" && content) controller.enqueue(encoder.encode(content)); + } + }, + }), + ); +} + +export async function handleAsk(request: Request, env: Env): Promise { + // The docs serve from the custom domain and the Pages origin; reflect the + // requesting origin only when it is on the allowlist (as /api/stats does). + const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim()); + const origin = request.headers.get("origin"); + const cors = { + "access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!, + "access-control-allow-methods": "POST", + "access-control-allow-headers": "content-type", + "access-control-max-age": "86400", + vary: "Origin", + }; + const text = (body: string, status: number) => + new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } }); + + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + if (request.method !== "POST") return text("POST only.", 405); + + const raw = await request.arrayBuffer(); + if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413); + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(raw)); + } catch { + body = undefined; + } + const messages = readMessages(body); + if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400); + if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503); + + let system: string; + try { + system = await instructions(env, messages, readPath(body)); + } catch (err) { + // Ungrounded beats unanswered: the prompt still forbids inventing details. + console.warn(`ask: grounding unavailable: ${err}`); + system = SYSTEM_PROMPT; + } + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + authorization: `Bearer ${env.OPENROUTER_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: MODEL, + stream: true, + reasoning: { effort: "none" }, + messages: [{ role: "system", content: system }, ...messages], + }), + signal: request.signal, + }); + if (!res.ok || !res.body) { + console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`); + return text("Failed to generate a response.", res.status === 429 ? 429 : 502); + } + return new Response(textStream(res.body), { + headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 21becdf..49ec6e9 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ * Writes are owner-only (webhook author check, HMAC one-tap links, bearer * admin routes); everything else is read-only public. */ +import { handleAsk } from "./ask.ts"; import { reconcileCatalog } from "./catalog.ts"; import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts"; import { sendDigest } from "./digest.ts"; @@ -322,6 +323,8 @@ export default { if (path.startsWith("/admin/") && request.method === "POST") { return await handleAdmin(request, env, path); } + // The docs assistant (the static Pages site's `ai.assistant.endpoint`). + if (path === "/ask") return await handleAsk(request, env); if (path === "/chart/progress.svg") { return new Response(await progressChart(env.DB), { headers: SVG_HEADERS }); diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index c78d4e6..f6d9760 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -38,4 +38,6 @@ } // Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY. + // OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions + // secret of that name, put by deploy.yml before every deploy. }