545 lines
16 KiB
Go
545 lines
16 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",
|
|
}
|
|
|
|
// collectionTextMatchType lets specific collections opt-in to `sum_score`
|
|
// instead of the Typesense default `max_score`.
|
|
//
|
|
// `sum_score` boosts documents that match in MULTIPLE fields, so e.g. a letter
|
|
// whose `recipient_display` AND `content` both contain the query ranks above
|
|
// a letter that only matches in `content`. Useful for collections that have a
|
|
// strong metadata field (recipient, title) tied to the user's intent.
|
|
//
|
|
// `max_score` (default elsewhere) preserves single-field dominance — important
|
|
// for verses where the canonical verse should beat commentaries that quote it.
|
|
var collectionTextMatchType = map[string]string{
|
|
"letters": "sum_score",
|
|
}
|
|
|
|
// 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,
|
|
// 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)
|
|
}
|
|
if tmt := collectionTextMatchType[col]; tmt != "" {
|
|
params.TextMatchType = stringPtr(tmt)
|
|
}
|
|
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,
|
|
// 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)
|
|
}
|
|
if tmt := collectionTextMatchType[collection]; tmt != "" {
|
|
params.TextMatchType = stringPtr(tmt)
|
|
}
|
|
|
|
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
|
|
}
|