Files
gochat/internal/search/engine_meili.go
T

336 lines
9.8 KiB
Go

package search
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
type MeiliSearchEngine struct {
client *resty.Client
indexPrefix string
}
type meiliSearchResponse struct {
Hits []map[string]interface{} `json:"hits"`
EstimatedTotalHits int64 `json:"estimatedTotalHits"`
TotalHits int64 `json:"totalHits"`
}
func NewMeiliSearchEngine(cfg EngineConfig) *MeiliSearchEngine {
cfg = normalizeEngineConfig(cfg)
client := resty.New().SetBaseURL(strings.TrimRight(cfg.Host, "/"))
if cfg.APIKey != "" {
client.SetAuthToken(cfg.APIKey)
}
if cfg.TimeoutSeconds > 0 {
client.SetTimeout(time.Duration(cfg.TimeoutSeconds) * time.Second)
}
return &MeiliSearchEngine{client: client, indexPrefix: cfg.IndexPrefix}
}
func (e *MeiliSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
if filter == nil {
filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
}
if filter.Page < 1 {
filter.Page = 1
}
if filter.PerPage < 1 {
filter.PerPage = DefaultPerPage
}
results := make([]SearchResult, 0)
byType := map[string]int64{}
var total int64
for _, docType := range searchableTypes(filter) {
resp, err := e.searchIndex(ctx, docType, accountID, query, filter)
if err != nil {
return nil, err
}
count := resp.EstimatedTotalHits
if count == 0 && resp.TotalHits > 0 {
count = resp.TotalHits
}
byType[string(docType)] = count
total += count
for _, hit := range resp.Hits {
results = append(results, hitToSearchResult(docType, hit))
}
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].Score == results[j].Score {
return results[i].ID > results[j].ID
}
return results[i].Score > results[j].Score
})
return &SearchResponse{
Results: results,
TotalCount: total,
ByType: byType,
Page: filter.Page,
PerPage: filter.PerPage,
Query: strings.TrimSpace(query),
}, nil
}
func (e *MeiliSearchEngine) IndexDocument(ctx context.Context, doc SearchDocument) error {
doc.ensureUID()
resp, err := e.client.R().
SetContext(ctx).
SetBody([]SearchDocument{doc}).
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(doc.Type)))
return meiliError(resp, err, "index document")
}
func (e *MeiliSearchEngine) IndexBatch(ctx context.Context, docs []SearchDocument) error {
grouped := map[SearchResultType][]SearchDocument{}
for _, doc := range docs {
doc.ensureUID()
grouped[doc.Type] = append(grouped[doc.Type], doc)
}
for docType, batch := range grouped {
resp, err := e.client.R().
SetContext(ctx).
SetBody(batch).
Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(docType)))
if err := meiliError(resp, err, "index batch"); err != nil {
return err
}
}
return nil
}
func (e *MeiliSearchEngine) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error {
resp, err := e.client.R().
SetContext(ctx).
Delete(fmt.Sprintf("/indexes/%s/documents/%s", e.indexName(docType), documentUID(docType, accountID, id)))
return meiliError(resp, err, "delete document")
}
func (e *MeiliSearchEngine) Bootstrap(ctx context.Context) error {
for _, docType := range searchableTypes(nil) {
if err := e.ensureIndex(ctx, docType); err != nil {
return err
}
if err := e.applySettings(ctx, docType); err != nil {
return err
}
}
return nil
}
func (e *MeiliSearchEngine) Close() error {
return nil
}
func (e *MeiliSearchEngine) searchIndex(ctx context.Context, docType SearchResultType, accountID uint, query string, filter *SearchFilter) (*meiliSearchResponse, error) {
body := map[string]interface{}{
"q": strings.TrimSpace(query),
"offset": filter.Offset(),
"limit": filter.PerPage,
"filter": e.filterExpression(accountID, docType, filter),
"showRankingScore": true,
}
if sortExpr := sortExpression(filter); sortExpr != "" {
body["sort"] = []string{sortExpr}
}
var out meiliSearchResponse
resp, err := e.client.R().
SetContext(ctx).
SetBody(body).
SetResult(&out).
Post(fmt.Sprintf("/indexes/%s/search", e.indexName(docType)))
if err := meiliError(resp, err, "search"); err != nil {
return nil, err
}
return &out, nil
}
func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResultType) error {
uid := e.indexName(docType)
resp, err := e.client.R().SetContext(ctx).Get(fmt.Sprintf("/indexes/%s", uid))
if err != nil {
return err
}
if resp.StatusCode() != http.StatusNotFound {
return meiliError(resp, nil, "check index")
}
resp, err = e.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{"uid": uid, "primaryKey": "uid"}).
Post("/indexes")
return meiliError(resp, err, "create index")
}
func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error {
settings := map[string]interface{}{
"searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"},
"filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"},
"sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"},
}
resp, err := e.client.R().
SetContext(ctx).
SetBody(settings).
Patch(fmt.Sprintf("/indexes/%s/settings", e.indexName(docType)))
return meiliError(resp, err, "apply settings")
}
func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResultType, filter *SearchFilter) string {
parts := []string{fmt.Sprintf("account_id = %d", accountID)}
if filter == nil {
return strings.Join(parts, " AND ")
}
if docType == ResultTypeConversation {
parts = appendListFilter(parts, "status", filter.Status)
parts = appendListFilter(parts, "priority", filter.Priority)
if filter.AssigneeID != nil {
parts = append(parts, fmt.Sprintf("assignee_id = %d", *filter.AssigneeID))
}
if filter.TeamID != nil {
parts = append(parts, fmt.Sprintf("team_id = %d", *filter.TeamID))
}
parts = appendListFilter(parts, "labels", filter.Labels)
}
if filter.InboxID != nil {
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if docType == ResultTypeMessage {
if filter.MessageType != "" {
parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType))
}
if filter.SenderType != "" {
parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType))
}
if filter.SenderID != nil {
parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID))
}
if filter.ContentType != "" {
parts = append(parts, fmt.Sprintf("content_type = %q", filter.ContentType))
}
if filter.Private != nil {
parts = append(parts, fmt.Sprintf("private = %t", *filter.Private))
}
}
if docType == ResultTypeContact && filter.ContactSource != "" {
parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource))
}
if docType == ResultTypeArticle || docType == ResultTypeHelpCenter {
if filter.PortalID != nil {
parts = append(parts, fmt.Sprintf("portal_id = %d", *filter.PortalID))
}
if filter.ArticleStatus != "" {
parts = append(parts, fmt.Sprintf("status = %q", filter.ArticleStatus))
}
if filter.ArticleLocale != "" {
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
}
}
if filter.DateFrom != nil {
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
}
if filter.DateTo != nil {
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
}
return strings.Join(parts, " AND ")
}
func (e *MeiliSearchEngine) indexName(docType SearchResultType) string {
return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s"
}
func appendListFilter(parts []string, field string, values []string) []string {
if len(values) == 0 {
return parts
}
quoted := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
quoted = append(quoted, fmt.Sprintf("%s = %q", field, value))
}
}
if len(quoted) > 0 {
parts = append(parts, "("+strings.Join(quoted, " OR ")+")")
}
return parts
}
func sortExpression(filter *SearchFilter) string {
if filter == nil {
return ""
}
field := filter.SortBy
switch field {
case "", "created", "created_at":
field = "created_at_ts"
case "updated", "updated_at":
field = "updated_at_ts"
case "id":
field = "id"
default:
return ""
}
dir := filter.SortOrder
if dir != "asc" {
dir = "desc"
}
return field + ":" + dir
}
func hitToSearchResult(docType SearchResultType, hit map[string]interface{}) SearchResult {
result := SearchResult{Type: docType, Data: hit}
if v, ok := hit["type"].(string); ok && v != "" {
result.Type = SearchResultType(v)
}
result.ID = uintFromHit(hit["id"])
result.AccountID = uintFromHit(hit["account_id"])
if snippet, ok := hit["snippet"].(string); ok {
result.Snippet = snippet
} else if title, ok := hit["title"].(string); ok {
result.Snippet = title
}
if score, ok := hit["_rankingScore"].(float64); ok {
result.Score = score
} else {
result.Score = 1
}
return result
}
func uintFromHit(v interface{}) uint {
switch n := v.(type) {
case float64:
return uint(n)
case int:
return uint(n)
case int64:
return uint(n)
case uint:
return n
default:
return 0
}
}
func meiliError(resp *resty.Response, err error, action string) error {
if err != nil {
return fmt.Errorf("meilisearch %s: %w", action, err)
}
if resp == nil {
return nil
}
if resp.StatusCode() >= 400 {
return fmt.Errorf("meilisearch %s: status=%d body=%s", action, resp.StatusCode(), string(resp.Body()))
}
return nil
}