- Added support for Typesense as a search engine alongside Meilisearch and PostgreSQL. - Updated configuration structure to include Typesense parameters in `Config` and `.env.example`. - Enhanced search handler and service to accommodate external search clients and filters. - Implemented new tests for external search clients and search service functionalities. - Updated project checklist to reflect completion of multi-index search features and contextual snippets.
334 lines
9.0 KiB
Go
334 lines
9.0 KiB
Go
package search
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
|
)
|
|
|
|
const defaultSearchIndex = "ulti"
|
|
|
|
type externalSearchClient interface {
|
|
Search(ctx context.Context, params ExternalSearchParams) ([]Result, error)
|
|
}
|
|
|
|
type ExternalSearchParams struct {
|
|
ExternalID string
|
|
Query string
|
|
Types []string
|
|
Params query.ListParams
|
|
Filters SearchFilters
|
|
}
|
|
|
|
func newExternalSearchClient(engine string, opts ServiceOptions) externalSearchClient {
|
|
switch engine {
|
|
case "meilisearch":
|
|
if strings.TrimSpace(opts.MeilisearchURL) == "" {
|
|
return nil
|
|
}
|
|
index := strings.TrimSpace(opts.MeilisearchIndex)
|
|
if index == "" {
|
|
index = defaultSearchIndex
|
|
}
|
|
return &meilisearchClient{
|
|
baseURL: strings.TrimRight(strings.TrimSpace(opts.MeilisearchURL), "/"),
|
|
apiKey: strings.TrimSpace(opts.MeilisearchKey),
|
|
index: index,
|
|
http: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
case "typesense":
|
|
if strings.TrimSpace(opts.TypesenseURL) == "" {
|
|
return nil
|
|
}
|
|
collection := strings.TrimSpace(opts.TypesenseCollection)
|
|
if collection == "" {
|
|
collection = defaultSearchIndex
|
|
}
|
|
return &typesenseClient{
|
|
baseURL: strings.TrimRight(strings.TrimSpace(opts.TypesenseURL), "/"),
|
|
apiKey: strings.TrimSpace(opts.TypesenseKey),
|
|
collection: collection,
|
|
http: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type meilisearchClient struct {
|
|
baseURL string
|
|
apiKey string
|
|
index string
|
|
http *http.Client
|
|
}
|
|
|
|
func (c *meilisearchClient) Search(ctx context.Context, params ExternalSearchParams) ([]Result, error) {
|
|
payload := map[string]any{
|
|
"q": params.Query,
|
|
"limit": params.Params.Limit(),
|
|
"offset": params.Params.Offset(),
|
|
}
|
|
if filters := meiliFilters(params); len(filters) > 0 {
|
|
payload["filter"] = filters
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
u := c.baseURL + "/indexes/" + url.PathEscape(c.index) + "/search"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("meilisearch status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var parsed struct {
|
|
Hits []map[string]any `json:"hits"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
return mapExternalHits(parsed.Hits, params.Query), nil
|
|
}
|
|
|
|
func meiliFilters(params ExternalSearchParams) []string {
|
|
filters := make([]string, 0, 5)
|
|
if params.ExternalID != "" {
|
|
filters = append(filters, fmt.Sprintf("user_external_id = %q", params.ExternalID))
|
|
}
|
|
if len(params.Types) > 0 {
|
|
escaped := make([]string, 0, len(params.Types))
|
|
for _, t := range params.Types {
|
|
escaped = append(escaped, fmt.Sprintf("%q", t))
|
|
}
|
|
filters = append(filters, "type IN ["+strings.Join(escaped, ", ")+"]")
|
|
}
|
|
if params.Filters.AccountID != "" {
|
|
filters = append(filters, fmt.Sprintf("account_id = %q", params.Filters.AccountID))
|
|
}
|
|
if params.Params.From != nil {
|
|
filters = append(filters, fmt.Sprintf("date >= %q", params.Params.From.UTC().Format(time.RFC3339)))
|
|
}
|
|
if params.Params.To != nil {
|
|
filters = append(filters, fmt.Sprintf("date <= %q", params.Params.To.UTC().Format(time.RFC3339)))
|
|
}
|
|
return filters
|
|
}
|
|
|
|
type typesenseClient struct {
|
|
baseURL string
|
|
apiKey string
|
|
collection string
|
|
http *http.Client
|
|
}
|
|
|
|
func (c *typesenseClient) Search(ctx context.Context, params ExternalSearchParams) ([]Result, error) {
|
|
u, err := url.Parse(c.baseURL + "/collections/" + url.PathEscape(c.collection) + "/documents/search")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := u.Query()
|
|
q.Set("q", params.Query)
|
|
q.Set("query_by", "title,snippet,content,name,summary,path")
|
|
q.Set("per_page", strconv.Itoa(params.Params.Limit()))
|
|
q.Set("page", strconv.Itoa(params.Params.Page))
|
|
q.Set("highlight_affix_num_tokens", "6")
|
|
q.Set("include_fields", "id,type,title,snippet,date,account_id,path,highlights,_text_match")
|
|
if filter := typesenseFilter(params); filter != "" {
|
|
q.Set("filter_by", filter)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.apiKey != "" {
|
|
req.Header.Set("X-TYPESENSE-API-KEY", c.apiKey)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("typesense status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var parsed struct {
|
|
Hits []struct {
|
|
Document map[string]any `json:"document"`
|
|
TextMatch any `json:"text_match"`
|
|
} `json:"hits"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hits := make([]map[string]any, 0, len(parsed.Hits))
|
|
for _, h := range parsed.Hits {
|
|
doc := h.Document
|
|
if doc == nil {
|
|
doc = map[string]any{}
|
|
}
|
|
doc["_text_match"] = h.TextMatch
|
|
hits = append(hits, doc)
|
|
}
|
|
return mapExternalHits(hits, params.Query), nil
|
|
}
|
|
|
|
func typesenseFilter(params ExternalSearchParams) string {
|
|
parts := make([]string, 0, 5)
|
|
if params.ExternalID != "" {
|
|
parts = append(parts, fmt.Sprintf("user_external_id:=%s", params.ExternalID))
|
|
}
|
|
if len(params.Types) > 0 {
|
|
parts = append(parts, "type:=["+strings.Join(params.Types, ",")+"]")
|
|
}
|
|
if params.Filters.AccountID != "" {
|
|
parts = append(parts, fmt.Sprintf("account_id:=%s", params.Filters.AccountID))
|
|
}
|
|
if params.Params.From != nil {
|
|
parts = append(parts, fmt.Sprintf("date:>=%s", params.Params.From.UTC().Format(time.RFC3339)))
|
|
}
|
|
if params.Params.To != nil {
|
|
parts = append(parts, fmt.Sprintf("date:<=%s", params.Params.To.UTC().Format(time.RFC3339)))
|
|
}
|
|
return strings.Join(parts, " && ")
|
|
}
|
|
|
|
func mapExternalHits(hits []map[string]any, queryText string) []Result {
|
|
results := make([]Result, 0, len(hits))
|
|
for _, hit := range hits {
|
|
r := Result{
|
|
Type: asString(firstNonNil(hit["type"], hit["kind"])),
|
|
ID: asString(firstNonNil(hit["id"], hit["_id"], hit["uid"], hit["path"])),
|
|
Title: asString(firstNonNil(hit["title"], hit["name"], hit["subject"], hit["summary"])),
|
|
Snippet: asString(firstNonNil(hit["snippet"], hit["content"], hit["description"], hit["path"])),
|
|
AccountID: asString(firstNonNil(hit["account_id"], hit["account"], hit["book_id"], hit["calendar_id"])),
|
|
Score: asFloat(firstNonNil(hit["score"], hit["_rankingScore"], hit["_text_match"])),
|
|
}
|
|
if r.Type == "" {
|
|
r.Type = "mail"
|
|
}
|
|
if r.ID == "" {
|
|
r.ID = r.Type + ":" + r.Title
|
|
}
|
|
if dt := asString(firstNonNil(hit["date"], hit["updated_at"], hit["start"])); dt != "" {
|
|
if parsed, ok := parseDateString(dt); ok {
|
|
r.Date = parsed.UTC()
|
|
}
|
|
}
|
|
if r.Snippet == "" {
|
|
r.Snippet = r.Title
|
|
}
|
|
r.Title = highlightTerms(r.Title, queryText)
|
|
r.Snippet = contextualSnippet(r.Snippet, queryText, 220)
|
|
if r.Score == 0 {
|
|
r.Score = textMatchScore(joinNonEmpty(" ", r.Title, r.Snippet), queryText, 1.0)
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func finalizeExternalResults(results []Result, queryText string, params query.ListParams) {
|
|
for i := range results {
|
|
if strings.TrimSpace(results[i].Title) == "" {
|
|
results[i].Title = results[i].ID
|
|
}
|
|
if strings.TrimSpace(results[i].Snippet) == "" {
|
|
results[i].Snippet = results[i].Title
|
|
}
|
|
results[i].Title = highlightTerms(results[i].Title, queryText)
|
|
results[i].Snippet = contextualSnippet(results[i].Snippet, queryText, 220)
|
|
if results[i].Score == 0 {
|
|
results[i].Score = textMatchScore(joinNonEmpty(" ", results[i].Title, results[i].Snippet), queryText, 1.0)
|
|
}
|
|
|
|
if ts, ok := dateFromAny(results[i].Date); ok {
|
|
if params.From != nil && ts.Before(*params.From) {
|
|
results[i].Score = -1
|
|
continue
|
|
}
|
|
if params.To != nil && ts.After(*params.To) {
|
|
results[i].Score = -1
|
|
continue
|
|
}
|
|
results[i].Date = ts.UTC()
|
|
results[i].Score += recencyBoost(ts)
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstNonNil(values ...any) any {
|
|
for _, v := range values {
|
|
if v != nil {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asString(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
switch v := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case fmt.Stringer:
|
|
return strings.TrimSpace(v.String())
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
|
|
func asFloat(value any) float64 {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return v
|
|
case float32:
|
|
return float64(v)
|
|
case int:
|
|
return float64(v)
|
|
case int64:
|
|
return float64(v)
|
|
case json.Number:
|
|
if f, err := v.Float64(); err == nil {
|
|
return f
|
|
}
|
|
case string:
|
|
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
|
return f
|
|
}
|
|
}
|
|
return 0
|
|
}
|