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

534 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}