93 lines
2.3 KiB
Go
93 lines
2.3 KiB
Go
package wing
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
|
)
|
|
|
|
const (
|
|
maxQueryTerms = 12
|
|
minTermLen = 5
|
|
hitsPerWing = 3
|
|
)
|
|
|
|
// BM25Scorer scores a note against each wing by running the brain's own BM25
|
|
// search scoped to that wing. It is deliberately a different mechanism from
|
|
// the LLM, so agreement between the two is evidence and not an echo.
|
|
type BM25Scorer struct {
|
|
brainDir string
|
|
wings []string
|
|
exclude string
|
|
}
|
|
|
|
// NewBM25Scorer scores against wings under brainDir. exclude is a brain-
|
|
// relative path ignored in results (the backtest sample itself); "" for none.
|
|
func NewBM25Scorer(brainDir string, wings []string, exclude string) *BM25Scorer {
|
|
return &BM25Scorer{brainDir: brainDir, wings: wings, exclude: exclude}
|
|
}
|
|
|
|
// Score implements Scorer. A wing whose search fails scores 0 rather than
|
|
// failing the note: the Resolver treats "no clear opinion" as an Exception.
|
|
func (b *BM25Scorer) Score(text string) map[string]float64 {
|
|
query := strings.Join(topTerms(text), " ")
|
|
out := map[string]float64{}
|
|
if query == "" {
|
|
return out
|
|
}
|
|
for _, w := range b.wings {
|
|
hits, err := search.Query(b.brainDir, search.QueryOptions{Query: query, Wing: w, Limit: hitsPerWing + 1})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var sum float64
|
|
n := 0
|
|
for _, h := range hits {
|
|
if b.exclude != "" && h.Path == b.exclude {
|
|
continue
|
|
}
|
|
if n == hitsPerWing {
|
|
break
|
|
}
|
|
sum += float64(h.Score)
|
|
n++
|
|
}
|
|
if sum > 0 {
|
|
out[w] = sum
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// topTerms picks the most frequent longer words, ties broken alphabetically
|
|
// so the query is deterministic.
|
|
func topTerms(text string) []string {
|
|
if len(text) > maxNoteChars {
|
|
text = text[:maxNoteChars]
|
|
}
|
|
counts := map[string]int{}
|
|
for _, f := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
}) {
|
|
if len([]rune(f)) >= minTermLen {
|
|
counts[f]++
|
|
}
|
|
}
|
|
terms := make([]string, 0, len(counts))
|
|
for t := range counts {
|
|
terms = append(terms, t)
|
|
}
|
|
sort.Slice(terms, func(i, j int) bool {
|
|
if counts[terms[i]] != counts[terms[j]] {
|
|
return counts[terms[i]] > counts[terms[j]]
|
|
}
|
|
return terms[i] < terms[j]
|
|
})
|
|
if len(terms) > maxQueryTerms {
|
|
terms = terms[:maxQueryTerms]
|
|
}
|
|
return terms
|
|
}
|