package handler import ( "context" "fmt" "math" "net/http" "sort" "strconv" "strings" "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/typesense/typesense-go/v2/typesense/api" ) // SearchHandler handles search requests via Typesense. type SearchHandler struct { ts *repository.TypesenseClient } // NewSearchHandler creates a new SearchHandler. func NewSearchHandler(ts *repository.TypesenseClient) *SearchHandler { return &SearchHandler{ts: ts} } // validSearchTypes lists the valid values for the "type" query parameter. var validSearchTypes = map[string]bool{ "all": true, "verses": true, "prose": true, "letters": true, "transcriptions": true, "glossary": true, "essays": true, "journals": true, "magazines": true, "other_documents": true, } // collectionQueryByFields maps each Typesense collection to the fields to search. // Field order matters: most relevant fields first. Used with query_by_weights // to control ranking — identifiers and primary text rank higher than secondary // text like purport/content where references/citations may appear. var collectionQueryByFields = map[string]string{ // Note: purport intentionally excluded from default verse search — matches there // (where verses are quoted in commentary) drown out actual verse-text matches. "verses": "reference,transliteration,transliteration_ascii,translation,translation_ascii", "prose": "title,title_ascii,content,content_ascii", "letters": "recipient_display,recipient_display_ascii,content,content_ascii", "transcriptions": "reference,name,name_ascii,content,content_ascii", "glossary": "term,term_ascii,definition,definition_ascii", "essays": "title,title_ascii,content,content_ascii", "journals": "title,title_ascii,content,content_ascii", "magazines": "title,title_ascii,content,content_ascii", "other_documents": "title,title_ascii,content,content_ascii", } // collectionQueryByWeights assigns weights matching the order of fields above. // Higher weight = stronger ranking signal. Identifiers/primary text dominate. var collectionQueryByWeights = map[string]string{ "verses": "10,8,8,4,4", // reference, transliteration, transliteration_ascii, translation, translation_ascii "prose": "10,10,2,2", "letters": "10,10,2,2", "transcriptions": "10,8,8,2,2", "glossary": "10,10,4,4", "essays": "10,10,2,2", "journals": "10,10,2,2", "magazines": "10,10,2,2", "other_documents": "10,10,2,2", } // searchStopwordsSet is the name of the Typesense stopwords set applied to // every search. The set is configured server-side (`PUT /stopwords/common`) // and contains conversational words like "letter", "to", "of", "essay", etc. // // Without this, a query like "letter to tusta krsna" ranks letters that merely // contain "letter" and "to" in their body above letters whose recipient is // actually "Tusta Krsna", because those common tokens contribute heavily to // the multi-field score. const searchStopwordsSet = "common" // searchResultItem represents a single search result in the API response. type searchResultItem struct { Collection string `json:"collection"` Slug string `json:"slug,omitempty"` Title string `json:"title"` Snippet string `json:"snippet"` URL string `json:"url"` Meta map[string]interface{} `json:"meta,omitempty"` score int64 // internal, not serialized } // searchResponse is the shape of the data field in the API response. type searchResponse struct { Query string `json:"query"` Type string `json:"type"` Results []searchResultItem `json:"results"` Total int `json:"total"` Page int `json:"page"` Limit int `json:"limit"` TotalPages int `json:"total_pages"` } // Search handles GET /api/v1/search?q=...&type=all&page=1&limit=20 func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) if query == "" { response.Error(w, http.StatusBadRequest, "query parameter 'q' is required") return } searchType := r.URL.Query().Get("type") if searchType == "" { searchType = "all" } // Parse comma-separated types (e.g., "verses,letters,transcriptions") var requestedTypes []string if searchType != "all" { for _, t := range strings.Split(searchType, ",") { t = strings.TrimSpace(t) if t == "" || t == "all" { continue } if !validSearchTypes[t] { response.Error(w, http.StatusBadRequest, fmt.Sprintf("invalid type '%s'", t)) return } requestedTypes = append(requestedTypes, t) } if len(requestedTypes) == 0 { searchType = "all" } } page := parseIntParam(r.URL.Query().Get("page"), 1) if page < 1 { page = 1 } limit := parseIntParam(r.URL.Query().Get("limit"), 20) if limit < 1 { limit = 1 } if limit > 100 { limit = 100 } ctx := r.Context() var results []searchResultItem var totalFound int if searchType == "all" { // Search all 9 collections results, totalFound = h.multiSearch(ctx, nil, query, page, limit) } else if len(requestedTypes) == 1 { // Single collection — use singleSearch for efficiency results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit) } else { // Multiple collections — use multiSearch with a subset results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit) } totalPages := int(math.Ceil(float64(totalFound) / float64(limit))) resp := searchResponse{ Query: query, Type: searchType, Results: results, Total: totalFound, Page: page, Limit: limit, TotalPages: totalPages, } response.JSON(w, http.StatusOK, resp) } // multiSearch searches across all 9 collections using Typesense multi_search, // merges results by text_match score, and returns a single paginated list. // multiSearch searches across multiple collections using Typesense multi_search. // If collections is nil, searches all 9 collections. func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) { if collections == nil { collections = []string{ "verses", "prose", "letters", "transcriptions", "glossary", "essays", "journals", "magazines", "other_documents", } } exhaustive := true prioritizeExact := true prioritizeTokenPos := true searches := make([]api.MultiSearchCollectionParameters, 0, len(collections)) for _, col := range collections { queryBy := collectionQueryByFields[col] weights := collectionQueryByWeights[col] perPage := limit pg := page params := api.MultiSearchCollectionParameters{ Collection: col, Q: stringPtr(query), QueryBy: stringPtr(queryBy), PerPage: &perPage, Page: &pg, Prefix: stringPtr("true"), NumTypos: stringPtr("2"), ExhaustiveSearch: &exhaustive, Stopwords: stringPtr(searchStopwordsSet), // Boost docs whose tokens are exact matches and appear early in the field // so "karma" outranks "karmic" and frequent matches outrank single ones. PrioritizeExactMatch: &prioritizeExact, PrioritizeTokenPosition: &prioritizeTokenPos, } if weights != "" { params.QueryByWeights = stringPtr(weights) } searches = append(searches, params) } searchParams := api.MultiSearchSearchesParameter{ Searches: searches, } commonParams := &api.MultiSearchParams{} msResult, err := h.ts.Client.MultiSearch.Perform(ctx, commonParams, searchParams) if err != nil { return []searchResultItem{}, 0 } allItems := []searchResultItem{} totalFound := 0 for i, sr := range msResult.Results { collectionName := collections[i] if sr.Found != nil { totalFound += *sr.Found } if sr.Hits == nil { continue } for _, hit := range *sr.Hits { item := hitToResultItem(collectionName, &hit) allItems = append(allItems, item) } } // Sort by text_match score descending sort.Slice(allItems, func(i, j int) bool { return allItems[i].score > allItems[j].score }) // Paginate the merged results: since each sub-search already returned `limit` // results for the given page, the merged set has at most 9*limit items. // We take the top `limit` after sorting. if len(allItems) > limit { allItems = allItems[:limit] } return allItems, totalFound } // singleSearch searches a single collection. func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) { queryBy, ok := collectionQueryByFields[collection] if !ok { return []searchResultItem{}, 0 } exhaustive := true prioritizeExact := true prioritizeTokenPos := true params := &api.SearchCollectionParams{ Q: stringPtr(query), QueryBy: stringPtr(queryBy), Page: &page, PerPage: &limit, Prefix: stringPtr("true"), NumTypos: stringPtr("2"), ExhaustiveSearch: &exhaustive, Stopwords: stringPtr(searchStopwordsSet), // Boost docs whose tokens are exact matches and appear early in the field // so "karma" outranks "karmic" and frequent matches outrank single ones. PrioritizeExactMatch: &prioritizeExact, PrioritizeTokenPosition: &prioritizeTokenPos, } if weights := collectionQueryByWeights[collection]; weights != "" { params.QueryByWeights = stringPtr(weights) } sr, err := h.ts.Client.Collection(collection).Documents().Search(ctx, params) if err != nil { return []searchResultItem{}, 0 } totalFound := 0 if sr.Found != nil { totalFound = *sr.Found } items := []searchResultItem{} if sr.Hits != nil { for _, hit := range *sr.Hits { item := hitToResultItem(collection, &hit) items = append(items, item) } } return items, totalFound } // hitToResultItem converts a Typesense SearchResultHit into our API result shape. func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultItem { doc := make(map[string]interface{}) if hit.Document != nil { doc = *hit.Document } var score int64 if hit.TextMatch != nil { score = *hit.TextMatch } item := searchResultItem{ Collection: collection, URL: getStringField(doc, "url"), score: score, } // Extract title based on collection type switch collection { case "verses": item.Title = getStringField(doc, "reference") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "book": getStringField(doc, "book"), "reference": getStringField(doc, "reference"), } case "prose": item.Title = getStringField(doc, "title") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "book": getStringField(doc, "book"), "type": getStringField(doc, "type"), } case "letters": recipient := getStringField(doc, "recipient_display") if recipient != "" { item.Title = "Letter to " + recipient } else { item.Title = "Letter" } item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "recipient_display": recipient, } if date, ok := doc["date"]; ok { item.Meta["date"] = date } if location, ok := doc["location"]; ok { item.Meta["location"] = location } case "transcriptions": item.Title = getStringField(doc, "name") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "type": getStringField(doc, "type"), } if place, ok := doc["place"]; ok { item.Meta["place"] = place } if date, ok := doc["date"]; ok { item.Meta["date"] = date } if ref, ok := doc["reference"]; ok { item.Meta["reference"] = ref } case "glossary": item.Title = getStringField(doc, "term") item.Slug = getStringField(doc, "term") item.Meta = map[string]interface{}{ "first_letter": getStringField(doc, "first_letter"), } case "essays": item.Title = getStringField(doc, "title") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "author": getStringField(doc, "author"), } if year, ok := doc["year"]; ok { item.Meta["year"] = year } case "journals": item.Title = getStringField(doc, "title") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "book": getStringField(doc, "book"), } case "magazines": item.Title = getStringField(doc, "title") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "book": getStringField(doc, "book"), } case "other_documents": item.Title = getStringField(doc, "title") item.Slug = slugFromURL(item.URL) item.Meta = map[string]interface{}{ "book": getStringField(doc, "book"), } } // Extract snippet from highlights item.Snippet = extractSnippet(hit, collection) return item } // extractSnippet builds a snippet string from Typesense highlight data. // It prefers the newer highlight map, falling back to the deprecated highlights array. func extractSnippet(hit *api.SearchResultHit, collection string) string { // Try the highlights array (deprecated but widely available) if hit.Highlights != nil && len(*hit.Highlights) > 0 { for _, hl := range *hit.Highlights { if hl.Snippet != nil && *hl.Snippet != "" { return *hl.Snippet } if hl.Snippets != nil && len(*hl.Snippets) > 0 { return (*hl.Snippets)[0] } } } // Try the highlight map (v2 style) if hit.Highlight != nil { hlMap := *hit.Highlight // Prioritize content/text fields for snippet priorityFields := snippetPriorityFields(collection) for _, field := range priorityFields { if val, ok := hlMap[field]; ok { if s := extractHighlightValue(val); s != "" { return s } } } // Fallback: return any highlighted field for _, val := range hlMap { if s := extractHighlightValue(val); s != "" { return s } } } return "" } // snippetPriorityFields returns the fields to check first for snippets, per collection. func snippetPriorityFields(collection string) []string { switch collection { case "verses": return []string{"translation", "purport", "transliteration", "reference"} case "prose": return []string{"content", "title"} case "letters": return []string{"content", "recipient_display"} case "transcriptions": return []string{"content", "name", "reference"} case "glossary": return []string{"definition", "term"} case "essays": return []string{"content", "title"} case "journals", "magazines", "other_documents": return []string{"content", "title"} default: return []string{"content", "title"} } } // extractHighlightValue tries to pull a string snippet from a highlight value, // which may be a string, a map with "snippet" or "matched_tokens", etc. func extractHighlightValue(val interface{}) string { switch v := val.(type) { case string: if v != "" { return v } case map[string]interface{}: if snippet, ok := v["snippet"].(string); ok && snippet != "" { return snippet } if value, ok := v["value"].(string); ok && value != "" { return value } } return "" } // getStringField safely extracts a string value from a map. func getStringField(doc map[string]interface{}, key string) string { if val, ok := doc[key]; ok { if s, ok := val.(string); ok { return s } } return "" } // slugFromURL extracts the last path segment from a URL as a slug. func slugFromURL(u string) string { u = strings.TrimRight(u, "/") parts := strings.Split(u, "/") if len(parts) > 0 { return parts[len(parts)-1] } return "" } // parseIntParam parses an integer from a query param string with a default fallback. func parseIntParam(s string, defaultVal int) int { if s == "" { return defaultVal } v, err := strconv.Atoi(s) if err != nil { return defaultVal } return v } // stringPtr returns a pointer to a string value. func stringPtr(s string) *string { return &s }