2026-06-26 08:37:04 +00:00
|
|
|
|
package knowledge
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
branddomain "haixun-backend/internal/model/brand/domain/usecase"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// PatrolTagInput grounds海巡 tag in研究地圖與品牌/產品輸入。
|
|
|
|
|
|
type PatrolTagInput struct {
|
|
|
|
|
|
BrandName string
|
|
|
|
|
|
ProductName string
|
|
|
|
|
|
ProductFeatures string
|
|
|
|
|
|
AudienceSummary string
|
2026-07-01 08:42:51 +00:00
|
|
|
|
TargetAudience string
|
2026-06-26 08:37:04 +00:00
|
|
|
|
MatchTags []string
|
|
|
|
|
|
Questions []string
|
|
|
|
|
|
Pillars []string
|
|
|
|
|
|
PatrolKeywords []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type productContextFields struct {
|
|
|
|
|
|
Brand string `json:"brand"`
|
|
|
|
|
|
Product string `json:"product"`
|
|
|
|
|
|
Features string `json:"features"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseProductContextFields(raw string) productContextFields {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" {
|
|
|
|
|
|
return productContextFields{}
|
|
|
|
|
|
}
|
|
|
|
|
|
var parsed productContextFields
|
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
|
|
|
|
|
return productContextFields{Features: raw}
|
|
|
|
|
|
}
|
|
|
|
|
|
parsed.Brand = strings.TrimSpace(parsed.Brand)
|
|
|
|
|
|
parsed.Product = strings.TrimSpace(parsed.Product)
|
|
|
|
|
|
parsed.Features = strings.TrimSpace(parsed.Features)
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief string) PatrolTagInput {
|
|
|
|
|
|
if brand == nil {
|
|
|
|
|
|
return PatrolTagInput{}
|
|
|
|
|
|
}
|
|
|
|
|
|
fields := parseProductContextFields(brand.ProductContext)
|
|
|
|
|
|
productName := productLabelFromBrand(brand)
|
|
|
|
|
|
if productName == "" {
|
|
|
|
|
|
productName = fields.Product
|
|
|
|
|
|
}
|
|
|
|
|
|
brandName := strings.TrimSpace(brand.DisplayName)
|
|
|
|
|
|
if brandName == "" {
|
|
|
|
|
|
brandName = fields.Brand
|
|
|
|
|
|
}
|
|
|
|
|
|
features := fields.Features
|
|
|
|
|
|
if features == "" {
|
|
|
|
|
|
features = strings.TrimSpace(productBrief)
|
|
|
|
|
|
}
|
|
|
|
|
|
if features == "" {
|
|
|
|
|
|
features = strings.TrimSpace(brand.ProductBrief)
|
|
|
|
|
|
}
|
|
|
|
|
|
return PatrolTagInput{
|
|
|
|
|
|
BrandName: brandName,
|
|
|
|
|
|
ProductName: productName,
|
|
|
|
|
|
ProductFeatures: features,
|
|
|
|
|
|
AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
|
2026-07-01 08:42:51 +00:00
|
|
|
|
TargetAudience: strings.TrimSpace(brand.TargetAudience),
|
2026-06-26 08:37:04 +00:00
|
|
|
|
MatchTags: matchTagsFromBrand(brand),
|
|
|
|
|
|
Questions: append([]string{}, brand.ResearchMap.Questions...),
|
|
|
|
|
|
Pillars: append([]string{}, brand.ResearchMap.Pillars...),
|
|
|
|
|
|
PatrolKeywords: append([]string{}, brand.ResearchMap.PatrolKeywords...),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
// OverlayResearchMap copies topic-level research map fields onto patrol input.
|
|
|
|
|
|
func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput {
|
|
|
|
|
|
if strings.TrimSpace(audienceSummary) != "" {
|
|
|
|
|
|
in.AudienceSummary = strings.TrimSpace(audienceSummary)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(questions) > 0 {
|
|
|
|
|
|
in.Questions = append([]string{}, questions...)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(pillars) > 0 {
|
|
|
|
|
|
in.Pillars = append([]string{}, pillars...)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(patrolKeywords) > 0 {
|
|
|
|
|
|
in.PatrolKeywords = append([]string{}, patrolKeywords...)
|
|
|
|
|
|
}
|
|
|
|
|
|
return in
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
func productLabelFromBrand(brand *branddomain.BrandSummary) string {
|
|
|
|
|
|
if brand == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
productID := strings.TrimSpace(brand.ProductID)
|
|
|
|
|
|
if productID == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, item := range brand.Products {
|
|
|
|
|
|
if item.ID == productID {
|
|
|
|
|
|
label := strings.TrimSpace(item.Label)
|
|
|
|
|
|
if label != "" {
|
|
|
|
|
|
return label
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseProductContextFields(item.ProductContext).Product
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func matchTagsFromBrand(brand *branddomain.BrandSummary) []string {
|
|
|
|
|
|
if brand == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
productID := strings.TrimSpace(brand.ProductID)
|
|
|
|
|
|
seen := map[string]struct{}{}
|
|
|
|
|
|
out := []string{}
|
|
|
|
|
|
add := func(tag string) {
|
|
|
|
|
|
tag = strings.TrimSpace(tag)
|
|
|
|
|
|
if tag == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := seen[tag]; ok {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[tag] = struct{}{}
|
|
|
|
|
|
out = append(out, tag)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, item := range brand.Products {
|
|
|
|
|
|
if productID != "" && item.ID != productID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tag := range item.MatchTags {
|
|
|
|
|
|
add(tag)
|
|
|
|
|
|
}
|
|
|
|
|
|
if productID != "" {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (in PatrolTagInput) HasResearchMap() bool {
|
|
|
|
|
|
return len(in.Questions) > 0 || len(in.Pillars) > 0 || strings.TrimSpace(in.AudienceSummary) != ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (in PatrolTagInput) HasProductContext() bool {
|
|
|
|
|
|
return strings.TrimSpace(in.ProductName) != "" ||
|
|
|
|
|
|
strings.TrimSpace(in.ProductFeatures) != "" ||
|
|
|
|
|
|
len(in.MatchTags) > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:42:51 +00:00
|
|
|
|
var patrolCoreValueTokens = []string{
|
|
|
|
|
|
"敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚",
|
|
|
|
|
|
"化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func primaryPatrolValueAnchor(in PatrolTagInput) string {
|
|
|
|
|
|
blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " +
|
|
|
|
|
|
strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " +
|
|
|
|
|
|
in.AudienceSummary + " " + in.TargetAudience))
|
|
|
|
|
|
priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"}
|
|
|
|
|
|
for _, token := range priority {
|
|
|
|
|
|
if strings.Contains(blob, token) {
|
|
|
|
|
|
return token
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, token := range patrolCoreValueTokens {
|
|
|
|
|
|
if strings.Contains(blob, token) {
|
|
|
|
|
|
return token
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
func productCategoryHint(productName, features string) string {
|
|
|
|
|
|
combined := productName + " " + features
|
|
|
|
|
|
for _, hint := range []string{
|
2026-07-01 08:42:51 +00:00
|
|
|
|
"洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
|
2026-06-26 08:37:04 +00:00
|
|
|
|
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
|
|
|
|
|
|
"防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
|
|
|
|
|
|
} {
|
|
|
|
|
|
if strings.Contains(combined, hint) {
|
|
|
|
|
|
return hint
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|