package placement import ( "sort" "strings" libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/websearch" ) const ( defaultPatrolProductFitNoGraph = 42 maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2 ) // 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 { keywords = libkg.NormalizePatrolKeywordList(keywords) if len(keywords) == 0 { return nil } if len(keywords) > libkg.MaxScanPatrolKeywords { keywords = keywords[:libkg.MaxScanPatrolKeywords] } out := make([]TagQuery, 0, len(keywords)*2) for _, tag := range keywords { fit := productFitForPatrolTag(tag, nodes, patrolInput) 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 } func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int { tagKey := patrolTagMatchKey(tag) best := 0 profile := libkg.BuildIntentProfile(patrolInput) intentFit := libkg.ScoreIntentSimilarity(tag, profile) 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 { 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 } } return best } 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) } // 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}) } 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 }