0b5d8d72f2
Deployment Config CI / Deployment Config (push) Successful in 27s
Frontend CI / Frontend (push) Successful in 2m53s
Backend CI / Backend (push) Successful in 16m53s
Desktop Client Build / Resolve Build Metadata (push) Waiting to run
Desktop Client Build / Build Desktop Client (push) Blocked by required conditions
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Search terms like "全屋定制" or "GEO排名优化" were rejected as invalid because both the Vue candidate validator and the Go question_metadata classifier required interrogative cues (什么/如何/为什么/...). Lexicon entries are search terms, not questions, so require only letters or digits plus the existing 4-char minimum. Rename helpers to searchTermLooksValid/questionLooksValid to reflect the looser semantics, and relabel the navigation entry from "公司和词库" to "品牌和词库" to match the actual domain.
149 lines
3.9 KiB
Go
149 lines
3.9 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
QuestionLayerL1 = "L1"
|
|
QuestionLayerL2 = "L2"
|
|
QuestionLayerL3 = "L3"
|
|
QuestionLayerL4 = "L4"
|
|
QuestionLayerL5 = "L5"
|
|
|
|
QuestionIntentInformational = "informational"
|
|
QuestionIntentEvaluative = "evaluative"
|
|
QuestionIntentDecisional = "decisional"
|
|
|
|
QuestionSourceManual = "manual"
|
|
QuestionSourceCombination = "combination"
|
|
QuestionSourceAIDistill = "ai_distill"
|
|
)
|
|
|
|
type ClassifiedQuestion struct {
|
|
Text string `json:"text"`
|
|
Layer string `json:"layer"`
|
|
Intent string `json:"intent"`
|
|
}
|
|
|
|
func normalizeQuestionText(text string) string {
|
|
trimmed := strings.TrimSpace(text)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
return strings.Map(func(r rune) rune {
|
|
if unicode.IsPunct(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, trimmed)
|
|
}
|
|
|
|
func normalizeQuestionKey(text string) string {
|
|
return strings.ToLower(normalizeQuestionText(text))
|
|
}
|
|
|
|
func isValidQuestionLayer(value string) bool {
|
|
switch strings.TrimSpace(value) {
|
|
case QuestionLayerL1, QuestionLayerL2, QuestionLayerL3, QuestionLayerL4, QuestionLayerL5:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isValidQuestionIntent(value string) bool {
|
|
switch strings.TrimSpace(value) {
|
|
case QuestionIntentInformational, QuestionIntentEvaluative, QuestionIntentDecisional:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isValidExternalQuestionSource(value string) bool {
|
|
switch strings.TrimSpace(value) {
|
|
case QuestionSourceManual, QuestionSourceCombination, QuestionSourceAIDistill:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func inferQuestionIntent(text string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(text))
|
|
if containsAny(normalized, []string{"哪家好", "哪个好", "怎么选", "推荐", "哪里买", "适合谁", "多少钱", "报价", "购买"}) {
|
|
return QuestionIntentDecisional
|
|
}
|
|
if containsAny(normalized, []string{"对比", "区别", "哪个更", "优劣", "测评", "排名", "值不值", "替代", "竞品"}) {
|
|
return QuestionIntentEvaluative
|
|
}
|
|
return QuestionIntentInformational
|
|
}
|
|
|
|
func inferQuestionLayer(text, brandName string, competitorNames []string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(text))
|
|
brandHits := 0
|
|
if containsFold(normalized, brandName) {
|
|
brandHits++
|
|
}
|
|
for _, competitor := range competitorNames {
|
|
if containsFold(normalized, competitor) {
|
|
brandHits++
|
|
}
|
|
}
|
|
if brandHits >= 2 || containsAny(normalized, []string{"对比", "区别", "哪个更", "优劣", "竞品", "替代"}) {
|
|
return QuestionLayerL5
|
|
}
|
|
if brandHits == 1 {
|
|
return QuestionLayerL1
|
|
}
|
|
if containsAny(normalized, []string{"北京", "上海", "广州", "深圳", "杭州", "成都", "合肥", "华东", "华南", "华北", "中小企业", "企业", "团队", "老板", "新手", "预算", "价格", "费用", "场景", "本地"}) {
|
|
return QuestionLayerL4
|
|
}
|
|
if containsAny(normalized, []string{"痛点", "问题", "踩坑", "解决", "失败", "难", "风险", "怎么办"}) {
|
|
return QuestionLayerL3
|
|
}
|
|
return QuestionLayerL2
|
|
}
|
|
|
|
func classifyQuestionText(text, brandName string, competitorNames []string) ClassifiedQuestion {
|
|
trimmed := normalizeQuestionText(text)
|
|
return ClassifiedQuestion{
|
|
Text: trimmed,
|
|
Layer: inferQuestionLayer(trimmed, brandName, competitorNames),
|
|
Intent: inferQuestionIntent(trimmed),
|
|
}
|
|
}
|
|
|
|
func questionLooksValid(text string) bool {
|
|
trimmed := normalizeQuestionText(text)
|
|
if utf8.RuneCountInString(trimmed) < 4 {
|
|
return false
|
|
}
|
|
hasSearchTermChar := false
|
|
for _, r := range trimmed {
|
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
|
hasSearchTermChar = true
|
|
break
|
|
}
|
|
}
|
|
return hasSearchTermChar
|
|
}
|
|
|
|
func containsAny(value string, needles []string) bool {
|
|
for _, needle := range needles {
|
|
if strings.Contains(value, strings.ToLower(strings.TrimSpace(needle))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsFold(value, needle string) bool {
|
|
needle = strings.ToLower(strings.TrimSpace(needle))
|
|
return needle != "" && strings.Contains(value, needle)
|
|
}
|