2026-06-26 08:37:04 +00:00
|
|
|
package placement
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-06 06:13:14 +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-06 06:13:14 +00:00
|
|
|
const (
|
|
|
|
|
defaultPatrolProductFitNoGraph = 42
|
|
|
|
|
maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
|
|
|
|
|
)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-07-06 06:13:14 +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-06 06:13:14 +00:00
|
|
|
if len(keywords) > libkg.MaxScanPatrolKeywords {
|
|
|
|
|
keywords = keywords[:libkg.MaxScanPatrolKeywords]
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
out := make([]TagQuery, 0, len(keywords)*2)
|
2026-06-26 08:37:04 +00:00
|
|
|
for _, tag := range keywords {
|
2026-07-06 06:13:14 +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-06 06:13:14 +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-06 06:13:14 +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-06 06:13:14 +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-06 06:13:14 +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-06 06:13:14 +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-06 06:13:14 +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
|
|
|
}
|