thread-master/backend/internal/library/placement/patrol_queries.go

167 lines
4.6 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
package placement
import (
2026-07-01 08:42:51 +00:00
"sort"
2026-06-26 08:37:04 +00:00
"strings"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
2026-07-01 08:42:51 +00:00
const (
defaultPatrolProductFitNoGraph = 42
2026-07-03 14:42:19 +00:00
maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
2026-07-01 08:42:51 +00:00
)
2026-06-26 08:37:04 +00:00
2026-07-01 08:42:51 +00:00
// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
2026-06-26 08:37:04 +00:00
keywords = libkg.NormalizePatrolKeywordList(keywords)
if len(keywords) == 0 {
return nil
}
2026-07-01 08:42:51 +00:00
if len(keywords) > libkg.MaxScanPatrolKeywords {
keywords = keywords[:libkg.MaxScanPatrolKeywords]
}
2026-06-26 08:37:04 +00:00
2026-07-01 08:42:51 +00:00
out := make([]TagQuery, 0, len(keywords)*2)
2026-06-26 08:37:04 +00:00
for _, tag := range keywords {
2026-07-01 08:42:51 +00:00
fit := productFitForPatrolTag(tag, nodes, patrolInput)
2026-06-26 08:37:04 +00:00
if q := BuildRelevanceQuery(provider, tag); q != "" {
out = append(out, TagQuery{
Tag: tag,
Query: q,
Dimension: QueryRelevance,
ProductFitScore: fit,
})
}
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q7 != "" {
out = append(out, TagQuery{
Tag: tag,
Query: q7,
Dimension: QueryRecency,
ProductFitScore: fit,
RecencyDays: IdealMaxPostAgeDays,
})
}
}
return out
}
2026-07-01 08:42:51 +00:00
func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
2026-06-26 08:37:04 +00:00
tagKey := patrolTagMatchKey(tag)
best := 0
2026-07-01 08:42:51 +00:00
profile := libkg.BuildIntentProfile(patrolInput)
intentFit := libkg.ScoreIntentSimilarity(tag, profile)
2026-06-26 08:37:04 +00:00
for _, node := range nodes {
score := node.ProductFitScore
if score <= 0 {
continue
}
matched := false
for _, candidate := range append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...) {
if patrolTagMatchKey(candidate) == tagKey {
matched = true
break
}
}
if !matched && patrolTagMatchKey(node.Label) != tagKey {
continue
}
if score > best {
best = score
}
}
if best > 0 {
2026-07-01 08:42:51 +00:00
return maxPatrolFit(best, intentFit)
}
if intentFit > 0 {
return intentFit
}
return defaultPatrolProductFitNoGraph
}
func maxPatrolFit(values ...int) int {
best := 0
for _, v := range values {
if v > best {
best = v
}
2026-06-26 08:37:04 +00:00
}
2026-07-01 08:42:51 +00:00
return best
2026-06-26 08:37:04 +00:00
}
func patrolTagMatchKey(tag string) string {
tag = strings.TrimSpace(tag)
for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎", " 請益"} {
if strings.HasSuffix(tag, suffix) {
tag = strings.TrimSuffix(tag, suffix)
break
}
}
return strings.TrimSpace(tag)
}
2026-07-01 08:42:51 +00:00
// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
keywords := patrolKeywords
if len(keywords) == 0 {
keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords)
}
queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput)
if len(queries) == 0 {
queries = CollectTagQueries(nodes, provider)
}
return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput)
}
func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery {
profile := libkg.BuildIntentProfile(patrolInput)
if max <= 0 || len(queries) <= max {
return queries
}
type ranked struct {
query TagQuery
key string
}
rankedQueries := make([]ranked, 0, len(queries))
seen := map[string]struct{}{}
for _, item := range queries {
key := strings.TrimSpace(item.Query)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
rankedQueries = append(rankedQueries, ranked{query: item, key: key})
2026-06-26 08:37:04 +00:00
}
2026-07-01 08:42:51 +00:00
sort.SliceStable(rankedQueries, func(i, j int) bool {
left := rankedQueries[i].query.ProductFitScore
right := rankedQueries[j].query.ProductFitScore
leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile)
rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile)
leftRank := left*2 + leftIntent*3
rightRank := right*2 + rightIntent*3
if leftRank == rightRank {
if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension {
return rankedQueries[i].key < rankedQueries[j].key
}
if rankedQueries[i].query.Dimension == QueryRelevance {
return true
}
return false
}
return leftRank > rightRank
})
out := make([]TagQuery, 0, max)
for _, item := range rankedQueries {
out = append(out, item.query)
if len(out) >= max {
break
}
}
return out
2026-06-26 08:37:04 +00:00
}