From 405a6f697f32aa4c85e0d1996c36c9d0ba5ba1e0 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Mar 2026 13:58:47 -0400 Subject: [PATCH] feat: add GET /api/search?q=...&format=md for AI/LLM consumption New endpoint returns all matching documents as concatenated plain-text markdown, one section per document separated by ---. Format: # Document: {title} ID: {id} | Category: {category} | Date: {date} | Vendor: {vendor} {full_text or summary} --- Parameters: q - search query (required) format - must be 'md' (required; distinguishes from HTML search) Uses same FTS5 search as existing endpoints, limit raised to 200. Falls back to LIKE search if FTS5 fails. Returns text/markdown content type. POST /api/search (HTML partial) unchanged. --- main.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/main.go b/main.go index 540dbe9..e399229 100644 --- a/main.go +++ b/main.go @@ -98,6 +98,7 @@ func main() { r.Get("/search", searchHandler) // API endpoints + r.Get("/api/search", apiSearchMDHandler) r.Post("/api/search", apiSearchHandler) r.Get("/api/documents", apiDocumentsHandler) r.Get("/api/processing", apiProcessingHandler) @@ -372,6 +373,77 @@ func apiSearchHandler(w http.ResponseWriter, r *http.Request) { renderPartial(w, "document-list", docs) } +// apiSearchMDHandler handles GET /api/search?q={query}&format=md +// Returns all matching documents as concatenated plain-text markdown, +// one document per section separated by ---. Intended for AI/LLM consumption. +func apiSearchMDHandler(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + format := r.URL.Query().Get("format") + + // Only serve markdown format; anything else falls back to a 400 + if format != "md" { + http.Error(w, "format=md required", http.StatusBadRequest) + return + } + if query == "" { + http.Error(w, "q parameter required", http.StatusBadRequest) + return + } + + docs, err := SearchDocuments(query, 200) + if err != nil { + docs, _ = SearchDocumentsFallback(query, 200) + } + + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + + if len(docs) == 0 { + w.Write([]byte("No documents found matching: " + query + "\n")) + return + } + + var sb strings.Builder + for i, doc := range docs { + sb.WriteString("# Document: ") + if doc.Title != "" { + sb.WriteString(doc.Title) + } else { + sb.WriteString("(untitled)") + } + sb.WriteString("\n") + sb.WriteString("ID: ") + sb.WriteString(doc.ID) + if doc.Category != "" { + sb.WriteString(" | Category: ") + sb.WriteString(doc.Category) + } + if doc.Date != "" { + sb.WriteString(" | Date: ") + sb.WriteString(doc.Date) + } + if doc.Vendor != "" { + sb.WriteString(" | Vendor: ") + sb.WriteString(doc.Vendor) + } + sb.WriteString("\n\n") + + text := doc.FullText + if text == "" { + text = doc.Summary + } + if text != "" { + sb.WriteString(text) + sb.WriteString("\n") + } + + if i < len(docs)-1 { + sb.WriteString("\n---\n\n") + } + } + + w.Write([]byte(sb.String())) +} + func apiProcessingHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(GetActiveJobs())