package knowledge import ( "strings" "unicode/utf8" ) const ( maxThreadsSearchRunes = 14 maxWebRecencyCoreRunes = 12 maxWebRelevanceTagRunes = 16 ) var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"} // ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API. // Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness). func ShortPatrolSearchCore(tag string) string { tag = strings.TrimSpace(tag) if tag == "" { return "" } tokens := selectSearchTokens(tag, 3) if len(tokens) == 0 { return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes) } core := strings.Join(tokens, " ") return truncateRunes(core, maxWebRecencyCoreRunes) } // ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search. func ThreadsAPIKeyword(tag string) string { core := ShortPatrolSearchCore(tag) if core != "" { return core } return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes) } // FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes). func FullPatrolWebSearchTag(tag string) string { tag = strings.TrimSpace(tag) if tag == "" { return "" } if !hasSearchIntentMarker(tag) { candidate := strings.TrimSpace(tag + " 推薦") if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes { tag = candidate } } if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes { return tag } tokens := selectSearchTokens(tag, 4) if len(tokens) == 0 { return truncateRunes(tag, maxWebRelevanceTagRunes) } full := strings.Join(tokens, " ") if !hasSearchIntentMarker(full) { withIntent := strings.TrimSpace(full + " 推薦") if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes { return withIntent } } return truncateRunes(full, maxWebRelevanceTagRunes) } func selectSearchTokens(tag string, maxTokens int) []string { tag = stripSearchIntent(tag) parts := []string{} seen := map[string]struct{}{} add := func(token string) { token = strings.TrimSpace(token) if isPatrolFiller(token) || len([]rune(token)) < 2 { return } if _, ok := seen[token]; ok { return } seen[token] = struct{}{} parts = append(parts, token) } for _, anchor := range patrolTopicAnchors { if strings.Contains(tag, anchor) { add(anchor) } } for _, hint := range productFormHints { if strings.Contains(tag, hint) { add(hint) } } for _, token := range intentTokenize(tag) { add(token) if len(parts) >= maxTokens { break } } if len(parts) > maxTokens { parts = parts[:maxTokens] } return parts } func stripSearchIntent(tag string) string { tag = strings.TrimSpace(tag) for _, marker := range searchIntentMarkers { tag = strings.ReplaceAll(tag, marker, " ") } return strings.Join(strings.Fields(tag), " ") } func hasSearchIntentMarker(tag string) bool { for _, marker := range searchIntentMarkers { if strings.Contains(tag, marker) { return true } } return false }