mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
feat: output format support via ?format= param
All search endpoints accept ?format=json|markdown|text|ndjson (default: json). Accept header negotiation also works (text/markdown, text/plain, application/x-ndjson). Non-JSON formats skip the response cache to avoid polluting the JSON cache key. Markdown output is optimised for n8n Slack/email nodes; text output reduces token count ~25-30% vs JSON for LLM grounding pipelines; NDJSON emits one result per line.
This commit is contained in:
72
core/format_markdown.go
Normal file
72
core/format_markdown.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderMarkdown formats an Envelope as a Markdown document suitable for
|
||||
// Slack/Discord/email nodes in n8n workflows.
|
||||
func RenderMarkdown(env *Envelope) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
|
||||
if enginesStr == "" {
|
||||
enginesStr = strings.Join(env.Query.EnginesRequested, ", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "# Search results for %q\n\n", env.Query.Text)
|
||||
fmt.Fprintf(&b, "**Query:** %s · **Engines:** %s · **Took:** %dms\n\n",
|
||||
env.Query.Text, enginesStr, env.Meta.TookMs)
|
||||
|
||||
if len(env.Meta.EnginesFailed) > 0 {
|
||||
fmt.Fprintf(&b, "> ⚠️ Engines that failed: %s\n\n", strings.Join(env.Meta.EnginesFailed, ", "))
|
||||
}
|
||||
|
||||
for i, r := range env.Results {
|
||||
fmt.Fprintf(&b, "## %d. %s\n\n", i+1, escapeMarkdown(r.Title))
|
||||
typeLabel := string(r.Type)
|
||||
if r.IsAd {
|
||||
typeLabel = "ad"
|
||||
}
|
||||
fmt.Fprintf(&b, "**%s** · %s\n\n", r.DisplayURL, typeLabel)
|
||||
if r.Snippet != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", r.Snippet)
|
||||
}
|
||||
fmt.Fprintf(&b, "→ %s\n\n", r.URL)
|
||||
}
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// RenderMarkdownImage formats an ImageEnvelope as Markdown.
|
||||
func RenderMarkdownImage(env *ImageEnvelope) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
|
||||
if enginesStr == "" {
|
||||
enginesStr = strings.Join(env.Query.EnginesRequested, ", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "# Image results for %q\n\n", env.Query.Text)
|
||||
fmt.Fprintf(&b, "**Query:** %s · **Engines:** %s · **Took:** %dms\n\n",
|
||||
env.Query.Text, enginesStr, env.Meta.TookMs)
|
||||
|
||||
for i, r := range env.Results {
|
||||
fmt.Fprintf(&b, "## %d. %s\n\n", i+1, escapeMarkdown(r.Title))
|
||||
fmt.Fprintf(&b, "**Source:** %s\n\n", r.Source.Domain)
|
||||
fmt.Fprintf(&b, "→ Image: %s\n", r.Image.URL)
|
||||
fmt.Fprintf(&b, "→ Page: %s\n\n", r.Source.PageURL)
|
||||
}
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"*", `\*`,
|
||||
"_", `\_`,
|
||||
"`", "\\`",
|
||||
"[", `\[`,
|
||||
"]", `\]`,
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
77
core/format_text.go
Normal file
77
core/format_text.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderText formats an Envelope as a minimal plain-text block optimised for
|
||||
// LLM context windows (~25-30% fewer tokens than JSON for the same data).
|
||||
func RenderText(env *Envelope) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Search: %s\n", env.Query.Text)
|
||||
enginesStr := strings.Join(env.Meta.EnginesResponded, ", ")
|
||||
if enginesStr != "" {
|
||||
fmt.Fprintf(&b, "Engines: %s\n", enginesStr)
|
||||
}
|
||||
if len(env.Meta.EnginesFailed) > 0 {
|
||||
fmt.Fprintf(&b, "Failed: %s\n", strings.Join(env.Meta.EnginesFailed, ", "))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, r := range env.Results {
|
||||
fmt.Fprintf(&b, "[%d] %s (%s)\n", i+1, r.Title, r.Domain)
|
||||
if r.Snippet != "" {
|
||||
fmt.Fprintf(&b, "%s\n", r.Snippet)
|
||||
}
|
||||
fmt.Fprintf(&b, "URL: %s\n\n", r.URL)
|
||||
}
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// RenderTextImage formats an ImageEnvelope as plain text.
|
||||
func RenderTextImage(env *ImageEnvelope) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Image search: %s\n\n", env.Query.Text)
|
||||
|
||||
for i, r := range env.Results {
|
||||
fmt.Fprintf(&b, "[%d] %s (%s)\n", i+1, r.Title, r.Source.Domain)
|
||||
fmt.Fprintf(&b, "Image: %s\n", r.Image.URL)
|
||||
fmt.Fprintf(&b, "Page: %s\n\n", r.Source.PageURL)
|
||||
}
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// RenderNDJSON formats an Envelope as newline-delimited JSON (one Result per line).
|
||||
// The envelope meta is omitted from the body; clients should read response headers.
|
||||
func RenderNDJSON(env *Envelope) []byte {
|
||||
var b strings.Builder
|
||||
for _, r := range env.Results {
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Write(data)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// RenderNDJSONImage formats an ImageEnvelope as newline-delimited JSON.
|
||||
func RenderNDJSONImage(env *ImageEnvelope) []byte {
|
||||
var b strings.Builder
|
||||
for _, r := range env.Results {
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Write(data)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
100
core/server.go
100
core/server.go
@@ -188,6 +188,11 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
|
||||
return err
|
||||
}
|
||||
|
||||
format, err := resolveFormat(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestCtx = WithQueryHash(c.UserContext(), QueryHashFromQuery(q))
|
||||
c.SetUserContext(requestCtx)
|
||||
|
||||
@@ -239,15 +244,17 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
|
||||
}
|
||||
env.Finalize(startedAt, q)
|
||||
|
||||
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
|
||||
if cacheStatus != "" {
|
||||
c.Set("X-Cache", cacheStatus)
|
||||
if format == "json" {
|
||||
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
|
||||
if cacheStatus != "" {
|
||||
c.Set("X-Cache", cacheStatus)
|
||||
}
|
||||
}
|
||||
if usedEngine != "" && usedEngine != engine.Name() {
|
||||
c.Set("X-Fallback-Engine", usedEngine)
|
||||
}
|
||||
WithRequest(requestCtx).WithFields(logrus.Fields{"action": action, "results_count": len(res)}).Info("Search completed")
|
||||
return c.JSON(env)
|
||||
return sendImageEnvelope(c, format, env)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -276,9 +283,11 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
|
||||
}
|
||||
env.Finalize(startedAt, q)
|
||||
|
||||
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
|
||||
if cacheStatus != "" {
|
||||
c.Set("X-Cache", cacheStatus)
|
||||
if format == "json" {
|
||||
cacheStatus := s.cacheEnvelopeIfEligible(engine.Name(), usedEngine, action, q, env)
|
||||
if cacheStatus != "" {
|
||||
c.Set("X-Cache", cacheStatus)
|
||||
}
|
||||
}
|
||||
if usedEngine != "" && usedEngine != engine.Name() {
|
||||
c.Set("X-Fallback-Engine", usedEngine)
|
||||
@@ -289,7 +298,7 @@ func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isIm
|
||||
completionCtx = WithEngine(completionCtx, usedEngine)
|
||||
}
|
||||
WithRequest(completionCtx).WithFields(logrus.Fields{"action": action, "results_count": len(res)}).Info("Search completed")
|
||||
return c.JSON(env)
|
||||
return sendEnvelope(c, format, env)
|
||||
}
|
||||
|
||||
// classifySearchError maps internal sentinel errors to user-facing messages.
|
||||
@@ -638,6 +647,12 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
|
||||
WithRequest(c.UserContext()).WithError(err).Warn("Invalid query parameters")
|
||||
return err
|
||||
}
|
||||
|
||||
format, err := resolveFormat(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestCtx = WithQueryHash(c.UserContext(), QueryHashFromQuery(q))
|
||||
c.SetUserContext(requestCtx)
|
||||
|
||||
@@ -688,13 +703,13 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
|
||||
}
|
||||
env.Finalize(startedAt, q)
|
||||
|
||||
if s.cache != nil {
|
||||
if format == "json" && s.cache != nil {
|
||||
c.Set("X-Cache", s.cacheMegaImageResults(action, enginesToUse, q, env))
|
||||
}
|
||||
WithRequest(requestCtx).WithFields(logrus.Fields{
|
||||
"action": action, "engines_count": len(enginesToUse), "results_count": len(env.Results),
|
||||
}).Info("Mega search completed")
|
||||
return c.JSON(env)
|
||||
return sendImageEnvelope(c, format, env)
|
||||
}
|
||||
|
||||
// Web search: enrich all raw results, build clusters from full set, then dedup flat list.
|
||||
@@ -721,7 +736,7 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
|
||||
env.Clusters = &clusters
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
if format == "json" && s.cache != nil {
|
||||
c.Set("X-Cache", s.cacheMegaEnvelopeResults(action, enginesToUse, q, env))
|
||||
}
|
||||
|
||||
@@ -730,7 +745,7 @@ func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(contex
|
||||
"engines_count": len(enginesToUse),
|
||||
"results_count": len(env.Results),
|
||||
}).Info("Mega search completed")
|
||||
return c.JSON(env)
|
||||
return sendEnvelope(c, format, env)
|
||||
}
|
||||
|
||||
func (s *Server) handleListEngines(c *fiber.Ctx) error {
|
||||
@@ -986,6 +1001,67 @@ func (s *Server) handleSwaggerUI(c *fiber.Ctx) error {
|
||||
return c.SendString(page)
|
||||
}
|
||||
|
||||
// resolveFormat returns the output format from ?format= or Accept header.
|
||||
// Supported values: "json" (default), "markdown", "text", "ndjson".
|
||||
func resolveFormat(c *fiber.Ctx) (string, error) {
|
||||
raw := strings.ToLower(strings.TrimSpace(c.Query("format", "")))
|
||||
if raw == "" {
|
||||
accept := strings.ToLower(c.Get("Accept"))
|
||||
switch {
|
||||
case strings.Contains(accept, "text/markdown"):
|
||||
raw = "markdown"
|
||||
case strings.Contains(accept, "text/plain"):
|
||||
raw = "text"
|
||||
case strings.Contains(accept, "application/x-ndjson"):
|
||||
raw = "ndjson"
|
||||
default:
|
||||
raw = "json"
|
||||
}
|
||||
}
|
||||
switch raw {
|
||||
case "json", "markdown", "text", "ndjson":
|
||||
return raw, nil
|
||||
}
|
||||
return "", &APIError{HTTPStatus: 400, Reason: ReasonUnknownFormat,
|
||||
Message: fmt.Sprintf("unknown format %q: accepted values are json, markdown, text, ndjson", raw)}
|
||||
}
|
||||
|
||||
// sendEnvelope serialises env according to the requested format and writes the
|
||||
// response. For non-JSON formats the envelope is NOT cached because format
|
||||
// variants would pollute the JSON cache.
|
||||
func sendEnvelope(c *fiber.Ctx, format string, env *Envelope) error {
|
||||
switch format {
|
||||
case "markdown":
|
||||
c.Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
return c.Send(RenderMarkdown(env))
|
||||
case "text":
|
||||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.Send(RenderText(env))
|
||||
case "ndjson":
|
||||
c.Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
return c.Send(RenderNDJSON(env))
|
||||
default:
|
||||
return c.JSON(env)
|
||||
}
|
||||
}
|
||||
|
||||
// sendImageEnvelope is sendEnvelope for ImageEnvelope.
|
||||
func sendImageEnvelope(c *fiber.Ctx, format string, env *ImageEnvelope) error {
|
||||
switch format {
|
||||
case "markdown":
|
||||
c.Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
return c.Send(RenderMarkdownImage(env))
|
||||
case "text":
|
||||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.Send(RenderTextImage(env))
|
||||
case "ndjson":
|
||||
c.Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
return c.Send(RenderNDJSONImage(env))
|
||||
default:
|
||||
return c.JSON(env)
|
||||
}
|
||||
}
|
||||
|
||||
// SetDraining controls readiness state exposed by /ready.
|
||||
func (s *Server) SetDraining(draining bool) {
|
||||
s.draining.Store(draining)
|
||||
|
||||
@@ -1619,6 +1619,47 @@ func TestMegaSearchClustersGroupSameURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatParamReturnsCorrectContentType(t *testing.T) {
|
||||
engine := &engineMock{name: "google", initialized: true}
|
||||
opts := DefaultServerOptions()
|
||||
opts.Resilience.Retry.MaxRetries = 0
|
||||
srv := NewServerWithOptions("127.0.0.1", 7204, opts, engine)
|
||||
|
||||
tests := []struct {
|
||||
format string
|
||||
wantCT string
|
||||
wantContains string
|
||||
}{
|
||||
{"json", "application/json", `"version"`},
|
||||
{"markdown", "text/markdown", "# Search results"},
|
||||
{"text", "text/plain", "Search:"},
|
||||
{"ndjson", "application/x-ndjson", `"id"`},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
// Use a unique query per subtest to avoid cache cross-contamination.
|
||||
resp := request(t, srv, fmt.Sprintf("/google/search?text=query%d&format=%s", i, tt.format))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, tt.wantCT) {
|
||||
t.Fatalf("expected Content-Type to contain %q, got %q", tt.wantCT, ct)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), tt.wantContains) {
|
||||
t.Fatalf("expected body to contain %q, got: %s", tt.wantContains, string(body)[:min(200, len(body))])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestResultIDIsStableAcrossRequests(t *testing.T) {
|
||||
engine := &engineMock{name: "google", initialized: true}
|
||||
opts := DefaultServerOptions()
|
||||
|
||||
Reference in New Issue
Block a user