2026-06-26 08:37:04 +00:00
|
|
|
package knowledge
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"haixun-backend/internal/library/websearch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type BraveSearchLocale struct {
|
|
|
|
|
Country string
|
|
|
|
|
SearchLang string
|
|
|
|
|
UserLocation string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type BraveCollectConfig struct {
|
|
|
|
|
ResultsPerQuery int
|
|
|
|
|
MinSourcesBeforeStop int
|
|
|
|
|
MaxSourcesCap int
|
|
|
|
|
Concurrency int
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
const minUsefulSourceRunes = 24
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig {
|
|
|
|
|
out := BraveCollectConfig{
|
|
|
|
|
ResultsPerQuery: cfg.ResultsPerQuery,
|
|
|
|
|
MinSourcesBeforeStop: cfg.MinSourcesBeforeStop,
|
|
|
|
|
MaxSourcesCap: cfg.MaxSourcesCap,
|
|
|
|
|
Concurrency: cfg.BraveCollectConcurrency,
|
|
|
|
|
}
|
|
|
|
|
if out.ResultsPerQuery <= 0 {
|
|
|
|
|
out.ResultsPerQuery = 8
|
|
|
|
|
}
|
|
|
|
|
if out.MinSourcesBeforeStop <= 0 {
|
|
|
|
|
out.MinSourcesBeforeStop = 18
|
|
|
|
|
}
|
|
|
|
|
if out.MaxSourcesCap <= 0 {
|
|
|
|
|
out.MaxSourcesCap = 32
|
|
|
|
|
}
|
|
|
|
|
if out.Concurrency <= 0 {
|
|
|
|
|
out.Concurrency = 4
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func DefaultBraveCollectConfig() BraveCollectConfig {
|
|
|
|
|
cfg, err := loadQueryConfig()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return BraveCollectConfig{ResultsPerQuery: 8, MinSourcesBeforeStop: 18, MaxSourcesCap: 32, Concurrency: 4}
|
|
|
|
|
}
|
|
|
|
|
return BraveCollectConfigFromQueryCfg(cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CollectBraveSources(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
client websearch.Client,
|
|
|
|
|
locale BraveSearchLocale,
|
|
|
|
|
queries []string,
|
|
|
|
|
cfg BraveCollectConfig,
|
|
|
|
|
onProgress func(i, total int),
|
|
|
|
|
heartbeat func() error,
|
|
|
|
|
) []BraveSource {
|
|
|
|
|
return CollectWebSources(ctx, client, locale, queries, cfg, onProgress, heartbeat)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CollectWebSources(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
client websearch.Client,
|
|
|
|
|
locale BraveSearchLocale,
|
|
|
|
|
queries []string,
|
|
|
|
|
cfg BraveCollectConfig,
|
|
|
|
|
onProgress func(i, total int),
|
|
|
|
|
heartbeat func() error,
|
|
|
|
|
) []BraveSource {
|
|
|
|
|
if client == nil || !client.Enabled() || len(queries) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if cfg.Concurrency <= 1 {
|
|
|
|
|
return collectWebSourcesSequential(ctx, client, locale, queries, cfg, onProgress, heartbeat)
|
|
|
|
|
}
|
|
|
|
|
return collectWebSourcesParallel(ctx, client, locale, queries, cfg, onProgress, heartbeat)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectWebSourcesSequential(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
client websearch.Client,
|
|
|
|
|
locale BraveSearchLocale,
|
|
|
|
|
queries []string,
|
|
|
|
|
cfg BraveCollectConfig,
|
|
|
|
|
onProgress func(i, total int),
|
|
|
|
|
heartbeat func() error,
|
|
|
|
|
) []BraveSource {
|
|
|
|
|
capHint := cfg.MaxSourcesCap
|
|
|
|
|
if est := len(queries) * cfg.ResultsPerQuery; est < capHint {
|
|
|
|
|
capHint = est
|
|
|
|
|
}
|
|
|
|
|
out := make([]BraveSource, 0, capHint)
|
|
|
|
|
seenURL := map[string]struct{}{}
|
|
|
|
|
|
|
|
|
|
for i, query := range queries {
|
2026-06-28 08:28:42 +00:00
|
|
|
if shouldStopCollect(out, queries, cfg) {
|
2026-06-26 08:37:04 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if heartbeat != nil {
|
|
|
|
|
if err := heartbeat(); err != nil {
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery), cfg.MaxSourcesCap)
|
2026-06-26 08:37:04 +00:00
|
|
|
if onProgress != nil {
|
|
|
|
|
onProgress(i, len(queries))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type braveCollectState struct {
|
2026-06-28 08:28:42 +00:00
|
|
|
cfg BraveCollectConfig
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
out []BraveSource
|
|
|
|
|
seenURL map[string]struct{}
|
|
|
|
|
stop bool
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func (s *braveCollectState) shouldStop(queries []string, cfg BraveCollectConfig) bool {
|
2026-06-26 08:37:04 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.stop {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
if shouldStopCollect(s.out, queries, cfg) {
|
2026-06-26 08:37:04 +00:00
|
|
|
s.stop = true
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func (s *braveCollectState) appendResults(query string, items []BraveSource, queries []string) {
|
2026-06-26 08:37:04 +00:00
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.stop {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
appendBraveResults(&s.out, s.seenURL, query, items, s.cfg.MaxSourcesCap)
|
|
|
|
|
if shouldStopCollect(s.out, queries, s.cfg) {
|
2026-06-26 08:37:04 +00:00
|
|
|
s.stop = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func (s *braveCollectState) snapshot() []BraveSource {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return append([]BraveSource(nil), s.out...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
func collectWebSourcesParallel(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
client websearch.Client,
|
|
|
|
|
locale BraveSearchLocale,
|
|
|
|
|
queries []string,
|
|
|
|
|
cfg BraveCollectConfig,
|
|
|
|
|
onProgress func(i, total int),
|
|
|
|
|
heartbeat func() error,
|
|
|
|
|
) []BraveSource {
|
|
|
|
|
state := &braveCollectState{
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
out: make([]BraveSource, 0, cfg.MaxSourcesCap),
|
|
|
|
|
seenURL: map[string]struct{}{},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
workers := cfg.Concurrency
|
|
|
|
|
if workers > len(queries) {
|
|
|
|
|
workers = len(queries)
|
|
|
|
|
}
|
|
|
|
|
if workers <= 0 {
|
|
|
|
|
workers = 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
completed := 0
|
|
|
|
|
for next := 0; next < len(queries); {
|
|
|
|
|
if state.shouldStop(queries, cfg) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
batchEnd := next + workers
|
|
|
|
|
if batchEnd > len(queries) {
|
|
|
|
|
batchEnd = len(queries)
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
type queryResult struct {
|
|
|
|
|
index int
|
|
|
|
|
items []BraveSource
|
|
|
|
|
}
|
|
|
|
|
results := make(chan queryResult, batchEnd-next)
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
for i := next; i < batchEnd; i++ {
|
|
|
|
|
if heartbeat != nil {
|
|
|
|
|
if err := heartbeat(); err != nil {
|
|
|
|
|
return state.snapshot()
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|
|
|
|
|
query := queries[i]
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(index int, q string) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
results <- queryResult{
|
|
|
|
|
index: index,
|
|
|
|
|
items: searchWebQuery(ctx, client, locale, q, cfg.ResultsPerQuery),
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
}(i, query)
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
close(results)
|
|
|
|
|
|
|
|
|
|
ordered := make([]queryResult, batchEnd-next)
|
|
|
|
|
for result := range results {
|
|
|
|
|
ordered[result.index-next] = result
|
|
|
|
|
}
|
|
|
|
|
for _, result := range ordered {
|
|
|
|
|
state.appendResults(queries[result.index], result.items, queries)
|
|
|
|
|
completed++
|
|
|
|
|
if onProgress != nil {
|
|
|
|
|
onProgress(completed-1, len(queries))
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
}
|
|
|
|
|
next = batchEnd
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
return state.snapshot()
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func shouldStopCollect(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
|
2026-06-26 08:37:04 +00:00
|
|
|
if len(out) >= cfg.MaxSourcesCap {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
return sourcesAreSufficient(out, queries, cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sourcesAreSufficient(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
|
|
|
|
|
if cfg.MinSourcesBeforeStop <= 0 {
|
|
|
|
|
return len(out) > 0
|
|
|
|
|
}
|
|
|
|
|
stats := sourceStats(out)
|
|
|
|
|
if stats.UniqueURLs < cfg.MinSourcesBeforeStop {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
minQueryCoverage := 1
|
|
|
|
|
if len(queries) > 1 {
|
|
|
|
|
minQueryCoverage = 2
|
|
|
|
|
}
|
|
|
|
|
if stats.QueryCoverage < minQueryCoverage {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
minUseful := (cfg.MinSourcesBeforeStop * 2) / 3
|
|
|
|
|
if minUseful < 1 {
|
|
|
|
|
minUseful = 1
|
|
|
|
|
}
|
|
|
|
|
if stats.UsefulSources < minUseful {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return stats.TextRunes >= cfg.MinSourcesBeforeStop*minUsefulSourceRunes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type collectSourceStats struct {
|
|
|
|
|
UniqueURLs int
|
|
|
|
|
QueryCoverage int
|
|
|
|
|
UsefulSources int
|
|
|
|
|
TextRunes int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sourceStats(items []BraveSource) collectSourceStats {
|
|
|
|
|
seenURL := map[string]struct{}{}
|
|
|
|
|
seenQuery := map[string]struct{}{}
|
|
|
|
|
stats := collectSourceStats{}
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
url := strings.TrimSpace(item.URL)
|
|
|
|
|
if url == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seenURL[url]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seenURL[url] = struct{}{}
|
|
|
|
|
query := strings.TrimSpace(item.Query)
|
|
|
|
|
if query != "" {
|
|
|
|
|
seenQuery[query] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
textRunes := len([]rune(strings.TrimSpace(item.Title))) + len([]rune(strings.TrimSpace(item.Snippet)))
|
|
|
|
|
stats.TextRunes += textRunes
|
|
|
|
|
if textRunes >= minUsefulSourceRunes {
|
|
|
|
|
stats.UsefulSources++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
stats.UniqueURLs = len(seenURL)
|
|
|
|
|
stats.QueryCoverage = len(seenQuery)
|
|
|
|
|
return stats
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func searchWebQuery(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
client websearch.Client,
|
|
|
|
|
locale BraveSearchLocale,
|
|
|
|
|
query string,
|
|
|
|
|
limit int,
|
|
|
|
|
) []BraveSource {
|
|
|
|
|
res, _ := client.Search(ctx, websearch.SearchOptions{
|
|
|
|
|
Query: query,
|
|
|
|
|
Limit: limit,
|
|
|
|
|
Mode: websearch.ModeKnowledgeExpand,
|
|
|
|
|
Country: locale.Country,
|
|
|
|
|
SearchLang: locale.SearchLang,
|
|
|
|
|
UserLocation: locale.UserLocation,
|
|
|
|
|
})
|
|
|
|
|
items := make([]BraveSource, 0, len(res.Results))
|
|
|
|
|
for _, item := range res.Results {
|
|
|
|
|
url := strings.TrimSpace(item.URL)
|
|
|
|
|
if url == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
items = append(items, BraveSource{
|
|
|
|
|
Query: query,
|
|
|
|
|
Snippet: item.Snippet,
|
|
|
|
|
URL: url,
|
|
|
|
|
Title: item.Title,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource, max int) {
|
2026-06-26 08:37:04 +00:00
|
|
|
for _, item := range items {
|
2026-06-28 08:28:42 +00:00
|
|
|
if max > 0 && len(*out) >= max {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
url := strings.TrimSpace(item.URL)
|
|
|
|
|
if url == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seenURL[url]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seenURL[url] = struct{}{}
|
|
|
|
|
if item.Query == "" {
|
|
|
|
|
item.Query = query
|
|
|
|
|
}
|
|
|
|
|
*out = append(*out, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func MergeBraveSources(chunks ...[]BraveSource) []BraveSource {
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
out := make([]BraveSource, 0)
|
|
|
|
|
for _, chunk := range chunks {
|
|
|
|
|
for _, item := range chunk {
|
|
|
|
|
url := strings.TrimSpace(item.URL)
|
|
|
|
|
if url == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seen[url]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[url] = struct{}{}
|
|
|
|
|
out = append(out, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func uniqueSourceCount(items []BraveSource) int {
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
for _, item := range items {
|
|
|
|
|
url := strings.TrimSpace(item.URL)
|
|
|
|
|
if url == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[url] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
return len(seen)
|
|
|
|
|
}
|