115 lines
2.3 KiB
Go
115 lines
2.3 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
|
|
"AI-Expert-Sidebar/internal/database"
|
|
"AI-Expert-Sidebar/internal/models"
|
|
)
|
|
|
|
const maxResults = 5
|
|
|
|
var ErrDBUnavailable = errors.New("database unavailable")
|
|
|
|
// SearchResult is the DTO returned to the frontend.
|
|
type SearchResult struct {
|
|
ID uint `json:"id"`
|
|
Question string `json:"question"`
|
|
Answer string `json:"answer"`
|
|
Category string `json:"category"`
|
|
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
|
|
IsFallback bool `json:"is_fallback"`
|
|
}
|
|
|
|
// SearchKnowledge performs fuzzy search in the active knowledge library.
|
|
func SearchKnowledge(query string) ([]SearchResult, error) {
|
|
db := database.Get()
|
|
if db == nil {
|
|
return nil, ErrDBUnavailable
|
|
}
|
|
if len(query) < 1 {
|
|
return nil, nil
|
|
}
|
|
|
|
type res struct {
|
|
rows []SearchResult
|
|
err error
|
|
}
|
|
ch := make(chan res, 1)
|
|
|
|
go func() {
|
|
var total int64
|
|
db.Model(&models.Entry{}).Count(&total)
|
|
if total == 0 {
|
|
ch <- res{[]SearchResult{}, nil}
|
|
return
|
|
}
|
|
|
|
like := "%" + query + "%"
|
|
var rows []models.Entry
|
|
err := db.Where("keyword LIKE ? OR question LIKE ?", like, like).
|
|
Order("updated_at DESC").Limit(maxResults).Find(&rows).Error
|
|
if err != nil {
|
|
ch <- res{nil, err}
|
|
return
|
|
}
|
|
|
|
isFallback := len(rows) == 0
|
|
if isFallback {
|
|
log.Printf("[Search] No match for %q, returning fallback", query)
|
|
db.Order("updated_at DESC").Limit(3).Find(&rows)
|
|
}
|
|
|
|
out := make([]SearchResult, 0, len(rows))
|
|
for _, r := range rows {
|
|
score := 0
|
|
if !isFallback {
|
|
if containsIgnoreCase(r.Keyword, query) {
|
|
score = 2
|
|
} else if containsIgnoreCase(r.Question, query) {
|
|
score = 1
|
|
}
|
|
}
|
|
out = append(out, SearchResult{
|
|
ID: r.ID, Question: r.Question, Answer: r.Answer,
|
|
Category: r.Category, Score: score, IsFallback: isFallback,
|
|
})
|
|
}
|
|
ch <- res{out, nil}
|
|
}()
|
|
|
|
r := <-ch
|
|
return r.rows, r.err
|
|
}
|
|
|
|
func containsIgnoreCase(s, sub string) bool {
|
|
if len(sub) == 0 {
|
|
return true
|
|
}
|
|
sl, subl := []rune(s), []rune(sub)
|
|
if len(sl) < len(subl) {
|
|
return false
|
|
}
|
|
for i := 0; i <= len(sl)-len(subl); i++ {
|
|
match := true
|
|
for j, c := range subl {
|
|
if toLower(sl[i+j]) != toLower(c) {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toLower(r rune) rune {
|
|
if r >= 'A' && r <= 'Z' {
|
|
return r + 32
|
|
}
|
|
return r
|
|
}
|