mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
feat(api): add docs assistant endpoint with OpenRouter integration
This commit is contained in:
1 parent
2c574018fb
commit
856ab0667a
4 files changed
+296
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||||
LINK_KEY=random_hex_signing_one_tap_links
|
LINK_KEY=random_hex_signing_one_tap_links
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
|
||||||
@@ -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: <url>`. 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<Section[]> {
|
||||||
|
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<string> {
|
||||||
|
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<Uint8Array>): ReadableStream<Uint8Array> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let buffer = "";
|
||||||
|
return sse.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
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<Response> {
|
||||||
|
// 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||||
* admin routes); everything else is read-only public.
|
* admin routes); everything else is read-only public.
|
||||||
*/
|
*/
|
||||||
|
import { handleAsk } from "./ask.ts";
|
||||||
import { reconcileCatalog } from "./catalog.ts";
|
import { reconcileCatalog } from "./catalog.ts";
|
||||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||||
import { sendDigest } from "./digest.ts";
|
import { sendDigest } from "./digest.ts";
|
||||||
@@ -322,6 +323,8 @@ export default {
|
|||||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||||
return await handleAdmin(request, env, path);
|
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") {
|
if (path === "/chart/progress.svg") {
|
||||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||||
|
|||||||
@@ -38,4 +38,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
// 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.
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user