thread-master/apps/backend/internal/module/scout/usecase/topic_signature.go

444 lines
13 KiB
Go
Raw Permalink Normal View History

2026-08-13 02:22:24 +00:00
package usecase
import (
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// TopicConcept is one necessary concept group in a topic. A candidate must
// match at least one core or approved alias from every group.
type TopicConcept struct {
Name string
Cores []string
Aliases []string
}
// TopicSignature is the hard relevance contract for one scan. Search
// modifiers and conversation anchors are intentionally not concepts.
type TopicSignature struct {
Intent string
Concepts []TopicConcept
Anchors []string
}
// TopicMatch is deliberately small: callers need the decision, matched
// concepts for diagnostics, and missing concepts for shortfall reasons.
type TopicMatch struct {
Matched bool
MatchedCores []string
MissingGroups []string
Reason string
}
// NewTopicSignature builds a signature from the original intent and the
// approved search terms. Approved terms may expose an explicit alias (for
// example "鬼滅" for "鬼滅之刃") or split a compact intent into concepts (for
// example "台北 市集" for "台北週末市集").
func NewTopicSignature(intent string, approvedTerms []string) TopicSignature {
intent = normalizeTopicTerm(intent)
sig := TopicSignature{Intent: intent, Anchors: append([]string(nil), topicAnchors...)}
intentParts := semanticTopicParts(intent)
naturalIntent := len(intentParts) == 1 && isNaturalTopicIntent(intent)
if naturalIntent {
if derived := deriveNaturalTopicParts(intent, approvedTerms); len(derived) > 0 {
intentParts = derived
}
}
approvedParts := make([]string, 0)
approvedTermParts := make([][]string, 0, len(approvedTerms))
for _, term := range approvedTerms {
parts := semanticTopicParts(term)
if len(parts) == 0 {
continue
}
approvedTermParts = append(approvedTermParts, parts)
approvedParts = append(approvedParts, parts...)
}
approvedParts = uniqueTopicTerms(approvedParts)
// A compact long phrase such as 台北週末市集 is often not present
// contiguously in a post. When approved terms explicitly split it, use the
// split concepts instead of making the unsplittable phrase mandatory. Named
// entities (鬼滅之刃) remain one concept because their approved form is
// still a single term.
splitApproved := !naturalIntent && len(intentParts) == 1 && len(approvedParts) > 1 &&
!containsTopicTerm(approvedParts, intentParts[0])
keepIntentParts := !splitApproved
if keepIntentParts {
for _, part := range intentParts {
sig.addConcept(part, "")
}
}
for _, termParts := range approvedTermParts {
// A multi-concept query ("後端 外包") is a conjunction, not an
// alias declaration. Treating each token as an alias would let a post
// mentioning only "後端" pass a signature that requires "後端工程師".
explicitAlias := len(termParts) == 1
for _, part := range termParts {
if isTopicAnchor(part) {
continue
}
matched := false
for i := range sig.Concepts {
concept := &sig.Concepts[i]
for _, core := range concept.Cores {
if topicEquivalent(part, core) {
matched = true
continue
}
// Work-intent vocabulary is a bounded synonym group. A
// user asking for 接案 should still see a post that says
// 外包, but this never turns arbitrary query fragments into
// aliases for a role or named entity.
if isWorkTopicTerm(core) && isWorkTopicTerm(part) {
concept.addAlias(part)
matched = true
continue
}
// Only a single-token approved term explicitly grants an
// alias. Never infer aliases from a conjunction's fragments.
if explicitAlias && topicContains(core, part) && topicRuneCount(part) >= 2 {
concept.addAlias(part)
matched = true
}
}
}
if !matched && splitApproved {
sig.addConcept(part, "")
}
}
}
// No usable concept means the input contained only generic anchors. It is
// safer to reject every candidate than to turn "推薦" into a topic.
return sig
}
// BuildTopicSignature is kept as a descriptive alias for callers that prefer
// a builder-style name.
func BuildTopicSignature(intent string, approvedTerms []string) TopicSignature {
return NewTopicSignature(intent, approvedTerms)
}
func (s *TopicSignature) addConcept(core, alias string) {
core = normalizeTopicTerm(core)
if core == "" || isTopicAnchor(core) {
return
}
for i := range s.Concepts {
if topicEquivalent(s.Concepts[i].Name, core) {
if alias != "" {
s.Concepts[i].addAlias(alias)
}
return
}
}
c := TopicConcept{Name: core, Cores: []string{core}}
if alias != "" {
c.addAlias(alias)
}
s.Concepts = append(s.Concepts, c)
}
func (c *TopicConcept) addAlias(alias string) {
alias = normalizeTopicTerm(alias)
if alias == "" || isTopicAnchor(alias) || topicEquivalent(alias, c.Name) {
return
}
for _, existing := range c.Aliases {
if topicEquivalent(existing, alias) {
return
}
}
c.Aliases = append(c.Aliases, alias)
}
// Match applies the all-concept hard gate. Matching is case-insensitive and
// whitespace-insensitive, which handles CJK and Latin terms consistently.
func (s TopicSignature) Match(text string) TopicMatch {
body := normalizeTopicText(text)
match := TopicMatch{Matched: len(s.Concepts) > 0}
for _, concept := range s.Concepts {
matched := ""
candidates := append(append([]string(nil), concept.Cores...), concept.Aliases...)
for _, candidate := range candidates {
candidate = normalizeTopicText(candidate)
if candidate != "" && strings.Contains(body, candidate) {
matched = candidate
break
}
}
if matched == "" {
match.Matched = false
match.MissingGroups = append(match.MissingGroups, concept.Name)
continue
}
match.MatchedCores = append(match.MatchedCores, matched)
}
if len(match.MatchedCores) > 0 {
match.Reason = "matched core: " + strings.Join(match.MatchedCores, ", ")
}
if len(match.MissingGroups) > 0 {
if match.Reason != "" {
match.Reason += "; "
}
match.Reason += "missing core: " + strings.Join(match.MissingGroups, ", ")
}
return match
}
func (s TopicSignature) Matches(text string) bool { return s.Match(text).Matched }
var topicAnchors = []string{
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
"有人知道", "請問", "請問一下", "求助", "有沒有", "有沒有人", "有人也",
"熱門", "最新", "近期",
}
func isTopicAnchor(term string) bool {
term = normalizeTopicText(term)
for _, anchor := range topicAnchors {
if term == normalizeTopicText(anchor) {
return true
}
}
return false
}
func semanticTopicParts(raw string) []string {
raw = normalizeTopicTerm(raw)
if raw == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune(",,、/|·。!?!?:;()【】[]", r)
})
parts := make([]string, 0, len(fields))
for _, field := range fields {
field = normalizeTopicTerm(field)
if field == "" || isTopicAnchor(field) || isTopicModifier(field) {
continue
}
parts = append(parts, field)
}
return uniqueTopicTerms(parts)
}
// Natural activity input is often a sentence ("想找後端工程師接案"), while
// approved search terms are short variants. Treating that whole sentence as
// one mandatory contiguous core makes every real post fail the gate. Derive
// concepts from the approved terms instead, but keep the gate conjunctive.
func isNaturalTopicIntent(intent string) bool {
if len(splitTitleCores(intent)) > 0 {
return false
}
if topicRuneCount(intent) > 8 {
return true
}
body := normalizeTopicText(intent)
for _, marker := range naturalSentenceMarkers {
if strings.Contains(body, normalizeTopicText(marker)) {
return true
}
}
for _, marker := range activityWorkMarkers {
if strings.Contains(body, normalizeTopicText(marker)) {
return true
}
}
return false
}
var naturalTopicFillers = []string{
"想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人",
"適合", "什麼", "怎麼", "如何", "最近", "換季", "週末", "這週", "本週",
"真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看",
}
var naturalSentenceMarkers = []string{
"想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人",
"適合", "什麼", "怎麼", "如何", "最近", "換季", "真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看",
}
func deriveNaturalTopicParts(intent string, approvedTerms []string) []string {
body := normalizeTopicText(intent)
type candidate struct {
term string
count int
}
candidates := make([]candidate, 0, 8)
index := make(map[string]int)
add := func(raw string) {
raw = cleanNaturalTopicPart(raw)
key := normalizeTopicText(raw)
if key == "" || topicRuneCount(raw) < 2 || !strings.Contains(body, key) {
return
}
if i, ok := index[key]; ok {
candidates[i].count++
return
}
index[key] = len(candidates)
candidates = append(candidates, candidate{term: raw, count: 1})
}
for _, region := range activityRegions {
if strings.Contains(body, normalizeTopicText(region)) {
add(region)
if i, ok := index[normalizeTopicText(region)]; ok {
candidates[i].count = 2
}
}
}
// Preserve explicit work/role vocabulary from the user's sentence even if
// they unchecked the corresponding generated query variant.
knownIntentTerms := append([]string{}, activityWorkMarkers...)
knownIntentTerms = append(knownIntentTerms, activityRoleSpecialties...)
knownIntentTerms = append(knownIntentTerms, "工程師")
for _, term := range knownIntentTerms {
if strings.Contains(body, normalizeTopicText(term)) {
add(term)
if i, ok := index[normalizeTopicText(term)]; ok {
candidates[i].count = 2
}
}
}
for _, term := range approvedTerms {
for _, part := range semanticTopicParts(term) {
add(part)
}
}
// Prefer compact, repeated concepts over one-off sliding-window fragments
// generated from a sentence. Known work vocabulary may legitimately occur
// in only one approved conjunction (for example 接案).
out := make([]string, 0, len(candidates))
for _, c := range candidates {
if c.count < 2 && !isKnownNaturalTopicTerm(c.term) {
continue
}
out = append(out, c.term)
}
if len(out) == 0 {
for _, part := range semanticTopicParts(intent) {
if cleaned := cleanNaturalTopicPart(part); cleaned != "" {
out = append(out, cleaned)
}
}
}
return pruneContainedTopicParts(uniqueTopicTerms(out))
}
func cleanNaturalTopicPart(raw string) string {
raw = normalizeTopicTerm(raw)
for _, filler := range naturalTopicFillers {
raw = strings.ReplaceAll(raw, filler, "")
}
for _, modifier := range []string{"熱門", "最新", "近期"} {
raw = strings.ReplaceAll(raw, modifier, "")
}
return normalizeTopicTerm(raw)
}
func isKnownNaturalTopicTerm(term string) bool {
term = normalizeTopicText(term)
if term == "工程師" {
return true
}
for _, group := range [][]string{activityWorkMarkers, activityRoleSpecialties, activityRegions} {
for _, known := range group {
if topicEquivalent(term, known) {
return true
}
}
}
return false
}
func isWorkTopicTerm(term string) bool {
term = normalizeTopicText(term)
for _, known := range activityWorkMarkers {
if normalizeTopicText(known) == term {
return true
}
}
return false
}
func pruneContainedTopicParts(parts []string) []string {
keep := make([]string, 0, len(parts))
for _, part := range parts {
partKey := normalizeTopicText(part)
contained := false
for _, other := range parts {
otherKey := normalizeTopicText(other)
if otherKey == partKey || topicRuneCount(other) <= topicRuneCount(part) {
continue
}
if strings.Contains(otherKey, partKey) && !isKnownNaturalTopicTerm(part) {
contained = true
break
}
}
if !contained {
keep = append(keep, part)
}
}
return keep
}
func isTopicModifier(term string) bool {
term = normalizeTopicText(term)
return term == "熱門" || term == "最新" || term == "近期"
}
func normalizeTopicTerm(raw string) string {
raw = strings.ReplaceAll(raw, "\u3000", " ")
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
}
func normalizeTopicText(raw string) string {
return strings.ReplaceAll(strings.ToLower(normalizeTopicTerm(raw)), " ", "")
}
func uniqueTopicTerms(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, term := range in {
term = normalizeTopicTerm(term)
key := normalizeTopicText(term)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, term)
}
return out
}
func containsTopicTerm(terms []string, want string) bool {
want = normalizeTopicText(want)
for _, term := range terms {
if normalizeTopicText(term) == want {
return true
}
}
return false
}
func topicEquivalent(a, b string) bool { return normalizeTopicText(a) == normalizeTopicText(b) }
func topicContains(container, part string) bool {
return strings.Contains(normalizeTopicText(container), normalizeTopicText(part))
}
func topicRuneCount(term string) int { return utf8.RuneCountInString(normalizeTopicText(term)) }
// Keep deterministic output if a caller serializes concepts for diagnostics.
func (s TopicSignature) SortConcepts() {
sort.SliceStable(s.Concepts, func(i, j int) bool { return s.Concepts[i].Name < s.Concepts[j].Name })
}