package placement import ( "strings" "time" "unicode" libkg "haixun-backend/internal/library/knowledge" ) const ( minPostBodyProductFit = 50 minPostBodyProductFitDiscover = 28 minPostProductFitPatrol = 58 minPostProductFitDiscover = 34 minPostProductFitRelevant = 62 minPostProductFitRelaxedFinal = 38 minPostProductFitIngestFinal = 28 minPostPillarAnchor = 1 patrolMaxPostAgeDays = IdealMaxPostAgeDays relevanceMaxPostAgeDays = 14 ) // PostScanContext carries research map + product signals for post-level filtering. type PostScanContext struct { MatchTags []string ProductName string ProductFeatures string AudienceSummary string TargetAudience string Questions []string Pillars []string Exclusions []string } func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext { return PostScanContext{ MatchTags: append([]string{}, in.MatchTags...), ProductName: strings.TrimSpace(in.ProductName), ProductFeatures: strings.TrimSpace(in.ProductFeatures), AudienceSummary: strings.TrimSpace(in.AudienceSummary), TargetAudience: strings.TrimSpace(in.TargetAudience), Questions: append([]string{}, in.Questions...), Pillars: append([]string{}, in.Pillars...), Exclusions: ExpandExclusionTokens(exclusions), } } func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int { text = strings.ToLower(strings.TrimSpace(text)) if text == "" { return 0 } tagScore := ScoreProductForTag(searchTag, ctx.MatchTags) if tagScore == 0 && ctx.ProductName != "" { tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName}) } postScore := ScoreProductForTag(text, ctx.MatchTags) if postScore == 0 && ctx.ProductName != "" { postScore = scoreTextAgainstPhrase(text, ctx.ProductName) } if ctx.ProductFeatures != "" { postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures)) } for _, pillar := range ctx.Pillars { postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2) } combined := postScore if tagScore > 0 && postScore > 0 { combined = (tagScore*2 + postScore*3) / 5 } else if postScore > 0 { combined = postScore } else if tagScore > 0 { combined = tagScore / 2 } if HasCategoryConflict(text, ctx) { return minInt(combined, 25) } if HasTangentialTopicMismatch(text, ctx) { return minInt(combined, 20) } intentScore := LocalIntentScore(text, ctx) if intentScore > combined { combined = (combined*2 + intentScore*3) / 5 } return combined } // PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API. // Stricter ranking happens at finalize. func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool { text = strings.TrimSpace(text) if text == "" { return false } if MatchesExclusion(text, ctx.Exclusions) { return false } if HasCategoryConflict(text, ctx) { return false } if HasTangentialTopicMismatch(text, ctx) { return false } if !HasPainPointDemand(text) && !HasPlacementIntent(text) { return false } if !PassesPostAge(postedAt, dimension, recencyDays) { return false } bodyFit := ScorePostBodyProductFit(text, ctx) effectiveFit := bodyFit if tagFit > 0 && bodyFit > 0 { effectiveFit = (tagFit + bodyFit*2) / 3 } else if tagFit > 0 && HasPlacementIntent(text) { effectiveFit = tagFit / 2 } if bodyFit < minPostBodyProductFitDiscover { if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) { return false } } return effectiveFit >= minPostProductFitDiscover || (HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) } func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool { text = strings.TrimSpace(text) if text == "" { return false } if MatchesExclusion(text, ctx.Exclusions) { return false } if HasCategoryConflict(text, ctx) { return false } if HasTangentialTopicMismatch(text, ctx) { return false } if !HasPainPointDemand(text) { return false } if !PassesPostAge(postedAt, dimension, 0) { return false } bodyFit := ScorePostBodyProductFit(text, ctx) if bodyFit < minPostBodyProductFit { return false } if !ProductSolvesPostPain(text, ctx) { return false } effectiveFit := bodyFit if tagFit > 0 && bodyFit > 0 { effectiveFit = (tagFit + bodyFit*2) / 3 } minFit := minPostProductFitPatrol if dimension == QueryRelevance { minFit = minPostProductFitRelevant } return effectiveFit >= minFit } // ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag). func ScorePostBodyProductFit(text string, ctx PostScanContext) int { return ScorePostProductFit(text, "", ctx) } // ProductSolvesPostPain checks pain in the post aligns with what the product can solve. func ProductSolvesPostPain(text string, ctx PostScanContext) bool { if HasCategoryConflict(text, ctx) { return false } if HasTangentialTopicMismatch(text, ctx) { return false } if matchesTopicAnchor(text, ctx) { return true } bodyFit := ScorePostBodyProductFit(text, ctx) if bodyFit < minPostBodyProductFit { return false } for _, q := range ctx.Questions { if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit { return true } } if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 { return true } for _, tag := range ctx.MatchTags { if scoreTextAgainstPhrase(text, tag) >= 55 { return true } } return LocalIntentScore(text, ctx) >= minPostBodyProductFit } // PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch. func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool { return effectiveFit >= minPostProductFitPatrol && HasPainPointDemand(text) && ProductSolvesPostPain(text, ctx) && !HasCategoryConflict(text, ctx) && !HasTangentialTopicMismatch(text, ctx) } // PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results. func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool { text = strings.TrimSpace(text) if text == "" { return false } if MatchesExclusion(text, ctx.Exclusions) { return false } if HasCategoryConflict(text, ctx) { return false } if HasTangentialTopicMismatch(text, ctx) { return false } if !HasPainPointDemand(text) && !HasPlacementIntent(text) { return false } return effectiveFit >= minPostProductFitRelaxedFinal } // PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero. func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool { text = strings.TrimSpace(text) if text == "" { return false } if MatchesExclusion(text, ctx.Exclusions) { return false } if HasCategoryConflict(text, ctx) { return false } if !HasPlacementIntent(text) && !HasPainPointDemand(text) { return false } return effectiveFit >= minPostProductFitIngestFinal } func matchesTopicAnchor(text string, ctx PostScanContext) bool { text = strings.ToLower(text) hits := 0 for _, pillar := range ctx.Pillars { if tokenHits(text, pillar) > 0 { hits++ } } for _, q := range ctx.Questions { if tokenHits(text, q) >= 2 { hits++ } } if hits >= minPostPillarAnchor { return true } for _, tag := range ctx.MatchTags { if tokenHits(text, tag) > 0 { return true } } if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 { return true } for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} { if tokenHits(text, phrase) >= 2 { return true } } return false } func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool { postedAt = strings.TrimSpace(postedAt) if postedAt == "" { // Web Search snippets often lack dates; do not drop at ingest. return true } maxDays := patrolMaxPostAgeDays switch dimension { case QueryRelevance: maxDays = relevanceMaxPostAgeDays case QueryRecency: if recencyDays > 0 { maxDays = recencyDays } } return IsPostWithinMaxAge(postedAt, maxDays, time.Now()) } func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool { if maxDays <= 0 { return true } ts, ok := ParsePostedAt(postedAt) if !ok { return true } if now.IsZero() { now = time.Now() } cutoff := now.AddDate(0, 0, -maxDays) return !ts.Before(cutoff) } func ParsePostedAt(raw string) (time.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, false } layouts := []string{ time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02 15:04:05", "2006-01-02", "2006/01/02", } for _, layout := range layouts { if ts, err := time.Parse(layout, raw); err == nil { return ts, true } } return time.Time{}, false } func ExpandExclusionTokens(exclusions []string) []string { seen := map[string]struct{}{} out := []string{} add := func(token string) { token = strings.TrimSpace(strings.ToLower(token)) if len([]rune(token)) < 2 { return } if _, ok := seen[token]; ok { return } seen[token] = struct{}{} out = append(out, token) } for _, rule := range exclusions { rule = strings.TrimSpace(rule) if rule == "" { continue } add(rule) for _, token := range tokenizePhrase(rule) { add(token) } for _, synonym := range exclusionSynonyms(rule) { add(synonym) } } return out } func exclusionSynonyms(rule string) []string { rule = strings.ToLower(rule) out := []string{} for _, group := range exclusionCategoryGroups { if strings.Contains(rule, group.key) { out = append(out, group.tokens...) } } return out } var exclusionCategoryGroups = []struct { key string tokens []string }{ {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}}, {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}}, {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}}, {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}}, {key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}}, {key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}}, } var productCategoryGroups = []struct { key string tokens []string }{ {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}}, {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}}, {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}}, {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}}, {key: "清潔", tokens: []string{"清潔", "打掃", "去污"}}, } func productCategories(ctx PostScanContext) []string { blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " ")) found := []string{} for _, group := range productCategoryGroups { for _, token := range group.tokens { if strings.Contains(blob, token) { found = append(found, group.key) break } } } return found } func postCategories(text string) []string { text = strings.ToLower(text) found := []string{} for _, group := range productCategoryGroups { for _, token := range group.tokens { if strings.Contains(text, token) { found = append(found, group.key) break } } } return found } var genericProductCategories = map[string]struct{}{ "清潔": {}, } func HasCategoryConflict(text string, ctx PostScanContext) bool { productCats := productCategories(ctx) if len(productCats) == 0 { return false } postCats := postCategories(text) if len(postCats) == 0 { return false } productSet := map[string]struct{}{} for _, c := range productCats { productSet[c] = struct{}{} } for _, c := range postCats { if _, ok := genericProductCategories[c]; ok { continue } if _, ok := productSet[c]; !ok { return true } } return false } func scoreTextAgainstPhrase(text, phrase string) int { text = strings.ToLower(text) phrase = strings.TrimSpace(strings.ToLower(phrase)) if text == "" || phrase == "" { return 0 } if strings.Contains(text, phrase) { return 85 } best := 0 for _, token := range tokenizePhrase(phrase) { if len([]rune(token)) < 2 { continue } if strings.Contains(text, token) { best = maxInt(best, 55) } } return best } func tokenHits(text, phrase string) int { text = strings.ToLower(text) hits := 0 for _, token := range tokenizePhrase(phrase) { if len([]rune(token)) < 2 { continue } if strings.Contains(text, token) { hits++ } } return hits } func tokenizePhrase(phrase string) []string { phrase = strings.ToLower(strings.TrimSpace(phrase)) if phrase == "" { return nil } repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ") phrase = repl.Replace(phrase) var tokens []string for _, part := range strings.Fields(phrase) { part = strings.Trim(part, `"'「」『』::`) if part != "" { tokens = append(tokens, part) } } if len(tokens) == 0 { return splitRunes(phrase) } return tokens } func splitRunes(s string) []string { var out []string var buf []rune flush := func() { if len(buf) >= 2 { out = append(out, string(buf)) } buf = buf[:0] } for _, r := range s { if unicode.Is(unicode.Han, r) { buf = append(buf, r) continue } flush() } flush() return out } func minInt(a, b int) int { if a < b { return a } return b }