Files
gochat/backend/internal/search/engine.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

460 lines
15 KiB
Go

package search
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
)
const (
EngineMeilisearch = "meilisearch"
EngineDB = "db"
)
// EngineConfig is the search package's stable configuration boundary. The app
// layer maps internal/config.SearchConfig into this struct.
type EngineConfig struct {
Engine string
Host string
APIKey string
IndexPrefix string
TimeoutSeconds int
}
// SearchEngine hides the full-text backend behind one contract. Meilisearch is
// the production parity engine; DB implementations are local development only.
type SearchEngine interface {
Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error)
IndexDocument(ctx context.Context, doc SearchDocument) error
IndexBatch(ctx context.Context, docs []SearchDocument) error
DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error
Bootstrap(ctx context.Context) error
Close() error
}
// SearchDocument is the normalized payload stored in Meilisearch. Data keeps the
// entity payload available for Chatwoot-compatible frontend responses.
type SearchDocument struct {
UID string `json:"uid"`
ID uint `json:"id"`
Type SearchResultType `json:"type"`
AccountID uint `json:"account_id"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
Snippet string `json:"snippet,omitempty"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
MessageType string `json:"message_type,omitempty"`
SenderType string `json:"sender_type,omitempty"`
SenderID *uint `json:"sender_id,omitempty"`
ContentType string `json:"content_type,omitempty"`
Private bool `json:"private"`
ContactSource string `json:"contact_source,omitempty"`
ContactType string `json:"contact_type,omitempty"`
ContactHasDetails bool `json:"contact_has_details"`
Labels []string `json:"labels,omitempty"`
AssigneeID *uint `json:"assignee_id,omitempty"`
TeamID *uint `json:"team_id,omitempty"`
InboxID *uint `json:"inbox_id,omitempty"`
ContactID *uint `json:"contact_id,omitempty"`
ConversationID *uint `json:"conversation_id,omitempty"`
PortalID *uint `json:"portal_id,omitempty"`
Locale string `json:"locale,omitempty"`
CreatedAtTS int64 `json:"created_at_ts"`
UpdatedAtTS int64 `json:"updated_at_ts"`
LastActivityAtTS int64 `json:"last_activity_at_ts,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
func (d *SearchDocument) ensureUID() {
if d.UID == "" {
d.UID = documentUID(d.Type, d.AccountID, d.ID)
}
}
func documentUID(docType SearchResultType, accountID uint, id uint) string {
return fmt.Sprintf("%d_%s_%d", accountID, docType, id)
}
func normalizeEngineConfig(cfg EngineConfig) EngineConfig {
cfg.Engine = strings.ToLower(strings.TrimSpace(cfg.Engine))
if cfg.Engine == "" {
cfg.Engine = EngineMeilisearch
}
if cfg.Host == "" {
cfg.Host = "http://localhost:7700"
}
if cfg.IndexPrefix == "" {
cfg.IndexPrefix = "gochat_"
}
if cfg.TimeoutSeconds == 0 {
cfg.TimeoutSeconds = 5
}
return cfg
}
// NewSearchEngine builds the configured search backend.
func NewSearchEngine(cfg EngineConfig, fallbackRepo SearchRepoInterface) (SearchEngine, error) {
cfg = normalizeEngineConfig(cfg)
switch cfg.Engine {
case EngineMeilisearch:
return NewMeiliSearchEngine(cfg), nil
case EngineDB:
if fallbackRepo == nil {
return nil, fmt.Errorf("db search engine requires a fallback repository")
}
return NewSearchEngineDB(fallbackRepo), nil
default:
return nil, fmt.Errorf("unsupported search engine %q", cfg.Engine)
}
}
func searchableTypes(filter *SearchFilter) []SearchResultType {
if filter != nil && len(filter.Types) > 0 {
return filter.Types
}
return []SearchResultType{
ResultTypeConversation,
ResultTypeMessage,
ResultTypeContact,
ResultTypeCompany,
ResultTypeArticle,
ResultTypeHelpCenter,
}
}
func timestamp(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func timestampPtr(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func ConversationDocument(conv model.Conversation) SearchDocument {
inboxID := conv.InboxID
content := conversationSearchContent(conv)
doc := SearchDocument{
ID: conv.ID,
Type: ResultTypeConversation,
AccountID: conv.AccountID,
Title: conversationSearchTitle(conv),
Content: content,
Status: conv.Status,
Priority: conv.Priority,
Labels: splitLabels(conv.Labels),
AssigneeID: conv.AssigneeID,
TeamID: conv.TeamID,
InboxID: &inboxID,
ContactID: &conv.ContactID,
CreatedAtTS: timestamp(conv.CreatedAt),
UpdatedAtTS: timestamp(conv.UpdatedAt),
LastActivityAtTS: timestampPtr(conv.LastActivityAt),
Data: conversationSearchData(conv),
}
doc.Snippet = conversationSnippet(&conv, "")
doc.ensureUID()
return doc
}
func conversationSearchData(conv model.Conversation) map[string]interface{} {
data := map[string]interface{}{"conversation": conv}
if conv.Contact != nil {
data["contact"] = map[string]interface{}{
"email": conv.Contact.Email,
"id": conv.Contact.ID,
"name": conv.Contact.Name,
"phone_number": conv.Contact.PhoneNumber,
"identifier": conv.Contact.Identifier,
"additional_attributes": conv.Contact.AdditionalAttributes,
"last_activity_at": timestampPtr(conv.Contact.LastActivityAt),
}
}
if conv.Inbox != nil {
data["inbox"] = map[string]interface{}{
"id": conv.Inbox.ID,
"channel_id": conv.Inbox.ChannelID,
"name": conv.Inbox.Name,
"channel_type": conv.Inbox.ChannelType,
}
}
if conv.Assignee != nil {
data["agent"] = map[string]interface{}{
"id": conv.Assignee.ID,
"available_name": firstNonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name),
"email": conv.Assignee.Email,
"name": conv.Assignee.Name,
"role": firstNonEmpty(conv.Assignee.Role, "agent"),
}
}
if len(conv.Messages) > 0 {
message := conv.Messages[0]
data["message"] = map[string]interface{}{
"id": message.ID,
"content": message.Content,
"account_id": message.AccountID,
"inbox_id": message.InboxID,
"conversation_id": message.ConversationID,
"message_type": message.MessageType,
"content_type": message.ContentType,
"status": message.Status,
"content_attributes": message.ContentAttributes,
"additional_attributes": message.AdditionalAttributes,
"created_at": timestamp(message.CreatedAt),
"private": message.Private,
"source_id": message.SourceID,
}
}
return data
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func conversationSearchTitle(conv model.Conversation) string {
if conv.DisplayID != nil && *conv.DisplayID != 0 {
return fmt.Sprintf("Conversation #%d", *conv.DisplayID)
}
return fmt.Sprintf("Conversation #%d", conv.ID)
}
func conversationSearchContent(conv model.Conversation) string {
parts := make([]string, 0, 5)
if conv.DisplayID != nil && *conv.DisplayID != 0 {
parts = append(parts, fmt.Sprintf("%d", *conv.DisplayID))
}
if conv.Contact != nil {
parts = append(parts, conv.Contact.Name, conv.Contact.Email, conv.Contact.PhoneNumber, conv.Contact.Identifier)
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func MessageDocument(msg model.Message) SearchDocument {
inboxID := msg.InboxID
conversationID := msg.ConversationID
messageData := messageDocumentData(msg)
doc := SearchDocument{
ID: msg.ID,
Type: ResultTypeMessage,
AccountID: msg.AccountID,
Title: fmt.Sprintf("Message #%d", msg.ID),
Content: msg.Content,
Snippet: messageSnippet(&msg, ""),
Status: msg.Status,
MessageType: msg.MessageType,
SenderType: msg.SenderType,
SenderID: msg.SenderID,
ContentType: msg.ContentType,
Private: msg.Private,
InboxID: &inboxID,
ConversationID: &conversationID,
CreatedAtTS: timestamp(msg.CreatedAt),
UpdatedAtTS: timestamp(msg.UpdatedAt),
Data: map[string]interface{}{"message": messageData},
}
doc.ensureUID()
return doc
}
func messageDocumentData(msg model.Message) map[string]interface{} {
conversationID := msg.ConversationID
if msg.Conversation != nil {
conversationID = messageConversationDisplayID(msg.Conversation)
}
data := map[string]interface{}{
"id": msg.ID,
"content": msg.Content,
"account_id": msg.AccountID,
"inbox_id": msg.InboxID,
"conversation_id": conversationID,
"message_type": msg.MessageType,
"content_type": firstNonEmpty(msg.ContentType, "text"),
"status": firstNonEmpty(msg.Status, "sent"),
"content_attributes": jsonObject(msg.ContentAttributes),
"created_at": timestamp(msg.CreatedAt),
"private": msg.Private,
"source_id": msg.SourceID,
}
if msg.EchoID != "" {
data["echo_id"] = msg.EchoID
}
if len(msg.Attachments) > 0 {
attachments := make([]map[string]interface{}, 0, len(msg.Attachments))
for i := range msg.Attachments {
attachments = append(attachments, attachmentDocumentData(msg.Attachments[i]))
}
data["attachments"] = attachments
}
return data
}
func messageConversationDisplayID(conversation *model.Conversation) uint {
if conversation != nil && conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
if conversation != nil {
return conversation.ID
}
return 0
}
func attachmentDocumentData(attachment model.Attachment) map[string]interface{} {
dataURL := firstNonEmpty(attachment.FileURL, attachment.ExternalURL)
data := map[string]interface{}{
"id": attachment.ID,
"message_id": attachment.MessageID,
"file_type": attachment.FileType,
"account_id": attachment.AccountID,
"data_url": dataURL,
"thumb_url": attachment.ThumbURL,
"file_size": attachment.FileSize,
"extension": strings.TrimPrefix(filepath.Ext(attachment.FileName), "."),
"width": attachment.Width,
"height": attachment.Height,
}
if attachment.FileType == "audio" {
data["transcribed_text"] = metadataValue(attachment.Metadata, "transcribed_text")
}
return data
}
func jsonObject(raw []byte) map[string]interface{} {
if len(raw) == 0 || string(raw) == "null" {
return map[string]interface{}{}
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil || out == nil {
return map[string]interface{}{}
}
return out
}
func metadataValue(raw string, key string) string {
if strings.TrimSpace(raw) == "" {
return ""
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return ""
}
if value, ok := data[key].(string); ok {
return value
}
return ""
}
func ContactDocument(contact model.Contact) SearchDocument {
content := strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " "))
doc := SearchDocument{
ID: contact.ID,
Type: ResultTypeContact,
AccountID: contact.AccountID,
Title: contact.Name,
Content: content,
Snippet: contactSnippet(&contact, ""),
ContactSource: contact.ContactType,
ContactType: contact.ContactType,
ContactHasDetails: content != "",
Labels: contact.Labels,
CreatedAtTS: timestamp(contact.CreatedAt),
UpdatedAtTS: timestamp(contact.UpdatedAt),
LastActivityAtTS: timestampPtr(contact.LastActivityAt),
Data: map[string]interface{}{"contact": contact},
}
doc.ensureUID()
return doc
}
func CompanyDocument(company model.Company) SearchDocument {
doc := SearchDocument{
ID: company.ID,
Type: ResultTypeCompany,
AccountID: company.AccountID,
Title: company.Name,
Content: strings.TrimSpace(strings.Join([]string{company.Description, company.Domain, company.WebsiteURL}, " ")),
Snippet: company.Name,
CreatedAtTS: timestamp(company.CreatedAt),
UpdatedAtTS: timestamp(company.UpdatedAt),
Data: map[string]interface{}{"company": company},
}
doc.ensureUID()
return doc
}
func ArticleDocument(article model.Article) SearchDocument {
portalID := article.PortalID
articleData := articleDocumentData(article)
doc := SearchDocument{
ID: article.ID,
Type: ResultTypeArticle,
AccountID: article.AccountID,
Title: article.Title,
Content: strings.TrimSpace(strings.Join([]string{article.Description, article.Content}, " ")),
Snippet: articleSnippet(&article, ""),
Status: article.Status,
PortalID: &portalID,
Locale: article.Locale,
CreatedAtTS: timestamp(article.CreatedAt),
UpdatedAtTS: timestamp(article.UpdatedAt),
Data: map[string]interface{}{"article": articleData},
}
doc.ensureUID()
return doc
}
func articleDocumentData(article model.Article) map[string]interface{} {
portalSlug := ""
if article.Portal.Slug != "" {
portalSlug = article.Portal.Slug
}
categoryName := ""
if article.Category != nil && article.Category.Name != "" {
categoryName = article.Category.Name
}
return map[string]interface{}{
"id": article.ID,
"title": article.Title,
"locale": article.Locale,
"content": article.Content,
"slug": article.Slug,
"portal_slug": portalSlug,
"account_id": article.AccountID,
"category_name": categoryName,
"status": article.Status,
"updated_at": timestamp(article.UpdatedAt),
}
}
func splitLabels(raw string) []string {
if raw == "" {
return nil
}
raw = strings.Trim(raw, "[]")
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '"' || r == '\'' })
labels := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
labels = append(labels, part)
}
}
return labels
}