Files
2026-05-30 02:33:57 +05:30

571 lines
17 KiB
Go

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
}
// `exact=1` (or "true") disables typo tolerance + prefix expansion so the
// query only matches literal keywords. Stopword filtering still applies —
// "letter to mukunda" with exact=1 still drops "letter"/"to" so the search
// is effectively the literal word "mukunda" with no fuzzy variants.
exact := parseBoolParam(r.URL.Query().Get("exact"))
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, exact)
} else if len(requestedTypes) == 1 {
// Single collection — use singleSearch for efficiency
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact)
} else {
// Multiple collections — use multiSearch with a subset
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact)
}
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. When `exact` is true,
// typo tolerance and prefix expansion are turned off so only literal-keyword
// matches come back.
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) {
if collections == nil {
collections = []string{
"verses", "prose", "letters", "transcriptions", "glossary",
"essays", "journals", "magazines", "other_documents",
}
}
exhaustive := true
prioritizeExact := true
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
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(prefix),
NumTypos: stringPtr(numTypos),
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, exact bool) ([]searchResultItem, int) {
queryBy, ok := collectionQueryByFields[collection]
if !ok {
return []searchResultItem{}, 0
}
exhaustive := true
prioritizeExact := true
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
params := &api.SearchCollectionParams{
Q: stringPtr(query),
QueryBy: stringPtr(queryBy),
Page: &page,
PerPage: &limit,
Prefix: stringPtr(prefix),
NumTypos: stringPtr(numTypos),
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
}
// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive).
// Everything else (including the empty string) is false.
func parseBoolParam(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params
// that actually disable fuzzy matching:
//
// exact=false → prefix=true, num_typos=2 (default forgiving search)
// exact=true → prefix=false, num_typos=0 (literal-keyword only)
//
// Stopword filtering is left unchanged in both modes.
func fuzzinessKnobs(exact bool) (prefix, numTypos string) {
if exact {
return "false", "0"
}
return "true", "2"
}