search added
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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",
|
||||
}
|
||||
|
||||
// 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
|
||||
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,
|
||||
}
|
||||
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
|
||||
params := &api.SearchCollectionParams{
|
||||
Q: stringPtr(query),
|
||||
QueryBy: stringPtr(queryBy),
|
||||
Page: &page,
|
||||
PerPage: &limit,
|
||||
Prefix: stringPtr("true"),
|
||||
NumTypos: stringPtr("2"),
|
||||
ExhaustiveSearch: &exhaustive,
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user