search for standard sites pub-search.waow.tech
search zig blog atproto

wip: ask feature scaffolding

- add ask.zig module
- update server.zig and timing.zig
- frontend changes in index.html

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+564 -2
+305
backend/src/ask.zig
··· 1 + //! Knowledge synthesis endpoint - answers questions using corpus context. 2 + //! 3 + //! Takes a question, retrieves relevant documents via FTS search, 4 + //! sends them to Claude for synthesis, returns answer with citations. 5 + 6 + const std = @import("std"); 7 + const http = std.http; 8 + const json = std.json; 9 + const mem = std.mem; 10 + const posix = std.posix; 11 + const Allocator = mem.Allocator; 12 + const logfire = @import("logfire"); 13 + const db = @import("db/mod.zig"); 14 + const search = @import("search.zig"); 15 + 16 + const MAX_CONTEXT_DOCS = 10; 17 + const MAX_CONTENT_CHARS = 4000; // per doc, ~1000 tokens 18 + const CLAUDE_MODEL = "claude-sonnet-4-20250514"; 19 + 20 + /// Source document included in the answer 21 + const Source = struct { 22 + uri: []const u8, 23 + title: []const u8, 24 + url: []const u8, 25 + }; 26 + 27 + /// Response from the /ask endpoint 28 + const AskResponse = struct { 29 + answer: []const u8, 30 + sources: []const Source, 31 + }; 32 + 33 + /// Document with full content for context 34 + const ContextDoc = struct { 35 + uri: []const u8, 36 + title: []const u8, 37 + content: []const u8, 38 + base_path: []const u8, 39 + path: []const u8, 40 + platform: []const u8, 41 + 42 + fn buildUrl(self: ContextDoc, alloc: Allocator) ![]const u8 { 43 + if (self.base_path.len == 0) return ""; 44 + // base_path is like "https://foo.leaflet.pub", path is like "/001" 45 + const url = try std.fmt.allocPrint(alloc, "{s}{s}", .{ self.base_path, self.path }); 46 + return url; 47 + } 48 + }; 49 + 50 + /// Answer a question using corpus knowledge 51 + pub fn ask(alloc: Allocator, question: []const u8) ![]const u8 { 52 + const span = logfire.span("ask.synthesize", .{ .question = question }); 53 + defer span.end(); 54 + 55 + const api_key = posix.getenv("ANTHROPIC_API_KEY") orelse { 56 + return try serializeError(alloc, "ANTHROPIC_API_KEY not configured"); 57 + }; 58 + 59 + // 1. search for relevant docs 60 + const docs = try getContextDocs(alloc, question); 61 + if (docs.len == 0) { 62 + return try serializeError(alloc, "no relevant documents found"); 63 + } 64 + 65 + // 2. build prompt with context 66 + const prompt = try buildPrompt(alloc, question, docs); 67 + defer alloc.free(prompt); 68 + 69 + // 3. call Claude 70 + const answer = try callClaude(alloc, api_key, prompt) orelse { 71 + return try serializeError(alloc, "failed to get response from Claude"); 72 + }; 73 + defer alloc.free(answer); 74 + 75 + // 4. build response with sources 76 + return try serializeResponse(alloc, answer, docs); 77 + } 78 + 79 + fn getContextDocs(alloc: Allocator, question: []const u8) ![]ContextDoc { 80 + const client = db.getClient() orelse return error.NotInitialized; 81 + 82 + // build FTS query from question 83 + const fts_query = try search.buildFtsQuery(alloc, question); 84 + defer if (fts_query.len > 0) alloc.free(fts_query); 85 + 86 + if (fts_query.len == 0) return &.{}; 87 + 88 + // query for docs with full content 89 + var result = client.query( 90 + \\SELECT uri, title, content, base_path, COALESCE(path, '') as path, platform 91 + \\FROM documents 92 + \\WHERE uri IN ( 93 + \\ SELECT uri FROM documents_fts 94 + \\ WHERE documents_fts MATCH ? 95 + \\ ORDER BY rank LIMIT ? 96 + \\) 97 + , &.{ fts_query, std.fmt.comptimePrint("{}", .{MAX_CONTEXT_DOCS}) }) catch { 98 + return &.{}; 99 + }; 100 + defer result.deinit(); 101 + 102 + var docs: std.ArrayList(ContextDoc) = .empty; 103 + errdefer docs.deinit(alloc); 104 + 105 + for (result.rows) |row| { 106 + const content = row.text(2); 107 + const truncated = if (content.len > MAX_CONTENT_CHARS) 108 + try alloc.dupe(u8, content[0..MAX_CONTENT_CHARS]) 109 + else 110 + try alloc.dupe(u8, content); 111 + 112 + try docs.append(alloc, .{ 113 + .uri = try alloc.dupe(u8, row.text(0)), 114 + .title = try alloc.dupe(u8, row.text(1)), 115 + .content = truncated, 116 + .base_path = try alloc.dupe(u8, row.text(3)), 117 + .path = try alloc.dupe(u8, row.text(4)), 118 + .platform = try alloc.dupe(u8, row.text(5)), 119 + }); 120 + } 121 + 122 + return try docs.toOwnedSlice(alloc); 123 + } 124 + 125 + fn buildPrompt(alloc: Allocator, question: []const u8, docs: []const ContextDoc) ![]const u8 { 126 + var prompt: std.ArrayList(u8) = .empty; 127 + errdefer prompt.deinit(alloc); 128 + 129 + try prompt.appendSlice(alloc, 130 + \\You are a research assistant that answers questions based on a corpus of documents from ATProto publishing platforms (Leaflet, pckt, Offprint, etc.). 131 + \\ 132 + \\Answer the user's question based ONLY on the provided documents. If the documents don't contain relevant information, say so. 133 + \\ 134 + \\When citing sources, use markdown links like [title](url). Be concise but thorough. 135 + \\ 136 + \\## Documents 137 + \\ 138 + \\ 139 + ); 140 + 141 + for (docs, 0..) |doc, i| { 142 + const url = try doc.buildUrl(alloc); 143 + defer if (url.len > 0) alloc.free(url); 144 + 145 + // format doc header 146 + const header = try std.fmt.allocPrint(alloc, "### [{d}] {s}\n", .{ i + 1, doc.title }); 147 + defer alloc.free(header); 148 + try prompt.appendSlice(alloc, header); 149 + 150 + if (url.len > 0) { 151 + const url_line = try std.fmt.allocPrint(alloc, "URL: {s}\n", .{url}); 152 + defer alloc.free(url_line); 153 + try prompt.appendSlice(alloc, url_line); 154 + } 155 + 156 + try prompt.appendSlice(alloc, "\n"); 157 + try prompt.appendSlice(alloc, doc.content); 158 + try prompt.appendSlice(alloc, "\n\n"); 159 + } 160 + 161 + try prompt.appendSlice(alloc, "## Question\n\n"); 162 + try prompt.appendSlice(alloc, question); 163 + try prompt.appendSlice(alloc, "\n\n## Answer\n"); 164 + 165 + return try prompt.toOwnedSlice(alloc); 166 + } 167 + 168 + fn callClaude(alloc: Allocator, api_key: []const u8, prompt: []const u8) !?[]const u8 { 169 + const span = logfire.span("ask.claude_api", .{}); 170 + defer span.end(); 171 + 172 + var http_client: http.Client = .{ .allocator = alloc }; 173 + defer http_client.deinit(); 174 + 175 + // build request body 176 + const body = try buildClaudeRequest(alloc, prompt); 177 + defer alloc.free(body); 178 + 179 + var auth_buf: [256]u8 = undefined; 180 + const auth = std.fmt.bufPrint(&auth_buf, "{s}", .{api_key}) catch 181 + return error.AuthTooLong; 182 + 183 + var response_body: std.Io.Writer.Allocating = .init(alloc); 184 + errdefer response_body.deinit(); 185 + 186 + const res = http_client.fetch(.{ 187 + .location = .{ .url = "https://api.anthropic.com/v1/messages" }, 188 + .method = .POST, 189 + .headers = .{ 190 + .content_type = .{ .override = "application/json" }, 191 + .authorization = .{ .override = "" }, // not used, we use x-api-key 192 + }, 193 + .extra_headers = &.{ 194 + .{ .name = "x-api-key", .value = auth }, 195 + .{ .name = "anthropic-version", .value = "2023-06-01" }, 196 + }, 197 + .payload = body, 198 + .response_writer = &response_body.writer, 199 + }) catch |err| { 200 + logfire.err("ask: claude request failed: {}", .{err}); 201 + return null; 202 + }; 203 + 204 + if (res.status != .ok) { 205 + const resp_text = response_body.toOwnedSlice() catch ""; 206 + defer if (resp_text.len > 0) alloc.free(resp_text); 207 + logfire.err("ask: claude error {}: {s}", .{ res.status, resp_text[0..@min(resp_text.len, 500)] }); 208 + return null; 209 + } 210 + 211 + const response_text = try response_body.toOwnedSlice(); 212 + defer alloc.free(response_text); 213 + 214 + return try parseClaudeResponse(alloc, response_text); 215 + } 216 + 217 + fn buildClaudeRequest(alloc: Allocator, prompt: []const u8) ![]const u8 { 218 + var body: std.Io.Writer.Allocating = .init(alloc); 219 + errdefer body.deinit(); 220 + var jw: json.Stringify = .{ .writer = &body.writer }; 221 + 222 + try jw.beginObject(); 223 + 224 + try jw.objectField("model"); 225 + try jw.write(CLAUDE_MODEL); 226 + 227 + try jw.objectField("max_tokens"); 228 + try jw.write(@as(i64, 2048)); 229 + 230 + try jw.objectField("messages"); 231 + try jw.beginArray(); 232 + try jw.beginObject(); 233 + try jw.objectField("role"); 234 + try jw.write("user"); 235 + try jw.objectField("content"); 236 + try jw.write(prompt); 237 + try jw.endObject(); 238 + try jw.endArray(); 239 + 240 + try jw.endObject(); 241 + 242 + return try body.toOwnedSlice(); 243 + } 244 + 245 + fn parseClaudeResponse(alloc: Allocator, response: []const u8) ![]const u8 { 246 + const parsed = json.parseFromSlice(json.Value, alloc, response, .{}) catch { 247 + logfire.err("ask: failed to parse claude response", .{}); 248 + return error.ParseError; 249 + }; 250 + defer parsed.deinit(); 251 + 252 + const content = parsed.value.object.get("content") orelse return error.MissingContent; 253 + if (content != .array or content.array.items.len == 0) return error.InvalidContent; 254 + 255 + const first_block = content.array.items[0]; 256 + const text = first_block.object.get("text") orelse return error.MissingText; 257 + if (text != .string) return error.InvalidText; 258 + 259 + return try alloc.dupe(u8, text.string); 260 + } 261 + 262 + fn serializeResponse(alloc: Allocator, answer: []const u8, docs: []const ContextDoc) ![]const u8 { 263 + var output: std.Io.Writer.Allocating = .init(alloc); 264 + errdefer output.deinit(); 265 + var jw: json.Stringify = .{ .writer = &output.writer }; 266 + 267 + try jw.beginObject(); 268 + 269 + try jw.objectField("answer"); 270 + try jw.write(answer); 271 + 272 + try jw.objectField("sources"); 273 + try jw.beginArray(); 274 + for (docs) |doc| { 275 + const url = try doc.buildUrl(alloc); 276 + defer if (url.len > 0) alloc.free(url); 277 + 278 + try jw.beginObject(); 279 + try jw.objectField("uri"); 280 + try jw.write(doc.uri); 281 + try jw.objectField("title"); 282 + try jw.write(doc.title); 283 + try jw.objectField("url"); 284 + try jw.write(url); 285 + try jw.endObject(); 286 + } 287 + try jw.endArray(); 288 + 289 + try jw.endObject(); 290 + 291 + return try output.toOwnedSlice(); 292 + } 293 + 294 + fn serializeError(alloc: Allocator, message: []const u8) ![]const u8 { 295 + var output: std.Io.Writer.Allocating = .init(alloc); 296 + errdefer output.deinit(); 297 + var jw: json.Stringify = .{ .writer = &output.writer }; 298 + 299 + try jw.beginObject(); 300 + try jw.objectField("error"); 301 + try jw.write(message); 302 + try jw.endObject(); 303 + 304 + return try output.toOwnedSlice(); 305 + }
+31
backend/src/server.zig
··· 9 9 const stats = @import("stats.zig"); 10 10 const timing = @import("timing.zig"); 11 11 const dashboard = @import("dashboard.zig"); 12 + const ask_mod = @import("ask.zig"); 12 13 13 14 const HTTP_BUF_SIZE = 8192; 14 15 const QUERY_PARAM_BUF_SIZE = 64; ··· 69 70 try handleSimilar(request, target); 70 71 } else if (mem.eql(u8, target, "/activity")) { 71 72 try handleActivity(request); 73 + } else if (mem.startsWith(u8, target, "/ask")) { 74 + try handleAsk(request, target); 72 75 } else { 73 76 try sendNotFound(request); 74 77 } ··· 336 339 337 340 try sendJson(request, stream.getWritten()); 338 341 } 342 + 343 + fn handleAsk(request: *http.Server.Request, target: []const u8) !void { 344 + const start_time = std.time.microTimestamp(); 345 + defer timing.record(.ask, start_time); 346 + 347 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 348 + defer arena.deinit(); 349 + const alloc = arena.allocator(); 350 + 351 + const question = parseQueryParam(alloc, target, "q") catch ""; 352 + 353 + const span = logfire.span("http.ask", .{ .question = question }); 354 + defer span.end(); 355 + 356 + if (question.len == 0) { 357 + try sendJson(request, "{\"error\":\"missing q parameter\"}"); 358 + return; 359 + } 360 + 361 + const result = ask_mod.ask(alloc, question) catch |err| { 362 + logfire.err("ask failed: {}", .{err}); 363 + stats.recordError(); 364 + try sendJson(request, "{\"error\":\"synthesis failed\"}"); 365 + return; 366 + }; 367 + 368 + try sendJson(request, result); 369 + }
+1
backend/src/timing.zig
··· 6 6 similar, 7 7 tags, 8 8 popular, 9 + ask, 9 10 10 11 pub fn name(self: Endpoint) []const u8 { 11 12 return @tagName(self);
+227 -2
site/index.html
··· 65 65 color: #1B7340; 66 66 } 67 67 68 + /* mode toggle - experimental aesthetic */ 69 + .mode-toggle { 70 + display: flex; 71 + align-items: center; 72 + gap: 0.5rem; 73 + margin-bottom: 1rem; 74 + font-size: 12px; 75 + } 76 + 77 + .mode-toggle .mode { 78 + padding: 4px 10px; 79 + cursor: pointer; 80 + color: #555; 81 + border-bottom: 2px solid transparent; 82 + transition: all 0.15s; 83 + } 84 + 85 + .mode-toggle .mode:hover { 86 + color: #888; 87 + } 88 + 89 + .mode-toggle .mode.active { 90 + color: #ccc; 91 + border-bottom-color: #1B7340; 92 + } 93 + 94 + .mode-toggle .mode.experimental { 95 + position: relative; 96 + } 97 + 98 + .mode-toggle .mode.experimental::after { 99 + content: 'experimental'; 100 + position: absolute; 101 + top: -8px; 102 + right: -4px; 103 + font-size: 7px; 104 + color: #d4956a; 105 + background: rgba(180, 100, 64, 0.2); 106 + padding: 1px 3px; 107 + border-radius: 2px; 108 + text-transform: uppercase; 109 + letter-spacing: 0.5px; 110 + } 111 + 112 + .mode-toggle .mode.experimental.active { 113 + border-bottom-style: dashed; 114 + border-bottom-color: #d4956a; 115 + color: #d4956a; 116 + } 117 + 118 + /* ask response styling */ 119 + .ask-response { 120 + border: 1px dashed #d4956a; 121 + border-radius: 4px; 122 + padding: 1rem; 123 + margin-bottom: 1rem; 124 + background: rgba(180, 100, 64, 0.05); 125 + } 126 + 127 + .ask-response .answer { 128 + color: #ccc; 129 + line-height: 1.7; 130 + font-size: 13px; 131 + } 132 + 133 + .ask-response .answer a { 134 + color: #d4956a; 135 + } 136 + 137 + .ask-response .answer a:hover { 138 + color: #e8b08a; 139 + } 140 + 141 + .ask-sources { 142 + margin-top: 1rem; 143 + padding-top: 1rem; 144 + border-top: 1px solid #222; 145 + } 146 + 147 + .ask-sources-label { 148 + font-size: 10px; 149 + color: #555; 150 + text-transform: uppercase; 151 + letter-spacing: 0.5px; 152 + margin-bottom: 0.5rem; 153 + } 154 + 155 + .ask-source { 156 + display: inline-block; 157 + font-size: 11px; 158 + padding: 3px 8px; 159 + margin: 0 0.5rem 0.5rem 0; 160 + background: #111; 161 + border: 1px solid #222; 162 + border-radius: 3px; 163 + color: #888; 164 + text-decoration: none; 165 + } 166 + 167 + .ask-source:hover { 168 + background: #1a1a1a; 169 + border-color: #333; 170 + color: #aaa; 171 + } 172 + 173 + .ask-thinking { 174 + color: #d4956a; 175 + font-size: 12px; 176 + animation: pulse 1.5s ease-in-out infinite; 177 + } 178 + 179 + .ask-error { 180 + color: #c44; 181 + font-size: 12px; 182 + padding: 1rem; 183 + border: 1px dashed #c44; 184 + border-radius: 4px; 185 + background: rgba(200, 60, 60, 0.05); 186 + } 187 + 68 188 .search-box { 69 189 display: flex; 70 190 gap: 0.5rem; ··· 507 627 font-size: 11px; 508 628 padding: 0.5rem; 509 629 } 630 + 631 + /* mode toggle mobile */ 632 + .mode-toggle .mode { 633 + min-height: 44px; 634 + display: inline-flex; 635 + align-items: center; 636 + } 510 637 } 511 638 512 639 /* ensure touch targets on tablets too */ ··· 522 649 <body> 523 650 <div class="container"> 524 651 <h1><a href="/" class="title">pub search</a> <span class="by">by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank">@zzstoatzz.io</a></span> <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search" target="_blank" class="src">[src]</a></h1> 652 + 653 + <div class="mode-toggle"> 654 + <span class="mode active" id="mode-search" onclick="setMode('search')">search</span> 655 + <span class="mode experimental" id="mode-ask" onclick="setMode('ask')">ask</span> 656 + </div> 525 657 526 658 <div class="search-box"> 527 659 <input type="text" id="query" placeholder="search content..." autofocus> ··· 562 694 563 695 let currentTag = null; 564 696 let currentPlatform = null; 697 + let currentMode = 'search'; // 'search' or 'ask' 565 698 let allTags = []; 566 699 let popularSearches = []; 700 + 701 + function setMode(mode) { 702 + currentMode = mode; 703 + document.getElementById('mode-search').classList.toggle('active', mode === 'search'); 704 + document.getElementById('mode-ask').classList.toggle('active', mode === 'ask'); 705 + 706 + // update UI based on mode 707 + if (mode === 'ask') { 708 + queryInput.placeholder = 'ask a question...'; 709 + searchBtn.textContent = 'ask'; 710 + // hide filters and suggestions in ask mode (they don't apply) 711 + tagsDiv.style.display = 'none'; 712 + platformFilterDiv.style.display = 'none'; 713 + activeFilterDiv.style.display = 'none'; 714 + suggestionsDiv.style.display = 'none'; 715 + // show ask-specific empty state 716 + resultsDiv.innerHTML = ` 717 + <div class="empty-state"> 718 + <p style="color:#d4956a">ask questions about the corpus</p> 719 + <p style="font-size:11px;margin-top:0.5rem;color:#666">e.g. "what approaches do writers take to deploying prefect flows?"</p> 720 + </div> 721 + `; 722 + statsDiv.textContent = ''; 723 + } else { 724 + queryInput.placeholder = 'search content...'; 725 + searchBtn.textContent = 'search'; 726 + tagsDiv.style.display = ''; 727 + platformFilterDiv.style.display = ''; 728 + activeFilterDiv.style.display = ''; 729 + suggestionsDiv.style.display = ''; 730 + renderEmptyState(); 731 + } 732 + } 567 733 568 734 async function search(query, tag = null, platform = null) { 569 735 if (!query.trim() && !tag && !platform) return; ··· 795 961 } 796 962 797 963 function doSearch() { 798 - updateUrl(); 799 - search(queryInput.value, currentTag, currentPlatform); 964 + if (currentMode === 'ask') { 965 + doAsk(); 966 + } else { 967 + updateUrl(); 968 + search(queryInput.value, currentTag, currentPlatform); 969 + } 970 + } 971 + 972 + async function doAsk() { 973 + const question = queryInput.value.trim(); 974 + if (!question) return; 975 + 976 + searchBtn.disabled = true; 977 + resultsDiv.innerHTML = `<div class="ask-thinking">thinking about "${escapeHtml(question)}"...</div>`; 978 + 979 + try { 980 + const res = await fetch(`${API_URL}/ask?q=${encodeURIComponent(question)}`); 981 + const data = await res.json(); 982 + 983 + if (data.error) { 984 + resultsDiv.innerHTML = `<div class="ask-error">${escapeHtml(data.error)}</div>`; 985 + return; 986 + } 987 + 988 + // render the synthesized answer 989 + let html = `<div class="ask-response">`; 990 + html += `<div class="answer">${formatAnswer(data.answer)}</div>`; 991 + 992 + if (data.sources && data.sources.length > 0) { 993 + html += `<div class="ask-sources">`; 994 + html += `<div class="ask-sources-label">sources (${data.sources.length})</div>`; 995 + for (const src of data.sources) { 996 + if (src.url) { 997 + html += `<a href="${escapeHtml(src.url)}" target="_blank" class="ask-source">${escapeHtml(src.title || 'Untitled')}</a>`; 998 + } else { 999 + html += `<span class="ask-source">${escapeHtml(src.title || 'Untitled')}</span>`; 1000 + } 1001 + } 1002 + html += `</div>`; 1003 + } 1004 + 1005 + html += `</div>`; 1006 + resultsDiv.innerHTML = html; 1007 + statsDiv.textContent = ''; 1008 + 1009 + } catch (err) { 1010 + resultsDiv.innerHTML = `<div class="ask-error">failed to get answer: ${escapeHtml(err.message)}</div>`; 1011 + } finally { 1012 + searchBtn.disabled = false; 1013 + } 1014 + } 1015 + 1016 + function formatAnswer(text) { 1017 + if (!text) return ''; 1018 + // escape HTML first 1019 + let html = escapeHtml(text); 1020 + // convert markdown links [title](url) to HTML links 1021 + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>'); 1022 + // convert newlines to <br> 1023 + html = html.replace(/\n/g, '<br>'); 1024 + return html; 800 1025 } 801 1026 802 1027 function setTag(tag) {