ultisuite-backend/internal/search/external_clients.go
R3D347HR4Y 0435e27ce6 Enhance search functionality with multi-engine support and configuration updates
- 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.
2026-05-22 19:14:27 +02:00

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
}