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.
This commit is contained in:
James 2026-03-23 13:58:47 -04:00
parent 63d4e5e5ca
commit 405a6f697f
1 changed files with 72 additions and 0 deletions

72
main.go
View File

@ -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())