fix all bug
This commit is contained in:
parent
806cd51333
commit
ee527ed988
|
|
@ -408,12 +408,17 @@ func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Serv
|
||||||
}
|
}
|
||||||
res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID)
|
res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// RunSweep persists a user-facing reason on fetch failures. Return that
|
||||||
|
// instead of leaking the crawler/provider transport response into Jobs UI.
|
||||||
|
if res != nil && res.FetchFailed && strings.TrimSpace(res.FailedReason) != "" {
|
||||||
|
return errors.New(res.FailedReason)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 判定 %d · 截斷 %d", res.Created, res.Judged, res.Truncated)
|
|
||||||
if res.FetchFailed {
|
if res.FetchFailed {
|
||||||
return fmt.Errorf("%s", res.FailedReason)
|
return fmt.Errorf("%s", res.FailedReason)
|
||||||
}
|
}
|
||||||
|
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 再次命中 %d · 判定 %d · 截斷 %d", res.Created, res.Rematched, res.Judged, res.Truncated)
|
||||||
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil {
|
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesR
|
||||||
postedFrom, postedTo := req.From, req.To
|
postedFrom, postedTo := req.From, req.To
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
if req.TimeScope == "today" {
|
if req.TimeScope == "today" {
|
||||||
postedFrom, postedTo = domain.UTCDayBounds(now)
|
postedFrom, postedTo = domain.LocalDayBounds(now, domain.DisplayLocation())
|
||||||
} else if req.TimeScope == "7d" {
|
} else if req.TimeScope == "7d" {
|
||||||
postedFrom, postedTo = now-7*int64(24*time.Hour), 0
|
postedFrom, postedTo = now-7*int64(24*time.Hour), 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -227,6 +228,61 @@ func (s *Service) findRadarSweepForRef(ctx context.Context, ownerUID int64, ref
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScheduleManualRadarSweep starts an extra patrol now.
|
||||||
|
// Daily jobs are keyed watch+UTC day and must stay idempotent; a finished
|
||||||
|
// daily job must not swallow 「立即巡邏」. If this watch already has a
|
||||||
|
// queued/running sweep, that in-flight job is returned instead of stacking.
|
||||||
|
func (s *Service) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
|
||||||
|
watchID = strings.TrimSpace(watchID)
|
||||||
|
if ownerUID <= 0 || watchID == "" {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
if runAt <= 0 {
|
||||||
|
runAt = domain.NowNano()
|
||||||
|
}
|
||||||
|
list, err := s.Repo.ListByOwner(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefix := watchID + ":"
|
||||||
|
for _, j := range list {
|
||||||
|
if j == nil || j.TemplateType != domain.TemplateRadarSweep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(j.RefID, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !domain.IsTerminal(j.Status) {
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
day := time.Unix(0, runAt).UTC().Format("2006-01-02")
|
||||||
|
ref := watchID + ":manual:" + strconv.FormatInt(runAt, 10)
|
||||||
|
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := domain.NowNano()
|
||||||
|
j := &domain.Job{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
TemplateType: domain.TemplateRadarSweep,
|
||||||
|
Status: domain.StatusQueued,
|
||||||
|
RefID: ref,
|
||||||
|
Payload: string(body),
|
||||||
|
RunAfter: runAt,
|
||||||
|
ProgressSummary: "立即巡邏已排程 · 等待 worker",
|
||||||
|
ProgressPercent: 0,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.Repo.Insert(ctx, j); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.notify(ctx, j)
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
|
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
|
||||||
return s.Repo.ListByOwner(ctx, ownerUID)
|
return s.Repo.ListByOwner(ctx, ownerUID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,6 +374,32 @@ func TestJobLease_HeartbeatRenewsWhileRunning(t *testing.T) {
|
||||||
}, 200*time.Millisecond, 5*time.Millisecond)
|
}, 200*time.Millisecond, 5*time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScheduleManualRadarSweep_ReusesInFlightAndCreatesAfterSuccess(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := usecase.New(jobRepo.NewMemory())
|
||||||
|
first, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, first.RefID, ":manual:")
|
||||||
|
again, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, first.ID, again.ID)
|
||||||
|
|
||||||
|
claimed, err := svc.ClaimNext(ctx, "worker-manual")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, first.ID, claimed.ID)
|
||||||
|
_, err = svc.SucceedJob(ctx, first.ID, "done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
second, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, first.ID, second.ID)
|
||||||
|
require.Contains(t, second.RefID, ":manual:")
|
||||||
|
|
||||||
|
daily, err := svc.ScheduleRadarSweep(ctx, 42, "watch-1", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, second.ID, daily.ID)
|
||||||
|
require.NotContains(t, daily.RefID, ":manual:")
|
||||||
|
}
|
||||||
|
|
||||||
func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) {
|
func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) {
|
||||||
repo := jobRepo.NewMemory()
|
repo := jobRepo.NewMemory()
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
|
|
|
||||||
|
|
@ -431,3 +431,51 @@ func UTCDayBounds(at int64) (start, end int64) {
|
||||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
|
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayLocation is the product default timezone (Harbor Desk is Taipei-first).
|
||||||
|
func DisplayLocation() *time.Location {
|
||||||
|
return time.FixedZone("Asia/Taipei", 8*60*60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at.
|
||||||
|
func LocalDayBounds(at int64, loc *time.Location) (start, end int64) {
|
||||||
|
if loc == nil {
|
||||||
|
loc = DisplayLocation()
|
||||||
|
}
|
||||||
|
if at <= 0 {
|
||||||
|
at = NowNano()
|
||||||
|
}
|
||||||
|
t := time.Unix(0, at).In(loc)
|
||||||
|
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||||
|
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InInboxTimeScope is true when a row belongs on today/7d.
|
||||||
|
// Posted-in-range is the primary meaning; a just-finished sweep must still
|
||||||
|
// surface older or undated posts via last_matched_at / created_at, otherwise
|
||||||
|
// the default pending+today inbox looks empty after patrol.
|
||||||
|
func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool {
|
||||||
|
if from <= 0 && to <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
in := func(ts int64) bool {
|
||||||
|
if ts <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if from > 0 && ts < from {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if to > 0 && ts >= to {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if in(postedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
discovered := lastMatchedAt
|
||||||
|
if discovered <= 0 {
|
||||||
|
discovered = createdAt
|
||||||
|
}
|
||||||
|
return in(discovered)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package domain
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBandFromScore_LockedThresholds(t *testing.T) {
|
func TestBandFromScore_LockedThresholds(t *testing.T) {
|
||||||
|
|
@ -34,6 +35,18 @@ func TestUnknownPublishedTimeIsNotStale(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTwentyDayOldPostIsNotHardRejected(t *testing.T) {
|
||||||
|
now := NowNano()
|
||||||
|
posted := now - 20*24*int64(time.Hour)
|
||||||
|
if IsStaleHardReject(posted, now) {
|
||||||
|
t.Fatal("a 20-day-old demand post must still be judgeable, not hard-rejected")
|
||||||
|
}
|
||||||
|
old := now - 40*24*int64(time.Hour)
|
||||||
|
if !IsStaleHardReject(old, now) {
|
||||||
|
t.Fatal("a 40-day-old post should still be hard-rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateReasons_RequiresAllFive(t *testing.T) {
|
func TestValidateReasons_RequiresAllFive(t *testing.T) {
|
||||||
full := fiveReasons()
|
full := fiveReasons()
|
||||||
if err := ValidateReasons(full); err != nil {
|
if err := ValidateReasons(full); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalDayBoundsUsesTaipeiNotUTC(t *testing.T) {
|
||||||
|
// 2026-08-13 02:44 UTC is 10:44 in Taipei, same local calendar day.
|
||||||
|
// 2026-08-13 00:30 Taipei is still 2026-08-12 16:30 UTC.
|
||||||
|
morningTaipei := time.Date(2026, 8, 13, 0, 30, 0, 0, DisplayLocation()).UnixNano()
|
||||||
|
start, end := LocalDayBounds(morningTaipei, DisplayLocation())
|
||||||
|
utcStart, utcEnd := UTCDayBounds(morningTaipei)
|
||||||
|
if start == utcStart {
|
||||||
|
t.Fatalf("local day must not collapse to UTC day: local=%d utc=%d", start, utcStart)
|
||||||
|
}
|
||||||
|
if morningTaipei < start || morningTaipei >= end {
|
||||||
|
t.Fatalf("Taipei 00:30 should sit inside local today [%d, %d)", start, end)
|
||||||
|
}
|
||||||
|
if utcEnd <= start {
|
||||||
|
t.Fatal("expected UTC Aug 12 window to end at Taipei midnight Aug 13")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInInboxTimeScopeKeepsResweptAndUndatedRows(t *testing.T) {
|
||||||
|
from, to := LocalDayBounds(NowNano(), DisplayLocation())
|
||||||
|
oldPost := from - int64(48*time.Hour)
|
||||||
|
if InInboxTimeScope(oldPost, 0, oldPost, from, to) {
|
||||||
|
t.Fatal("stale post that was not rematched today should stay out of today")
|
||||||
|
}
|
||||||
|
if !InInboxTimeScope(oldPost, from+1, oldPost, from, to) {
|
||||||
|
t.Fatal("a post rematched today must remain visible after patrol")
|
||||||
|
}
|
||||||
|
if !InInboxTimeScope(0, 0, from+1, from, to) {
|
||||||
|
t.Fatal("undated post created today must remain visible after patrol")
|
||||||
|
}
|
||||||
|
if !InInboxTimeScope(oldPost, 0, from+1, from, to) {
|
||||||
|
t.Fatal("older post created today without last_matched must remain visible (no-backfill)")
|
||||||
|
}
|
||||||
|
if !InInboxTimeScope(from+1, 0, oldPost, from, to) {
|
||||||
|
t.Fatal("post published today should stay on today even if created earlier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,10 @@ const (
|
||||||
WeightFit = 10
|
WeightFit = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// Freshness hard-reject after 14 days.
|
// Freshness scoring decays after 14 days. Hard-reject is wider so Exa/Threads
|
||||||
|
// hits from the last month still reach the inbox as stale, not as "found nothing".
|
||||||
const MaxFreshnessDays = 14
|
const MaxFreshnessDays = 14
|
||||||
|
const StaleHardRejectDays = 30
|
||||||
|
|
||||||
// FreshnessScore maps age in hours to the 0–15 freshness dimension score.
|
// FreshnessScore maps age in hours to the 0–15 freshness dimension score.
|
||||||
func FreshnessScore(hours int) int {
|
func FreshnessScore(hours int) int {
|
||||||
|
|
@ -50,12 +52,14 @@ func FreshnessHoursSince(postedAt, now int64) int {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStaleHardReject is true when the post is older than 14 days.
|
// IsStaleHardReject is true when the post is older than a month.
|
||||||
|
// 14–30 day posts stay scoreable (low freshness) so a just-run patrol can
|
||||||
|
// still surface a real pain that Threads/Exa returned as an older result.
|
||||||
func IsStaleHardReject(postedAt, now int64) bool {
|
func IsStaleHardReject(postedAt, now int64) bool {
|
||||||
if postedAt <= 0 {
|
if postedAt <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24
|
return FreshnessHoursSince(postedAt, now) > StaleHardRejectDays*24
|
||||||
}
|
}
|
||||||
|
|
||||||
// SumReasonScores totals dimension scores (capped components assumed already).
|
// SumReasonScores totals dimension scores (capped components assumed already).
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ func NormalizeSuggestUsage(s string) string {
|
||||||
CleanSuggestions 收掉空白與重複,丟掉沒有理由的項目,並套用數量上限。
|
CleanSuggestions 收掉空白與重複,丟掉沒有理由的項目,並套用數量上限。
|
||||||
|
|
||||||
沒有理由的項目直接丟:補一句「AI 建議」等於假裝有理由,比少一則更糟。
|
沒有理由的項目直接丟:補一句「AI 建議」等於假裝有理由,比少一則更糟。
|
||||||
include 關鍵字必須通過 Threads 短詞規則(IsThreadsSearchable);不合規整條丟掉、不截短,
|
include 必須能在 Threads 搜到:已合規的原詞保留;過長或超過兩個 token 的先收成短詞變體,
|
||||||
避免產出半截怪詞。exclude 仍用較寬的長度界線(訂閱排除詞可能較長)。
|
變體也沒有才丟掉。exclude 仍用較寬的長度界線(訂閱排除詞可能較長)。
|
||||||
*/
|
*/
|
||||||
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
|
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
|
||||||
if limit <= 0 || limit > MaxSuggestions {
|
if limit <= 0 || limit > MaxSuggestions {
|
||||||
|
|
@ -68,10 +68,18 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usage := NormalizeSuggestUsage(s.Usage)
|
usage := NormalizeSuggestUsage(s.Usage)
|
||||||
|
basisText := strings.TrimSpace(s.BasisText)
|
||||||
if usage == SuggestUsageInclude {
|
if usage == SuggestUsageInclude {
|
||||||
if !IsThreadsSearchable(term) {
|
if !IsThreadsSearchable(term) {
|
||||||
|
variants := SearchableTermVariants(term, true)
|
||||||
|
if len(variants) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if basisText == "" {
|
||||||
|
basisText = term
|
||||||
|
}
|
||||||
|
term = variants[0]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// exclude: keep broader length; still reject empty after normalize
|
// exclude: keep broader length; still reject empty after normalize
|
||||||
n := len([]rune(term))
|
n := len([]rune(term))
|
||||||
|
|
@ -86,7 +94,7 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
out = append(out, WatchTermSuggestion{
|
out = append(out, WatchTermSuggestion{
|
||||||
Term: term, Reason: reason, Usage: usage,
|
Term: term, Reason: reason, Usage: usage,
|
||||||
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: strings.TrimSpace(s.BasisText),
|
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: basisText,
|
||||||
})
|
})
|
||||||
if len(out) >= limit {
|
if len(out) >= limit {
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const (
|
||||||
MaxCJKTokenRunes = 4
|
MaxCJKTokenRunes = 4
|
||||||
MaxThreadsTermRunes = 12 // 去掉空白後
|
MaxThreadsTermRunes = 12 // 去掉空白後
|
||||||
MaxExploreTerms = 6
|
MaxExploreTerms = 6
|
||||||
|
// 一則長句最多收成幾組可搜短詞,避免滑窗把訂閱或需求地圖塞滿半截字。
|
||||||
|
MaxSearchableVariantsPerTerm = 3
|
||||||
|
MaxSearchableVariants = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
|
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
|
||||||
|
|
@ -109,3 +112,180 @@ func isCJK(r rune) bool {
|
||||||
unicode.Is(unicode.Katakana, r) ||
|
unicode.Is(unicode.Katakana, r) ||
|
||||||
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
|
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var searchFillers = []string{
|
||||||
|
"怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問",
|
||||||
|
"想問", "想找", "有沒有", "可以嗎", "好不好",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchableTermVariants turns a user/product phrase into Threads-searchable queries.
|
||||||
|
// If the input already passes IsThreadsSearchable it is the only result.
|
||||||
|
// Longer phrases are shortened: adjacent token pairs, filler tails stripped,
|
||||||
|
// then first/last 2–4 CJK windows. include=false keeps the broader exclude length.
|
||||||
|
func SearchableTermVariants(raw string, include bool) []string {
|
||||||
|
raw = NormalizeSearchTerm(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, MaxSearchableVariants)
|
||||||
|
add := func(term string) {
|
||||||
|
term = NormalizeSearchTerm(term)
|
||||||
|
if term == "" || seen[strings.ToLower(term)] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if include {
|
||||||
|
if !IsThreadsSearchable(term) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if n := utf8.RuneCountInString(term); n < MinTermLen || n > MaxTermLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[strings.ToLower(term)] = true
|
||||||
|
out = append(out, term)
|
||||||
|
}
|
||||||
|
|
||||||
|
if include && IsThreadsSearchable(raw) {
|
||||||
|
add(raw)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
add(raw)
|
||||||
|
if len(out) > 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := strings.Fields(raw)
|
||||||
|
if len(tokens) > MaxThreadsTokens {
|
||||||
|
for i := 0; i+1 < len(tokens) && len(out) < MaxSearchableVariants; i++ {
|
||||||
|
add(tokens[i] + " " + tokens[i+1])
|
||||||
|
}
|
||||||
|
for _, tok := range tokens {
|
||||||
|
if len(out) >= MaxSearchableVariants {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, v := range SearchableTermVariants(tok, include) {
|
||||||
|
add(v)
|
||||||
|
if len(out) >= MaxSearchableVariants {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := splitSearchParts(raw)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
parts = []string{raw}
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if len(out) >= MaxSearchableVariants {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if include && IsThreadsSearchable(part) {
|
||||||
|
add(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stripped := stripSearchFillers(part)
|
||||||
|
if stripped != "" && stripped != part {
|
||||||
|
add(stripped)
|
||||||
|
}
|
||||||
|
runes := []rune(stripped)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
runes = []rune(part)
|
||||||
|
}
|
||||||
|
if len(runes) == 5 {
|
||||||
|
add(string(runes[:3]))
|
||||||
|
add(string(runes[3:]))
|
||||||
|
}
|
||||||
|
for _, width := range []int{4, 3, 2} {
|
||||||
|
if len(runes) < width {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(string(runes[:width]))
|
||||||
|
if len(runes) > width {
|
||||||
|
add(string(runes[len(runes)-width:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) >= 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for width := 4; width >= 2; width-- {
|
||||||
|
if len(runes) < width {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for start := 0; start+width <= len(runes) && len(out) < MaxSearchableVariants; start++ {
|
||||||
|
add(string(runes[start : start+width]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandSearchTerms keeps already-searchable include terms and shortens the rest.
|
||||||
|
// Used when persisting a watch and when fanning out a sweep.
|
||||||
|
func ExpandSearchTerms(terms []string, max int) []string {
|
||||||
|
if max <= 0 {
|
||||||
|
max = MaxWatchTerms
|
||||||
|
}
|
||||||
|
out := make([]string, 0, max)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(term string) bool {
|
||||||
|
term = strings.ToLower(NormalizeSearchTerm(term))
|
||||||
|
if term == "" || !IsThreadsSearchable(term) || seen[term] {
|
||||||
|
return len(out) < max
|
||||||
|
}
|
||||||
|
seen[term] = true
|
||||||
|
out = append(out, term)
|
||||||
|
return len(out) < max
|
||||||
|
}
|
||||||
|
for _, raw := range terms {
|
||||||
|
raw = NormalizeSearchTerm(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if IsThreadsSearchable(raw) {
|
||||||
|
if !add(raw) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, v := range SearchableTermVariants(raw, true) {
|
||||||
|
if i >= MaxSearchableVariantsPerTerm {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !add(v) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripSearchFillers(s string) string {
|
||||||
|
for _, filler := range searchFillers {
|
||||||
|
s = strings.ReplaceAll(s, filler, "")
|
||||||
|
}
|
||||||
|
return strings.Trim(s, " ,,、。!?!?::的了嗎呢啊喔唷")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSearchParts(raw string) []string {
|
||||||
|
var b strings.Builder
|
||||||
|
parts := make([]string, 0, 4)
|
||||||
|
flush := func() {
|
||||||
|
if value := strings.TrimSpace(b.String()); value != "" {
|
||||||
|
parts = append(parts, value)
|
||||||
|
}
|
||||||
|
b.Reset()
|
||||||
|
}
|
||||||
|
for _, r := range raw {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,51 @@ func TestIsThreadsSearchable(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchableTermVariantsKeepsShortAndShortensLong(t *testing.T) {
|
||||||
|
got := SearchableTermVariants("保母 求推薦", true)
|
||||||
|
if len(got) != 1 || got[0] != "保母 求推薦" {
|
||||||
|
t.Fatalf("short term variants=%v", got)
|
||||||
|
}
|
||||||
|
got = SearchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦", true)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("long sentence produced no variants")
|
||||||
|
}
|
||||||
|
for _, term := range got {
|
||||||
|
if !IsThreadsSearchable(term) {
|
||||||
|
t.Fatalf("variant %q is not searchable", term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasDry := false
|
||||||
|
hasSleep := false
|
||||||
|
for _, term := range got {
|
||||||
|
if term == "口乾舌燥" {
|
||||||
|
hasDry = true
|
||||||
|
}
|
||||||
|
if term == "晚上睡覺" {
|
||||||
|
hasSleep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasDry || !hasSleep {
|
||||||
|
t.Fatalf("variants=%v want 晚上睡覺 and 口乾舌燥", got)
|
||||||
|
}
|
||||||
|
got = SearchableTermVariants("台北 婚攝 推薦 價格", true)
|
||||||
|
if len(got) == 0 || got[0] != "台北 婚攝" {
|
||||||
|
t.Fatalf("multi-token variants=%v want 台北 婚攝 first", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandSearchTermsShortensUnsearchable(t *testing.T) {
|
||||||
|
got := ExpandSearchTerms([]string{"求推薦", "晚上睡覺容易口乾舌燥怎麼辦"}, 20)
|
||||||
|
if len(got) < 2 {
|
||||||
|
t.Fatalf("expanded=%v", got)
|
||||||
|
}
|
||||||
|
if got[0] != "求推薦" {
|
||||||
|
t.Fatalf("kept original first, got %v", got)
|
||||||
|
}
|
||||||
|
for _, term := range got {
|
||||||
|
if !IsThreadsSearchable(term) {
|
||||||
|
t.Fatalf("expanded term %q is not searchable", term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,13 @@ func (w *RadarWatch) Normalize() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTerms(in []string, field string, max int) ([]string, error) {
|
func normalizeTerms(in []string, field string, max int) ([]string, error) {
|
||||||
|
if field == "terms" {
|
||||||
|
out := ExpandSearchTerms(in, max)
|
||||||
|
if len(out) > max {
|
||||||
|
return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
out := make([]string, 0, len(in))
|
out := make([]string, 0, len(in))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, raw := range in {
|
for _, raw := range in {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/radar/domain"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReviewStateListClauseLegacyPending(t *testing.T) {
|
||||||
|
pending := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewPending))
|
||||||
|
for _, part := range []string{domain.ReviewPending, "$nin", domain.OppAccepted, domain.OppDismissed} {
|
||||||
|
if !strings.Contains(pending, part) {
|
||||||
|
t.Fatalf("legacy pending clause missing %q: %s", part, pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewCompleted))
|
||||||
|
if !strings.Contains(completed, domain.ReviewCompleted) || !strings.Contains(completed, domain.OppAccepted) {
|
||||||
|
t.Fatalf("completed clause: %s", completed)
|
||||||
|
}
|
||||||
|
removed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewRemoved))
|
||||||
|
if !strings.Contains(removed, domain.ReviewRemoved) || !strings.Contains(removed, domain.OppDismissed) {
|
||||||
|
t.Fatalf("removed clause: %s", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxTimeScopeClauseOldPostedCreatedTodayNoLastMatched(t *testing.T) {
|
||||||
|
from, to := domain.LocalDayBounds(domain.NowNano(), domain.DisplayLocation())
|
||||||
|
old := from - int64(48*time.Hour)
|
||||||
|
clause := inboxTimeScopeClause(from, to)
|
||||||
|
|
||||||
|
justFound := bson.M{"posted_at": old, "created_at": from + 1}
|
||||||
|
if !bsonMMatches(clause, justFound) {
|
||||||
|
t.Fatalf("posted=old created=today last_matched missing must match today $or; clause=%#v", clause)
|
||||||
|
}
|
||||||
|
zeroLast := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": int64(0)}
|
||||||
|
if !bsonMMatches(clause, zeroLast) {
|
||||||
|
t.Fatalf("posted=old created=today last_matched=0 must match today $or; clause=%#v", clause)
|
||||||
|
}
|
||||||
|
staleMatch := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": from - int64(24*time.Hour)}
|
||||||
|
if bsonMMatches(clause, staleMatch) {
|
||||||
|
t.Fatal("old last_matched must not fall back to created_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOpportunitiesCreatedTodayOldPostedWithoutUpsert(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
m := NewMemory()
|
||||||
|
now := domain.NowNano()
|
||||||
|
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
|
||||||
|
row := sampleOpp("seed-no-upsert", []string{"漏水"}, 80)
|
||||||
|
row.ID = "seed-no-upsert"
|
||||||
|
row.PostedAt = from - int64(48*time.Hour)
|
||||||
|
row.CreatedAt = from + 1
|
||||||
|
row.LastMatchedAt = 0
|
||||||
|
row.ReviewState = ""
|
||||||
|
m.opportunities[row.ID] = row
|
||||||
|
|
||||||
|
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
|
||||||
|
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
|
||||||
|
})
|
||||||
|
if err != nil || total != 1 || len(pending) != 1 || pending[0].ID != "seed-no-upsert" {
|
||||||
|
t.Fatalf("no-backfill just-found row missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bsonMMatches(query bson.M, doc bson.M) bool {
|
||||||
|
if or, ok := query["$or"]; ok && !bsonOrMatches(or, doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if and, ok := query["$and"]; ok && !bsonAndMatches(and, doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key, cond := range query {
|
||||||
|
if key == "$or" || key == "$and" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !bsonFieldMatches(key, cond, doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func bsonOrMatches(raw any, doc bson.M) bool {
|
||||||
|
items, ok := raw.(bson.A)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
clause, ok := item.(bson.M)
|
||||||
|
if ok && bsonMMatches(clause, doc) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func bsonAndMatches(raw any, doc bson.M) bool {
|
||||||
|
items, ok := raw.(bson.A)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
clause, ok := item.(bson.M)
|
||||||
|
if !ok || !bsonMMatches(clause, doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func bsonFieldMatches(key string, cond any, doc bson.M) bool {
|
||||||
|
got, exists := doc[key]
|
||||||
|
switch c := cond.(type) {
|
||||||
|
case bson.M:
|
||||||
|
if flag, ok := c["$exists"]; ok {
|
||||||
|
want, _ := flag.(bool)
|
||||||
|
if exists != want {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gte, ok := c["$gte"]; ok {
|
||||||
|
if !exists || toQueryInt64(got) < toQueryInt64(gte) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lt, ok := c["$lt"]; ok {
|
||||||
|
if !exists || toQueryInt64(got) >= toQueryInt64(lt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return exists && toQueryInt64(got) == toQueryInt64(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toQueryInt64(v any) int64 {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return int64(n)
|
||||||
|
case float64:
|
||||||
|
return int64(n)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -253,12 +253,20 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O
|
||||||
if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo {
|
if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if f.PostedFrom > 0 || f.PostedTo > 0 {
|
||||||
|
if f.TimeScope == "today" || f.TimeScope == "7d" {
|
||||||
|
if !domain.InInboxTimeScope(o.PostedAt, o.LastMatchedAt, o.CreatedAt, f.PostedFrom, f.PostedTo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
|
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
|
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
matched = append(matched, cloneOpportunity(o))
|
matched = append(matched, cloneOpportunity(o))
|
||||||
}
|
}
|
||||||
sort.Slice(matched, func(i, j int) bool {
|
sort.Slice(matched, func(i, j int) bool {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"apps/backend/internal/module/radar/domain"
|
"apps/backend/internal/module/radar/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -146,6 +147,52 @@ func TestUpdateStatusAndOverride(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListOpportunitiesLegacyPendingAndResweptToday(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
m := NewMemory()
|
||||||
|
now := domain.NowNano()
|
||||||
|
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
|
||||||
|
old := from - int64(72*time.Hour)
|
||||||
|
|
||||||
|
legacy := sampleOpp("legacy-pending", []string{"漏水"}, 80)
|
||||||
|
legacy.ReviewState = ""
|
||||||
|
legacy.PostedAt = old
|
||||||
|
legacy.CreatedAt = old
|
||||||
|
legacy.LastMatchedAt = 0
|
||||||
|
if _, err := m.UpsertByExternalID(ctx, legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Re-sweep the same source: merge must not hide the card from today's inbox.
|
||||||
|
if _, err := m.UpsertByExternalID(ctx, &domain.Opportunity{
|
||||||
|
OwnerUID: 42, ExternalID: "legacy-pending", MatchedTerms: []string{"抓漏"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
|
||||||
|
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
|
||||||
|
})
|
||||||
|
if err != nil || total != 1 || len(pending) != 1 {
|
||||||
|
t.Fatalf("legacy rematch missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
|
||||||
|
}
|
||||||
|
if pending[0].ReviewState != domain.ReviewPending {
|
||||||
|
t.Fatalf("legacy review_state = %q", pending[0].ReviewState)
|
||||||
|
}
|
||||||
|
|
||||||
|
undated := sampleOpp("undated-today", []string{"漏水"}, 70)
|
||||||
|
undated.PostedAt = 0
|
||||||
|
undated.CreatedAt = now
|
||||||
|
if _, err := m.UpsertByExternalID(ctx, undated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pending, total, err = m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
|
||||||
|
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
|
||||||
|
})
|
||||||
|
if err != nil || total != 2 {
|
||||||
|
t.Fatalf("undated created-today row should join pending today: total=%d err=%v", total, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReviewStateGuardAndTombstone(t *testing.T) {
|
func TestReviewStateGuardAndTombstone(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
m := NewMemory()
|
m := NewMemory()
|
||||||
|
|
|
||||||
|
|
@ -37,22 +37,18 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
|
||||||
"external_id": externalID,
|
"external_id": externalID,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
terms := domain.NormalizeMatchedTerms(o.MatchedTerms)
|
now := domain.NowNano()
|
||||||
if len(terms) > 0 {
|
set := bson.M{"updated_at": now, "last_matched_at": now}
|
||||||
_, uerr := s.opportunities.UpdateOne(ctx,
|
update := bson.M{"$set": set}
|
||||||
bson.M{"_id": existing.ID},
|
if terms := domain.NormalizeMatchedTerms(o.MatchedTerms); len(terms) > 0 {
|
||||||
bson.M{
|
update["$addToSet"] = bson.M{"matched_terms": bson.M{"$each": terms}}
|
||||||
"$addToSet": bson.M{"matched_terms": bson.M{"$each": terms}},
|
}
|
||||||
"$set": bson.M{"updated_at": domain.NowNano()},
|
if _, uerr := s.opportunities.UpdateOne(ctx, bson.M{"_id": existing.ID}, update); uerr != nil {
|
||||||
})
|
|
||||||
if uerr != nil {
|
|
||||||
return nil, uerr
|
return nil, uerr
|
||||||
}
|
}
|
||||||
// re-read after merge
|
|
||||||
if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
|
if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
|
||||||
return nil, rerr
|
return nil, rerr
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for _, match := range o.ProductMatches {
|
for _, match := range o.ProductMatches {
|
||||||
if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
|
if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
|
||||||
return nil, merr
|
return nil, merr
|
||||||
|
|
@ -85,6 +81,9 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
|
||||||
if o.ReviewState == "" {
|
if o.ReviewState == "" {
|
||||||
o.ReviewState = reviewStateFor(o)
|
o.ReviewState = reviewStateFor(o)
|
||||||
}
|
}
|
||||||
|
if o.LastMatchedAt == 0 {
|
||||||
|
o.LastMatchedAt = now
|
||||||
|
}
|
||||||
o.ExternalID = externalID
|
o.ExternalID = externalID
|
||||||
|
|
||||||
_, err = s.opportunities.InsertOne(ctx, o)
|
_, err = s.opportunities.InsertOne(ctx, o)
|
||||||
|
|
@ -242,10 +241,7 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
|
||||||
q["priority_band"] = f.PriorityBand
|
q["priority_band"] = f.PriorityBand
|
||||||
}
|
}
|
||||||
if f.ReviewState != "" {
|
if f.ReviewState != "" {
|
||||||
q["$or"] = bson.A{
|
appendAnd(q, reviewStateListClause(f.ReviewState))
|
||||||
bson.M{"review_state": f.ReviewState},
|
|
||||||
bson.M{"review_state": bson.M{"$exists": false}, "status": map[string]string{domain.ReviewCompleted: domain.OppAccepted, domain.ReviewRemoved: domain.OppDismissed}[f.ReviewState]},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
switch f.MatchState {
|
switch f.MatchState {
|
||||||
case "eligible":
|
case "eligible":
|
||||||
|
|
@ -255,9 +251,9 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
|
||||||
case "excluded":
|
case "excluded":
|
||||||
q["product_matches.excluded"] = true
|
q["product_matches.excluded"] = true
|
||||||
case "generic":
|
case "generic":
|
||||||
q["$or"] = bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}
|
appendAnd(q, bson.M{"$or": bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}})
|
||||||
case "stale":
|
case "stale":
|
||||||
q["posted_at"] = bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}
|
appendAnd(q, bson.M{"posted_at": bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}})
|
||||||
}
|
}
|
||||||
if f.CreatedFrom > 0 || f.CreatedTo > 0 {
|
if f.CreatedFrom > 0 || f.CreatedTo > 0 {
|
||||||
rng := bson.M{}
|
rng := bson.M{}
|
||||||
|
|
@ -270,6 +266,9 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
|
||||||
q["created_at"] = rng
|
q["created_at"] = rng
|
||||||
}
|
}
|
||||||
if f.PostedFrom > 0 || f.PostedTo > 0 {
|
if f.PostedFrom > 0 || f.PostedTo > 0 {
|
||||||
|
if f.TimeScope == "today" || f.TimeScope == "7d" {
|
||||||
|
appendAnd(q, inboxTimeScopeClause(f.PostedFrom, f.PostedTo))
|
||||||
|
} else {
|
||||||
rng := bson.M{}
|
rng := bson.M{}
|
||||||
if f.PostedFrom > 0 {
|
if f.PostedFrom > 0 {
|
||||||
rng["$gte"] = f.PostedFrom
|
rng["$gte"] = f.PostedFrom
|
||||||
|
|
@ -279,6 +278,7 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
|
||||||
}
|
}
|
||||||
q["posted_at"] = rng
|
q["posted_at"] = rng
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
total, err := s.opportunities.CountDocuments(ctx, q)
|
total, err := s.opportunities.CountDocuments(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -446,3 +446,77 @@ func (s *MonStore) SetOpportunityOverride(ctx context.Context, id string, ov *do
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendAnd(q bson.M, clause bson.M) {
|
||||||
|
if len(clause) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing, ok := q["$and"].(bson.A); ok {
|
||||||
|
q["$and"] = append(existing, clause)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q["$and"] = bson.A{clause}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewStateListClause implements spec §3.2: missing review_state is derived
|
||||||
|
// from OpportunityStatus so a list query never requires a one-shot migration.
|
||||||
|
func reviewStateListClause(state string) bson.M {
|
||||||
|
missing := bson.A{
|
||||||
|
bson.M{"review_state": bson.M{"$exists": false}},
|
||||||
|
bson.M{"review_state": ""},
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case domain.ReviewPending:
|
||||||
|
return bson.M{"$or": bson.A{
|
||||||
|
bson.M{"review_state": domain.ReviewPending},
|
||||||
|
bson.M{"$and": bson.A{
|
||||||
|
bson.M{"$or": missing},
|
||||||
|
bson.M{"status": bson.M{"$nin": []string{domain.OppAccepted, domain.OppDismissed}}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
case domain.ReviewCompleted:
|
||||||
|
return bson.M{"$or": bson.A{
|
||||||
|
bson.M{"review_state": domain.ReviewCompleted},
|
||||||
|
bson.M{"$and": bson.A{
|
||||||
|
bson.M{"$or": missing},
|
||||||
|
bson.M{"status": domain.OppAccepted},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
case domain.ReviewRemoved:
|
||||||
|
return bson.M{"$or": bson.A{
|
||||||
|
bson.M{"review_state": domain.ReviewRemoved},
|
||||||
|
bson.M{"$and": bson.A{
|
||||||
|
bson.M{"$or": missing},
|
||||||
|
bson.M{"status": domain.OppDismissed},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
default:
|
||||||
|
return bson.M{"review_state": state}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboxTimeScopeClause(from, to int64) bson.M {
|
||||||
|
rng := bson.M{}
|
||||||
|
if from > 0 {
|
||||||
|
rng["$gte"] = from
|
||||||
|
}
|
||||||
|
if to > 0 {
|
||||||
|
rng["$lt"] = to
|
||||||
|
}
|
||||||
|
// Same predicate as domain.InInboxTimeScope: posted in range, or
|
||||||
|
// last_matched in range, or last_matched missing/0 and created in range.
|
||||||
|
// Must not gate created_at on posted_at — a just-found older post has a
|
||||||
|
// real posted_at from yesterday and no last_matched_at (no-backfill).
|
||||||
|
missingLastMatched := bson.A{
|
||||||
|
bson.M{"last_matched_at": bson.M{"$exists": false}},
|
||||||
|
bson.M{"last_matched_at": int64(0)},
|
||||||
|
}
|
||||||
|
return bson.M{"$or": bson.A{
|
||||||
|
bson.M{"posted_at": rng},
|
||||||
|
bson.M{"last_matched_at": rng},
|
||||||
|
bson.M{"$and": bson.A{
|
||||||
|
bson.M{"$or": missingLastMatched},
|
||||||
|
bson.M{"created_at": rng},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,23 +62,35 @@ func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *do
|
||||||
}
|
}
|
||||||
|
|
||||||
func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap {
|
func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap {
|
||||||
mapPhrase := func(text, kind, basisKind string) domain.DemandMapPhrase {
|
mapPhrase := func(text, kind, basisKind, basisText string) domain.DemandMapPhrase {
|
||||||
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: product.Label, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
|
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: basisText, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
|
||||||
}
|
}
|
||||||
phrases := func(items []string, kind, basisKind string) []domain.DemandMapPhrase {
|
phrases := func(items []string, kind, basisKind string, include bool) []domain.DemandMapPhrase {
|
||||||
out := make([]domain.DemandMapPhrase, 0, len(items))
|
out := make([]domain.DemandMapPhrase, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if strings.TrimSpace(item) != "" {
|
raw := strings.TrimSpace(item)
|
||||||
out = append(out, mapPhrase(item, kind, basisKind))
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
out = append(out, mapPhrase(raw, kind, basisKind, product.Label))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
variants := domain.SearchableTermVariants(raw, true)
|
||||||
|
if len(variants) > domain.MaxSearchableVariantsPerTerm {
|
||||||
|
variants = variants[:domain.MaxSearchableVariantsPerTerm]
|
||||||
|
}
|
||||||
|
for _, term := range variants {
|
||||||
|
out = append(out, mapPhrase(term, kind, basisKind, raw))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
pain := phrases(product.PainPoints, "pain", "pain_point")
|
pain := phrases(product.PainPoints, "pain", "pain_point", true)
|
||||||
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context")
|
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context", true)
|
||||||
outcomes := phrases(product.MatchTags, "outcome", "match_tag")
|
outcomes := phrases(product.MatchTags, "outcome", "match_tag", true)
|
||||||
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability")
|
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability", true)
|
||||||
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude")
|
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude", false)
|
||||||
state := "incomplete"
|
state := "incomplete"
|
||||||
if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 {
|
if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 {
|
||||||
state = "ready"
|
state = "ready"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
|
||||||
if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil {
|
if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil {
|
||||||
return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady)
|
return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady)
|
||||||
}
|
}
|
||||||
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q;情境=%q;結果=%q;能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases,每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
|
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q;情境=%q;結果=%q;能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases,每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。include 類 text 必須是 Threads 可搜短詞:最多 2 個 token、中文每 token 2–4 字、去空格後 ≤12 字、禁止標點。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
|
||||||
raw, err := s.completeAI(ctx, ownerUID, prompt)
|
raw, err := s.completeAI(ctx, ownerUID, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -61,9 +61,30 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
|
||||||
p.Text = strings.TrimSpace(p.Text)
|
p.Text = strings.TrimSpace(p.Text)
|
||||||
p.Origin = "ai"
|
p.Origin = "ai"
|
||||||
p.Enabled = p.Enabled && p.Text != ""
|
p.Enabled = p.Enabled && p.Text != ""
|
||||||
if p.Enabled && !seen[strings.ToLower(p.Text)] {
|
if !p.Enabled {
|
||||||
out = append(out, p)
|
continue
|
||||||
seen[strings.ToLower(p.Text)] = true
|
}
|
||||||
|
texts := []string{p.Text}
|
||||||
|
if p.Kind != "exclusion" {
|
||||||
|
texts = domain.SearchableTermVariants(p.Text, true)
|
||||||
|
if len(texts) > domain.MaxSearchableVariantsPerTerm {
|
||||||
|
texts = texts[:domain.MaxSearchableVariantsPerTerm]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
basis := strings.TrimSpace(p.BasisText)
|
||||||
|
if basis == "" {
|
||||||
|
basis = p.Text
|
||||||
|
}
|
||||||
|
for _, text := range texts {
|
||||||
|
key := strings.ToLower(text)
|
||||||
|
if text == "" || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := p
|
||||||
|
cp.Text = text
|
||||||
|
cp.BasisText = basis
|
||||||
|
out = append(out, cp)
|
||||||
|
seen[key] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ func TestDemandMapBaselineAndOptimisticVersion(t *testing.T) {
|
||||||
if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 {
|
if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 {
|
||||||
t.Fatalf("unexpected baseline: %+v", first)
|
t.Fatalf("unexpected baseline: %+v", first)
|
||||||
}
|
}
|
||||||
|
for _, p := range append(append([]domain.DemandMapPhrase{}, first.PainPhrases...), append(first.ScenarioPhrases, first.SolutionSignals...)...) {
|
||||||
|
if !domain.IsThreadsSearchable(p.Text) {
|
||||||
|
t.Fatalf("baseline phrase %q is not searchable", p.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
first.PainPhrases[0].Text = "新的痛點"
|
first.PainPhrases[0].Text = "新的痛點"
|
||||||
updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1)
|
updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
|
||||||
if productContext != nil {
|
if productContext != nil {
|
||||||
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
|
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
|
||||||
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
|
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
|
||||||
w.Terms = make([]string, 0, len(plan.Groups))
|
w = mergeFetchWatch(w, plan)
|
||||||
for _, group := range plan.Groups {
|
|
||||||
w.Terms = append(w.Terms, group.Query)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +107,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
|
||||||
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
|
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
|
||||||
})
|
})
|
||||||
|
|
||||||
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
|
created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
|
||||||
ctx, ownerUID, w, profile, sw.ID, cands, nil,
|
ctx, ownerUID, w, profile, sw.ID, cands, nil,
|
||||||
)
|
)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,18 @@ func (s *Service) ProcessCandidates(
|
||||||
sweepID string,
|
sweepID string,
|
||||||
cands []*domain.CandidatePost,
|
cands []*domain.CandidatePost,
|
||||||
alreadyJudged map[string]bool,
|
alreadyJudged map[string]bool,
|
||||||
) (created, judged, truncated, failed int, credits int, err error) {
|
) (created, rematched, judged, truncated, failed int, credits int, err error) {
|
||||||
matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0
|
matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0
|
||||||
if alreadyJudged == nil {
|
if alreadyJudged == nil {
|
||||||
alreadyJudged = map[string]bool{}
|
alreadyJudged = map[string]bool{}
|
||||||
}
|
}
|
||||||
maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID)
|
maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, 0, 0, 0, err
|
return 0, 0, 0, 0, 0, 0, err
|
||||||
}
|
}
|
||||||
todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano())
|
todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, 0, 0, 0, err
|
return 0, 0, 0, 0, 0, 0, err
|
||||||
}
|
}
|
||||||
remaining := maxDaily - int(todayCount)
|
remaining := maxDaily - int(todayCount)
|
||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
|
|
@ -64,6 +64,7 @@ func (s *Service) ProcessCandidates(
|
||||||
pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing)
|
pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing)
|
||||||
credits += pcredits
|
credits += pcredits
|
||||||
judged++
|
judged++
|
||||||
|
rematched++
|
||||||
if perr == nil {
|
if perr == nil {
|
||||||
matchMerged++
|
matchMerged++
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -77,7 +78,7 @@ func (s *Service) ProcessCandidates(
|
||||||
if sweepID != "" {
|
if sweepID != "" {
|
||||||
_, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected})
|
_, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected})
|
||||||
}
|
}
|
||||||
return 0, judged, truncated, failed, credits, nil
|
return 0, rematched, judged, truncated, failed, credits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var scored []scoredCandidate
|
var scored []scoredCandidate
|
||||||
|
|
@ -112,6 +113,7 @@ func (s *Service) ProcessCandidates(
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
credits += pcredits
|
credits += pcredits
|
||||||
|
rematched++
|
||||||
if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil {
|
if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil {
|
||||||
matchMerged++
|
matchMerged++
|
||||||
}
|
}
|
||||||
|
|
@ -158,11 +160,16 @@ func (s *Service) ProcessCandidates(
|
||||||
res := sc.result
|
res := sc.result
|
||||||
// rejected always stored if reasons ok
|
// rejected always stored if reasons ok
|
||||||
if res.Status == domain.OppRejected {
|
if res.Status == domain.OppRejected {
|
||||||
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil {
|
inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
|
||||||
|
if perr != nil {
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if inserted {
|
||||||
created++
|
created++
|
||||||
|
} else {
|
||||||
|
rematched++
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
|
|
@ -170,11 +177,16 @@ func (s *Service) ProcessCandidates(
|
||||||
budgetDeferred++
|
budgetDeferred++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil {
|
inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
|
||||||
|
if perr != nil {
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if inserted {
|
||||||
created++
|
created++
|
||||||
|
} else {
|
||||||
|
rematched++
|
||||||
|
}
|
||||||
remaining--
|
remaining--
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,15 +204,15 @@ func (s *Service) ProcessCandidates(
|
||||||
BudgetDeferredCount: budgetDeferred,
|
BudgetDeferredCount: budgetDeferred,
|
||||||
}
|
}
|
||||||
if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil {
|
if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil {
|
||||||
return created, judged, truncated, failed, credits, uerr
|
return created, rematched, judged, truncated, failed, credits, uerr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return created, judged, truncated, failed, credits, nil
|
return created, rematched, judged, truncated, failed, credits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) error {
|
func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) (inserted bool, err error) {
|
||||||
if err := domain.ValidateReasons(res.Reasons); err != nil {
|
if err := domain.ValidateReasons(res.Reasons); err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
watchID := ""
|
watchID := ""
|
||||||
if watch != nil {
|
if watch != nil {
|
||||||
|
|
@ -235,6 +247,7 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
|
||||||
EvidenceQualityScore: priority.EvidenceQuality,
|
EvidenceQualityScore: priority.EvidenceQuality,
|
||||||
FreshnessScore: priority.Freshness,
|
FreshnessScore: priority.Freshness,
|
||||||
DemandEvidence: priority.Evidence,
|
DemandEvidence: priority.Evidence,
|
||||||
|
LastMatchedAt: domain.NowNano(),
|
||||||
}
|
}
|
||||||
if watch != nil && watch.ContextMode == domain.WatchContextProduct {
|
if watch != nil && watch.ContextMode == domain.WatchContextProduct {
|
||||||
if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil {
|
if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil {
|
||||||
|
|
@ -245,11 +258,15 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
|
||||||
o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)}
|
o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)}
|
||||||
}
|
}
|
||||||
if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil {
|
if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if o.IntentBand == "" {
|
if o.IntentBand == "" {
|
||||||
o.ApplyBandFromScore()
|
o.ApplyBandFromScore()
|
||||||
}
|
}
|
||||||
_, err := s.Repo.UpsertByExternalID(ctx, o)
|
existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, cand.ExternalID)
|
||||||
return err
|
already := gerr == nil && existing != nil
|
||||||
|
if _, err := s.Repo.UpsertByExternalID(ctx, o); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return !already, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,57 @@ func TestM2_QT01_AtCapNoError(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestM2_ManualTriggerRunsAgainAfterDailySuccess(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
jobs := jobUC.New(jobRepo.NewMemory())
|
||||||
|
svc := New(repository.NewMemory())
|
||||||
|
svc.SweepJobs = radarManualJobs{jobs: jobs}
|
||||||
|
now := domain.NowNano()
|
||||||
|
w := &domain.RadarWatch{
|
||||||
|
ID: "w-live", OwnerUID: 17, Terms: []string{"痛點"}, Status: domain.WatchActive,
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := svc.Repo.SaveWatch(ctx, w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
daily, err := jobs.ScheduleRadarSweep(ctx, 17, w.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := jobs.ClaimNext(ctx, "w")
|
||||||
|
if err != nil || claimed.ID != daily.ID {
|
||||||
|
t.Fatalf("claim daily: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := jobs.SucceedJob(ctx, daily.ID, "daily done"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manualID, err := svc.TriggerSweep(ctx, 17, w.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if manualID == daily.ID {
|
||||||
|
t.Fatal("立即巡邏 must not reuse the finished daily job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type radarManualJobs struct{ jobs *jobUC.Service }
|
||||||
|
|
||||||
|
func (a radarManualJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
|
||||||
|
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return j.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a radarManualJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
|
||||||
|
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return j.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestM2_ManualTriggerPausedRejected(t *testing.T) {
|
func TestM2_ManualTriggerPausedRejected(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
jobs := jobUC.New(jobRepo.NewMemory())
|
jobs := jobUC.New(jobRepo.NewMemory())
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,10 @@ func (s *Service) TriggerSweep(ctx context.Context, ownerUID int64, watchID stri
|
||||||
if w.Status != domain.WatchActive {
|
if w.Status != domain.WatchActive {
|
||||||
return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status)
|
return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status)
|
||||||
}
|
}
|
||||||
// Manual trigger is due now.
|
// Manual trigger is due now and must not reuse a finished daily slot.
|
||||||
|
if manual, ok := s.SweepJobs.(ManualSweepJobScheduler); ok {
|
||||||
|
return manual.ScheduleManualRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
|
||||||
|
}
|
||||||
return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
|
return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ func TestProductFitStaleCandidateStillPersistsCompleteMatch(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Status != domain.OppRejected || result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 {
|
if result.Status == domain.OppRejected {
|
||||||
|
t.Fatalf("a 20-day-old demand post must stay judgeable, got rejected: %+v", result)
|
||||||
|
}
|
||||||
|
if result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 {
|
||||||
t.Fatalf("stale candidate lost product evidence: %+v", result)
|
t.Fatalf("stale candidate lost product evidence: %+v", result)
|
||||||
}
|
}
|
||||||
if result.ProductMatch.ProductFitScore == 0 {
|
if result.ProductMatch.ProductFitScore == 0 {
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,39 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
|
||||||
}
|
}
|
||||||
productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel))
|
productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel))
|
||||||
include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
|
include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
|
||||||
|
out := make([]domain.DemandMapPhrase, 0, len(list))
|
||||||
|
for _, p := range list {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
variants := domain.SearchableTermVariants(p.Text, true)
|
||||||
|
if len(variants) > domain.MaxSearchableVariantsPerTerm {
|
||||||
|
variants = variants[:domain.MaxSearchableVariantsPerTerm]
|
||||||
|
}
|
||||||
|
basis := strings.TrimSpace(p.BasisText)
|
||||||
|
if basis == "" {
|
||||||
|
basis = strings.TrimSpace(p.Text)
|
||||||
|
}
|
||||||
|
for _, text := range variants {
|
||||||
|
if text == "" || strings.ToLower(text) == productLabel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := p
|
||||||
|
cp.Text = text
|
||||||
|
cp.BasisText = basis
|
||||||
|
out = append(out, cp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
excludePhrases := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
|
||||||
out := make([]domain.DemandMapPhrase, 0, len(list))
|
out := make([]domain.DemandMapPhrase, 0, len(list))
|
||||||
for _, p := range list {
|
for _, p := range list {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
text := domain.NormalizeSearchTerm(p.Text)
|
text := domain.NormalizeSearchTerm(p.Text)
|
||||||
if text == "" || strings.ToLower(text) == productLabel || !domain.IsThreadsSearchable(text) {
|
if text == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.Text = text
|
p.Text = text
|
||||||
|
|
@ -53,11 +79,14 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
|
||||||
}
|
}
|
||||||
pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases)
|
pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases)
|
||||||
outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals)
|
outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals)
|
||||||
exclusions := include(m.ExclusionSignals)
|
exclusions := excludePhrases(m.ExclusionSignals)
|
||||||
|
|
||||||
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
|
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
add := func(parts ...domain.DemandMapPhrase) {
|
add := func(parts ...domain.DemandMapPhrase) {
|
||||||
|
if len(groups) >= domain.MaxExploreTerms {
|
||||||
|
return
|
||||||
|
}
|
||||||
terms := make([]string, 0, len(parts))
|
terms := make([]string, 0, len(parts))
|
||||||
basisKinds := make([]string, 0, len(parts))
|
basisKinds := make([]string, 0, len(parts))
|
||||||
basisTexts := make([]string, 0, len(parts))
|
basisTexts := make([]string, 0, len(parts))
|
||||||
|
|
@ -82,12 +111,18 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
|
||||||
}
|
}
|
||||||
for _, pain := range pains {
|
for _, pain := range pains {
|
||||||
add(pain)
|
add(pain)
|
||||||
|
if len(groups) >= domain.MaxExploreTerms {
|
||||||
|
break
|
||||||
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
add(pain, scenario)
|
add(pain, scenario)
|
||||||
if len(groups) >= domain.MaxExploreTerms {
|
if len(groups) >= domain.MaxExploreTerms {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(groups) >= domain.MaxExploreTerms {
|
||||||
|
break
|
||||||
|
}
|
||||||
for _, outcome := range outcomes {
|
for _, outcome := range outcomes {
|
||||||
add(pain, outcome)
|
add(pain, outcome)
|
||||||
if len(groups) >= domain.MaxExploreTerms {
|
if len(groups) >= domain.MaxExploreTerms {
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,24 @@ func TestBuildQueryPlanRejectsIncomplete(t *testing.T) {
|
||||||
t.Fatal("incomplete map must not produce a plan")
|
t.Fatal("incomplete map must not produce a plan")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildQueryPlanShortensLongDemandPhrases(t *testing.T) {
|
||||||
|
m := &domain.DemandMap{
|
||||||
|
ProductID: "p1", DemandInputVersion: "demand-a", MapVersion: 1, State: "ready",
|
||||||
|
PainPhrases: []domain.DemandMapPhrase{{Text: "晚上睡覺容易口乾舌燥怎麼辦", Kind: "pain", Enabled: true}},
|
||||||
|
ScenarioPhrases: []domain.DemandMapPhrase{{Text: "換季日常修護", Kind: "scenario", Enabled: true}},
|
||||||
|
SolutionSignals: []domain.DemandMapPhrase{{Text: "保濕", Kind: "solution", Enabled: true}},
|
||||||
|
}
|
||||||
|
plan, err := BuildQueryPlan(m, "舒緩精華")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(plan.Groups) == 0 {
|
||||||
|
t.Fatal("long demand phrases must still compile into a plan")
|
||||||
|
}
|
||||||
|
for _, group := range plan.Groups {
|
||||||
|
if !domain.IsThreadsSearchable(group.Query) {
|
||||||
|
t.Fatalf("unsearchable query: %+v", group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"apps/backend/internal/module/radar/domain"
|
"apps/backend/internal/module/radar/domain"
|
||||||
usageDomain "apps/backend/internal/module/usage/domain"
|
usageDomain "apps/backend/internal/module/usage/domain"
|
||||||
|
|
@ -294,70 +293,7 @@ func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.Wa
|
||||||
// productSearchTermVariants keeps fallback terms short without inventing
|
// productSearchTermVariants keeps fallback terms short without inventing
|
||||||
// product names. Exact short fields win; longer CJK fields yield small windows.
|
// product names. Exact short fields win; longer CJK fields yield small windows.
|
||||||
func productSearchTermVariants(raw string, include bool) []string {
|
func productSearchTermVariants(raw string, include bool) []string {
|
||||||
raw = domain.NormalizeSearchTerm(raw)
|
return domain.SearchableTermVariants(raw, include)
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parts := splitProductSearchParts(raw)
|
|
||||||
if len(parts) == 0 {
|
|
||||||
parts = []string{raw}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
out := make([]string, 0, 8)
|
|
||||||
add := func(term string) {
|
|
||||||
term = domain.NormalizeSearchTerm(term)
|
|
||||||
if term == "" || seen[term] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if include {
|
|
||||||
if !domain.IsThreadsSearchable(term) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if n := len([]rune(term)); n < domain.MinTermLen || n > domain.MaxTermLen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[term] = true
|
|
||||||
out = append(out, term)
|
|
||||||
}
|
|
||||||
if domain.IsThreadsSearchable(raw) {
|
|
||||||
add(raw)
|
|
||||||
}
|
|
||||||
for _, part := range parts {
|
|
||||||
if domain.IsThreadsSearchable(part) {
|
|
||||||
add(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
runes := []rune(part)
|
|
||||||
for width := 4; width >= 2; width-- {
|
|
||||||
if len(runes) < width {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for start := 0; start+width <= len(runes) && len(out) < 8; start++ {
|
|
||||||
add(string(runes[start : start+width]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitProductSearchParts(raw string) []string {
|
|
||||||
var b strings.Builder
|
|
||||||
parts := make([]string, 0, 4)
|
|
||||||
flush := func() {
|
|
||||||
if value := strings.TrimSpace(b.String()); value != "" {
|
|
||||||
parts = append(parts, value)
|
|
||||||
}
|
|
||||||
b.Reset()
|
|
||||||
}
|
|
||||||
for _, r := range raw {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
|
|
||||||
b.WriteRune(r)
|
|
||||||
} else {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
return parts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ func TestSuggestRespectsLimit(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSuggestDropsUnusableItems(t *testing.T) {
|
func TestSuggestDropsUnusableItems(t *testing.T) {
|
||||||
// 沒理由、太短、重複、超過 Threads 短詞規則的 include 都要丟掉。
|
// 沒理由、太短、重複丟掉;過長 include 收成可搜短詞,不再整條丟。
|
||||||
svc, _, ctx := suggestService(t, `[
|
svc, _, ctx := suggestService(t, `[
|
||||||
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
|
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
|
||||||
{"term":"沒有理由的詞","reason":" ","usage":"include"},
|
{"term":"沒有理由的詞","reason":" ","usage":"include"},
|
||||||
|
|
@ -173,8 +173,13 @@ func TestSuggestDropsUnusableItems(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("suggest: %v", err)
|
t.Fatalf("suggest: %v", err)
|
||||||
}
|
}
|
||||||
if len(list) != 1 || list[0].Term != "婚攝 求推薦" {
|
if len(list) != 2 || list[0].Term != "婚攝 求推薦" || list[1].Term != "台北 婚攝" {
|
||||||
t.Fatalf("got %+v, want only the one usable suggestion", list)
|
t.Fatalf("got %+v, want shortened multi-token plus the original short term", list)
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if item.Usage == domain.SuggestUsageInclude && !domain.IsThreadsSearchable(item.Term) {
|
||||||
|
t.Fatalf("include not searchable: %+v", item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
|
||||||
if w == nil {
|
if w == nil {
|
||||||
return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation)
|
return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
terms := w.Terms
|
terms := domain.ExpandSearchTerms(w.Terms, domain.MaxWatchTerms)
|
||||||
if len(terms) == 0 {
|
if len(terms) == 0 {
|
||||||
return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation)
|
return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
@ -104,11 +104,15 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
|
||||||
|
|
||||||
out := make([]*domain.CandidatePost, 0, len(hits))
|
out := make([]*domain.CandidatePost, 0, len(hits))
|
||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
|
permalink := strings.TrimSpace(h.URL)
|
||||||
text := strings.TrimSpace(h.Snippet)
|
text := strings.TrimSpace(h.Snippet)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = strings.TrimSpace(h.Title)
|
text = strings.TrimSpace(h.Title)
|
||||||
}
|
}
|
||||||
if text == "" {
|
if text == "" && permalink != "" {
|
||||||
|
text = permalink
|
||||||
|
}
|
||||||
|
if text == "" || permalink == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
blob := strings.ToLower(text + " " + h.Title)
|
blob := strings.ToLower(text + " " + h.Title)
|
||||||
|
|
@ -122,10 +126,6 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
|
||||||
if skip {
|
if skip {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
permalink := strings.TrimSpace(h.URL)
|
|
||||||
if permalink == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
term := matchingWatchTerm(blob, terms)
|
term := matchingWatchTerm(blob, terms)
|
||||||
class := classifyCandidate(blob)
|
class := classifyCandidate(blob)
|
||||||
out = append(out, &domain.CandidatePost{
|
out = append(out, &domain.CandidatePost{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/radar/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeFetchWatchKeepsSubscriberTerms(t *testing.T) {
|
||||||
|
w := &domain.RadarWatch{Terms: []string{"求推薦", "泛紅"}, ExcludeTerms: []string{"抽獎"}}
|
||||||
|
plan := &domain.QueryPlan{Groups: []domain.QueryPlanGroup{
|
||||||
|
{Query: "換季 不適", Exclude: []string{"業配"}},
|
||||||
|
{Query: "求推薦"},
|
||||||
|
}}
|
||||||
|
got := mergeFetchWatch(w, plan)
|
||||||
|
if len(got.Terms) != 3 || got.Terms[0] != "求推薦" || got.Terms[1] != "泛紅" || got.Terms[2] != "換季 不適" {
|
||||||
|
t.Fatalf("terms=%v want subscriber keywords first, then new plan queries", got.Terms)
|
||||||
|
}
|
||||||
|
if len(got.ExcludeTerms) != 2 {
|
||||||
|
t.Fatalf("exclude=%v", got.ExcludeTerms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeFetchWatchExpandsLongSubscriberTerms(t *testing.T) {
|
||||||
|
w := &domain.RadarWatch{Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"}}
|
||||||
|
got := mergeFetchWatch(w, nil)
|
||||||
|
if len(got.Terms) == 0 {
|
||||||
|
t.Fatal("long subscriber term must expand before fetch")
|
||||||
|
}
|
||||||
|
for _, term := range got.Terms {
|
||||||
|
if !domain.IsThreadsSearchable(term) {
|
||||||
|
t.Fatalf("fetch term %q is not searchable", term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanFetchErrorCrawlerSessionExpired(t *testing.T) {
|
||||||
|
got := humanFetchError(fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`))
|
||||||
|
if !strings.Contains(got, "Chrome 登入已過期") || !strings.Contains(got, "設定") {
|
||||||
|
t.Fatalf("human message=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCandidatesKeepsTitleOnlyHits(t *testing.T) {
|
||||||
|
svc := New(nil)
|
||||||
|
svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) {
|
||||||
|
return []ThreadHit{{
|
||||||
|
URL: "https://www.threads.net/@a/post/xyz",
|
||||||
|
Title: "皮膚泛紅怎麼辦",
|
||||||
|
}}, "api", nil
|
||||||
|
})
|
||||||
|
// bill path: HitFetch returns api so FetchCandidates will try to bill.
|
||||||
|
// Avoid billing by setting path after... FetchCandidates bills API path.
|
||||||
|
// Use empty Usage so bill is a no-op if implemented that way.
|
||||||
|
cands, _, _, err := svc.FetchCandidates(context.Background(), 1, &domain.RadarWatch{Terms: []string{"泛紅"}}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch: %v", err)
|
||||||
|
}
|
||||||
|
if len(cands) != 1 || cands[0].Text == "" {
|
||||||
|
t.Fatalf("title-only hit dropped: %+v err=%v", cands, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
type SweepRunResult struct {
|
type SweepRunResult struct {
|
||||||
Sweep *domain.RadarSweep
|
Sweep *domain.RadarSweep
|
||||||
Created int
|
Created int
|
||||||
|
Rematched int
|
||||||
Judged int
|
Judged int
|
||||||
Truncated int
|
Truncated int
|
||||||
FailedJudges int
|
FailedJudges int
|
||||||
|
|
@ -85,17 +86,10 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
|
||||||
already[id] = true
|
already[id] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchWatch := w
|
fetchWatch := mergeFetchWatch(w, nil)
|
||||||
if w.ContextMode == domain.WatchContextProduct {
|
if w.ContextMode == domain.WatchContextProduct {
|
||||||
if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 {
|
if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 {
|
||||||
planned := *w
|
fetchWatch = mergeFetchWatch(w, plan)
|
||||||
planned.Terms = make([]string, 0, len(plan.Groups))
|
|
||||||
planned.ExcludeTerms = append([]string(nil), w.ExcludeTerms...)
|
|
||||||
for _, group := range plan.Groups {
|
|
||||||
planned.Terms = append(planned.Terms, group.Query)
|
|
||||||
planned.ExcludeTerms = append(planned.ExcludeTerms, group.Exclude...)
|
|
||||||
}
|
|
||||||
fetchWatch = &planned
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40)
|
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40)
|
||||||
|
|
@ -135,7 +129,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
|
created, rematched, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
|
||||||
ctx, ownerUID, w, profile, sw.ID, cands, already,
|
ctx, ownerUID, w, profile, sw.ID, cands, already,
|
||||||
)
|
)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
|
|
@ -162,6 +156,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
|
||||||
return &SweepRunResult{
|
return &SweepRunResult{
|
||||||
Sweep: sw,
|
Sweep: sw,
|
||||||
Created: created,
|
Created: created,
|
||||||
|
Rematched: rematched,
|
||||||
Judged: judged,
|
Judged: judged,
|
||||||
Truncated: truncated,
|
Truncated: truncated,
|
||||||
FailedJudges: failed,
|
FailedJudges: failed,
|
||||||
|
|
@ -199,15 +194,50 @@ func (s *Service) notifySweepFailed(ctx context.Context, ownerUID int64, sweepID
|
||||||
return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason)
|
return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeFetchWatch keeps the subscriber's own keywords and adds demand-map
|
||||||
|
// queries. Replacing the watch terms with only plan groups made patrols
|
||||||
|
// miss the phrases the user actually typed.
|
||||||
|
func mergeFetchWatch(w *domain.RadarWatch, plan *domain.QueryPlan) *domain.RadarWatch {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
planned := *w
|
||||||
|
terms := append([]string{}, w.Terms...)
|
||||||
|
excludes := append([]string{}, w.ExcludeTerms...)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range terms {
|
||||||
|
seen[strings.ToLower(strings.TrimSpace(t))] = true
|
||||||
|
}
|
||||||
|
if plan != nil {
|
||||||
|
for _, group := range plan.Groups {
|
||||||
|
q := strings.TrimSpace(group.Query)
|
||||||
|
if q != "" && !seen[strings.ToLower(q)] {
|
||||||
|
terms = append(terms, q)
|
||||||
|
seen[strings.ToLower(q)] = true
|
||||||
|
}
|
||||||
|
excludes = append(excludes, group.Exclude...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planned.Terms = domain.ExpandSearchTerms(terms, domain.MaxWatchTerms)
|
||||||
|
planned.ExcludeTerms = excludes
|
||||||
|
return &planned
|
||||||
|
}
|
||||||
|
|
||||||
func humanFetchError(err error) string {
|
func humanFetchError(err error) string {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return "抓取失敗"
|
return "抓取失敗"
|
||||||
}
|
}
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
|
low := strings.ToLower(msg)
|
||||||
// never include token-like blobs
|
// never include token-like blobs
|
||||||
if strings.Contains(strings.ToLower(msg), "bearer ") {
|
if strings.Contains(low, "bearer ") {
|
||||||
return "抓取路徑不可用"
|
return "抓取路徑不可用"
|
||||||
}
|
}
|
||||||
|
if strings.Contains(low, "crawler session expired") ||
|
||||||
|
strings.Contains(low, "crawler session is invalid") ||
|
||||||
|
strings.Contains(low, "crawler session required") {
|
||||||
|
return "今天沒巡到:Chrome 登入已過期。請到設定重新同步已登入的 Threads 分頁,或先關掉開發模式改走 API 搜尋。"
|
||||||
|
}
|
||||||
if len(msg) > 200 {
|
if len(msg) > 200 {
|
||||||
msg = msg[:200]
|
msg = msg[:200]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ type SweepJobScheduler interface {
|
||||||
ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
|
ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManualSweepJobScheduler is optional. TriggerSweep uses it so 「立即巡邏」
|
||||||
|
// is not swallowed by a finished same-day daily job.
|
||||||
|
type ManualSweepJobScheduler interface {
|
||||||
|
ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// SweepJobSchedulerFunc adapts a function to SweepJobScheduler.
|
// SweepJobSchedulerFunc adapts a function to SweepJobScheduler.
|
||||||
type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
|
type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,25 @@ func TestCreateWatchRejectsBadInput(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateWatchExpandsUnsearchableTerms(t *testing.T) {
|
||||||
|
svc, ctx := serviceWithProfile(t, 5)
|
||||||
|
w, err := svc.CreateWatch(ctx, 42, WatchInput{
|
||||||
|
Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"},
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if len(w.Terms) == 0 {
|
||||||
|
t.Fatal("long sentence must expand into searchable terms")
|
||||||
|
}
|
||||||
|
for _, term := range w.Terms {
|
||||||
|
if !domain.IsThreadsSearchable(term) {
|
||||||
|
t.Fatalf("persisted term %q is not searchable", term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWatchPauseResumeRoundTrip(t *testing.T) {
|
func TestWatchPauseResumeRoundTrip(t *testing.T) {
|
||||||
svc, ctx := serviceWithProfile(t, 5)
|
svc, ctx := serviceWithProfile(t, 5)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ var (
|
||||||
ErrForbidden = errors.New("scout forbidden")
|
ErrForbidden = errors.New("scout forbidden")
|
||||||
ErrValidation = errors.New("scout validation")
|
ErrValidation = errors.New("scout validation")
|
||||||
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
|
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
|
||||||
|
ErrCrawlerSessionExpired = errors.New("crawler session expired")
|
||||||
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
|
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
|
||||||
ErrHasProducts = errors.New("brand has products; remove products first")
|
ErrHasProducts = errors.New("brand has products; remove products first")
|
||||||
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
|
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/scout/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChromeCrawlerProvider is the private worker-to-browser boundary. The
|
// ChromeCrawlerProvider is the private worker-to-browser boundary. The
|
||||||
|
|
@ -43,7 +46,12 @@ func (p *HTTPCrawlerProvider) ResolveMediaID(ctx context.Context, storageState,
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
return "", fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, truncate(string(raw), 160))
|
body := truncate(string(raw), 160)
|
||||||
|
err := fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, body)
|
||||||
|
if isCrawlerSessionDead(err) {
|
||||||
|
return "", fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
MediaID string `json:"media_id"`
|
MediaID string `json:"media_id"`
|
||||||
|
|
@ -104,7 +112,12 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
return nil, fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, truncate(string(raw), 160))
|
body := truncate(string(raw), 160)
|
||||||
|
err := fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, body)
|
||||||
|
if isCrawlerSessionDead(err) {
|
||||||
|
return nil, fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Posts []struct {
|
Posts []struct {
|
||||||
|
|
@ -165,3 +178,16 @@ func isSoftAged(publishedAtNano int64, softDays int) bool {
|
||||||
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
|
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
|
||||||
return publishedAtNano < cutoff
|
return publishedAtNano < cutoff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCrawlerSessionDead(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, domain.ErrNoCrawlerSession) || errors.Is(err, domain.ErrCrawlerSessionExpired) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "crawler session expired") ||
|
||||||
|
strings.Contains(msg, "crawler session is invalid") ||
|
||||||
|
strings.Contains(msg, "crawler session required")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"apps/backend/internal/module/scout/domain"
|
"apps/backend/internal/module/scout/domain"
|
||||||
|
"apps/backend/internal/module/scout/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordingProvider struct {
|
type recordingProvider struct {
|
||||||
|
|
@ -39,8 +41,8 @@ func TestSearchHitsOnlyFansOutPerTermAndDedupes(t *testing.T) {
|
||||||
if path != domain.PathAPI {
|
if path != domain.PathAPI {
|
||||||
t.Fatalf("path=%q want %q", path, domain.PathAPI)
|
t.Fatalf("path=%q want %q", path, domain.PathAPI)
|
||||||
}
|
}
|
||||||
if len(prov.calls) != 2 {
|
if len(prov.calls) < 2 {
|
||||||
t.Fatalf("provider calls=%d want 2 (fan-out), calls=%v", len(prov.calls), prov.calls)
|
t.Fatalf("provider calls=%d want >=2 (fan-out, plus sparse top-up)", len(prov.calls))
|
||||||
}
|
}
|
||||||
for _, c := range prov.calls {
|
for _, c := range prov.calls {
|
||||||
if len(c) != 1 {
|
if len(c) != 1 {
|
||||||
|
|
@ -129,6 +131,87 @@ func TestMergeHitsDedupe(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type alwaysDevMode struct{}
|
||||||
|
|
||||||
|
func (alwaysDevMode) DevModeEnabled(context.Context, int64) (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
type expiredCrawler struct{ calls int }
|
||||||
|
|
||||||
|
func (c *expiredCrawler) SearchChrome(context.Context, string, []string, int) ([]ThreadSearchResult, error) {
|
||||||
|
c.calls++
|
||||||
|
return nil, fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *expiredCrawler) ResolveMediaID(context.Context, string, string) (string, error) {
|
||||||
|
return "", fmt.Errorf("crawler session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionExpired(t *testing.T) {
|
||||||
|
crawler := &expiredCrawler{}
|
||||||
|
prov := &recordingProvider{}
|
||||||
|
svc := &Service{
|
||||||
|
Provider: prov,
|
||||||
|
Crawler: crawler,
|
||||||
|
Settings: alwaysDevMode{},
|
||||||
|
Repo: repository.NewMemory(),
|
||||||
|
SessionSecret: "test-crawler-session-secret",
|
||||||
|
}
|
||||||
|
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
|
||||||
|
t.Fatalf("seed session: %v", err)
|
||||||
|
}
|
||||||
|
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expired crawler must fall back to api: %v", err)
|
||||||
|
}
|
||||||
|
if path != domain.PathAPI {
|
||||||
|
t.Fatalf("path=%q want api after crawler session expired", path)
|
||||||
|
}
|
||||||
|
if len(hits) == 0 {
|
||||||
|
t.Fatal("api fallback returned no hits")
|
||||||
|
}
|
||||||
|
if crawler.calls != 1 {
|
||||||
|
t.Fatalf("dead session should stop crawler fan-out, calls=%d", crawler.calls)
|
||||||
|
}
|
||||||
|
if len(prov.calls) == 0 {
|
||||||
|
t.Fatal("api provider was not used")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionMissing(t *testing.T) {
|
||||||
|
prov := &recordingProvider{}
|
||||||
|
svc := &Service{Provider: prov, Settings: alwaysDevMode{}}
|
||||||
|
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing session must fall back to api: %v", err)
|
||||||
|
}
|
||||||
|
if path != domain.PathAPI || len(hits) == 0 {
|
||||||
|
t.Fatalf("path=%q hits=%d", path, len(hits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchHitsOnlyErrorsWhenCrawlerExpiredAndAPIUnavailable(t *testing.T) {
|
||||||
|
crawler := &expiredCrawler{}
|
||||||
|
svc := &Service{
|
||||||
|
Crawler: crawler,
|
||||||
|
Settings: alwaysDevMode{},
|
||||||
|
Repo: repository.NewMemory(),
|
||||||
|
SessionSecret: "test-crawler-session-secret",
|
||||||
|
}
|
||||||
|
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
|
||||||
|
t.Fatalf("seed session: %v", err)
|
||||||
|
}
|
||||||
|
_, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("both paths down must error")
|
||||||
|
}
|
||||||
|
if path != domain.PathCrawler {
|
||||||
|
t.Fatalf("path=%q want crawler", path)
|
||||||
|
}
|
||||||
|
if !isCrawlerSessionDead(err) {
|
||||||
|
t.Fatalf("err=%v want session-dead", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) {
|
func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) {
|
||||||
aliases := []string{
|
aliases := []string{
|
||||||
"https://www.threads.net/@alice/post/AbC123?xmt=AQG",
|
"https://www.threads.net/@alice/post/AbC123?xmt=AQG",
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,10 @@ func (r *searchPipelineRunner) runStageForTerms(perQuery int, source func(contex
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.lastErr = err
|
r.lastErr = err
|
||||||
r.diagnostics.SourceUnavailable = true
|
r.diagnostics.SourceUnavailable = true
|
||||||
|
if isCrawlerSessionDead(err) {
|
||||||
|
r.source = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
r.sourceOK = true
|
r.sourceOK = true
|
||||||
|
|
|
||||||
|
|
@ -515,31 +515,49 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if devMode {
|
if devMode {
|
||||||
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
|
var storageState string
|
||||||
if serr != nil {
|
var serr error
|
||||||
|
if s.Repo != nil {
|
||||||
|
storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
|
||||||
|
} else {
|
||||||
|
serr = domain.ErrNoCrawlerSession
|
||||||
|
}
|
||||||
|
if serr != nil || strings.TrimSpace(storageState) == "" {
|
||||||
|
if s.Provider == nil {
|
||||||
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
|
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
|
||||||
}
|
}
|
||||||
path = domain.PathCrawler
|
logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID)
|
||||||
if s.Crawler == nil {
|
} else if s.Crawler == nil {
|
||||||
return nil, path, fmt.Errorf("Chrome crawler is not configured")
|
if s.Provider == nil {
|
||||||
|
return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured")
|
||||||
}
|
}
|
||||||
|
logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID)
|
||||||
|
} else {
|
||||||
|
path = domain.PathCrawler
|
||||||
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
|
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
|
||||||
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
|
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState)
|
||||||
|
return capHits(hits, limit), path, nil
|
||||||
|
}
|
||||||
|
if s.Provider == nil {
|
||||||
return nil, path, err
|
return nil, path, err
|
||||||
}
|
}
|
||||||
return capHits(hits, limit), path, nil
|
logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if s.Provider == nil {
|
if s.Provider == nil {
|
||||||
return nil, path, fmt.Errorf("scout search provider is not configured")
|
return nil, path, fmt.Errorf("scout search provider is not configured")
|
||||||
}
|
}
|
||||||
|
path = domain.PathAPI
|
||||||
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
|
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
|
||||||
return s.Provider.SearchThreads(ctx, []string{q}, n)
|
return s.Provider.SearchThreads(ctx, []string{q}, n)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, path, err
|
return nil, path, err
|
||||||
}
|
}
|
||||||
|
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
|
||||||
return capHits(hits, limit), path, nil
|
return capHits(hits, limit), path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -761,6 +779,9 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
if isCrawlerSessionDead(err) {
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, hit := range hits {
|
for _, hit := range hits {
|
||||||
|
|
|
||||||
|
|
@ -247,14 +247,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
// 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
|
// 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
|
||||||
radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
|
radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
|
||||||
radarSvc.MediaResolver = scoutSvc
|
radarSvc.MediaResolver = scoutSvc
|
||||||
// 每日巡與手動觸發共用 job.ScheduleRadarSweep(同 template、同日去重)。
|
// 每日巡走同日去重;立即巡邏另開 manual job,避免當天已成功的日巡把按鈕吞掉。
|
||||||
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
|
radarSvc.SweepJobs = radarSweepJobs{jobs: jobs}
|
||||||
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return j.ID, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
zipPath := findExtensionZip()
|
zipPath := findExtensionZip()
|
||||||
|
|
||||||
|
|
@ -552,6 +546,32 @@ func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, use
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type radarSweepJobs struct {
|
||||||
|
jobs *jobUC.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a radarSweepJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
|
||||||
|
if a.jobs == nil {
|
||||||
|
return "", fmt.Errorf("jobs not configured")
|
||||||
|
}
|
||||||
|
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return j.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a radarSweepJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
|
||||||
|
if a.jobs == nil {
|
||||||
|
return "", fmt.Errorf("jobs not configured")
|
||||||
|
}
|
||||||
|
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return j.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only).
|
// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only).
|
||||||
type personaJobBridge struct {
|
type personaJobBridge struct {
|
||||||
Jobs *jobUC.Service
|
Jobs *jobUC.Service
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,9 @@ export default function App() {
|
||||||
<Route path="outbox" element={<OutboxPage />} />
|
<Route path="outbox" element={<OutboxPage />} />
|
||||||
<Route path="outbox/:id" element={<OutboxDetailPage />} />
|
<Route path="outbox/:id" element={<OutboxDetailPage />} />
|
||||||
<Route path="scout" element={<ScoutPage />} />
|
<Route path="scout" element={<ScoutPage />} />
|
||||||
<Route path="radar" element={<Navigate to="/app/radar/today" replace />} />
|
<Route path="radar" element={<RadarOpportunitiesPage />} />
|
||||||
<Route path="radar/watches" element={<RadarWatchesPage />} />
|
<Route path="radar/watches" element={<RadarWatchesPage />} />
|
||||||
<Route path="radar/today" element={<Navigate to="/app/radar/opportunities?review_state=pending&time_scope=today" replace />} />
|
<Route path="radar/today" element={<RadarOpportunitiesPage />} />
|
||||||
<Route path="radar/opportunities" element={<RadarOpportunitiesPage />} />
|
<Route path="radar/opportunities" element={<RadarOpportunitiesPage />} />
|
||||||
<Route path="crm" element={<CrmBoardPage />} />
|
<Route path="crm" element={<CrmBoardPage />} />
|
||||||
<Route path="crm/followups" element={<CrmFollowUpsPage />} />
|
<Route path="crm/followups" element={<CrmFollowUpsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,15 @@ export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComp
|
||||||
</section>
|
</section>
|
||||||
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3>原始判定</h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
|
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3>原始判定</h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
|
||||||
<div className="hb-opp-drawer__actions">
|
<div className="hb-opp-drawer__actions">
|
||||||
{pending && !accepted && onAccept ? (
|
|
||||||
<Button type="button" disabled={busy} onClick={() => onAccept(opportunity)}>加入名單並追蹤</Button>
|
|
||||||
) : null}
|
|
||||||
{pending && onComplete ? (
|
{pending && onComplete ? (
|
||||||
<Button type="button" variant="secondary" disabled={busy} onClick={() => onComplete(opportunity)}>只標示已處理</Button>
|
<Button type="button" disabled={busy} onClick={() => onComplete(opportunity)}>留下</Button>
|
||||||
|
) : null}
|
||||||
|
{pending && !accepted && onAccept ? (
|
||||||
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>加入名單(可選)</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">開啟 Threads 原文</a>
|
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">開啟 Threads 原文</a>
|
||||||
</div>
|
</div>
|
||||||
{pending ? <p className="hb-field__hint">加入名單會保留這位使用者供後續追蹤;只標示已處理不會建立名單。兩者都不扣點。</p> : null}
|
{pending ? <p className="hb-field__hint">先看痛點與產品理由。留下或丟掉即可;加入名單只在你要追這個人時才需要。</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,14 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
||||||
<a href={opportunity.permalink} target="_blank" rel="noreferrer">查看 Threads 原文</a>
|
<a href={opportunity.permalink} target="_blank" rel="noreferrer">查看 Threads 原文</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="hb-opp-card__actions" aria-label="商機操作">
|
<div className="hb-opp-card__actions" aria-label="商機操作">
|
||||||
|
{pending ? <Button type="button" variant="primary" disabled={busy} onClick={() => onComplete(opportunity)}>留下</Button> : null}
|
||||||
|
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}>丟掉</Button> : null}
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}>為什麼推薦</Button>
|
||||||
{(pending || reviewState === "completed") && !accepted ? (
|
{(pending || reviewState === "completed") && !accepted ? (
|
||||||
<Button type="button" variant="primary" disabled={busy} onClick={() => onAccept(opportunity)}>
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>
|
||||||
{busy ? "處理中…" : "加入名單並追蹤"}
|
{busy ? "處理中…" : "加入名單(可選)"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{pending ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onComplete(opportunity)}>只標示已處理</Button> : null}
|
|
||||||
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}>不適合</Button> : null}
|
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}>看完整判定</Button>
|
|
||||||
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}>還原到待處理</Button> : null}
|
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}>還原到待處理</Button> : null}
|
||||||
{reviewState === "completed" && accepted && opportunity.contact_id ? (
|
{reviewState === "completed" && accepted && opportunity.contact_id ? (
|
||||||
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}>前往名單</Link>
|
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}>前往名單</Link>
|
||||||
|
|
@ -83,15 +83,15 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
||||||
</div>
|
</div>
|
||||||
{removeOpen ? (
|
{removeOpen ? (
|
||||||
<div className="hb-opp-card__remove-form" aria-label="標示為不適合">
|
<div className="hb-opp-card__remove-form" aria-label="標示為不適合">
|
||||||
<strong>為什麼不適合?</strong>
|
<strong>為什麼丟掉?</strong>
|
||||||
<p className="hb-field__hint">選原因後,這筆會移到「已移除」且不會再次出現在待處理。這個動作不扣點。</p>
|
<p className="hb-field__hint">選原因後會移出「新找到」,之後巡邏不會再把同一篇推上來。這個動作不扣點。</p>
|
||||||
<Select name={`remove-reason-${opportunity.id}`} label="原因" value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
|
<Select name={`remove-reason-${opportunity.id}`} label="原因" value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
|
||||||
{(Object.keys(reasonLabels) as Array<Exclude<OpportunityRemovalReason, "legacy_unknown">>).map((key) => <option key={key} value={key}>{reasonLabels[key]}</option>)}
|
{(Object.keys(reasonLabels) as Array<Exclude<OpportunityRemovalReason, "legacy_unknown">>).map((key) => <option key={key} value={key}>{reasonLabels[key]}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label="補充說明" value={note} onChange={(event) => setNote(event.target.value)} /> : null}
|
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label="補充說明" value={note} onChange={(event) => setNote(event.target.value)} /> : null}
|
||||||
{reason === "duplicate" ? <p className="hb-field__hint">詳情中可指定要保留的原始商機。</p> : null}
|
{reason === "duplicate" ? <p className="hb-field__hint">詳情中可指定要保留的原始商機。</p> : null}
|
||||||
<div className="hb-opp-card__actions">
|
<div className="hb-opp-card__actions">
|
||||||
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}>確認不適合</Button>
|
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}>確認丟掉</Button>
|
||||||
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}>取消</Button>
|
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}>取消</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildQueryPlan } from "./QueryPlanPreview";
|
import { buildQueryPlan } from "./QueryPlanPreview";
|
||||||
import type { DemandMap } from "../../domain/types";
|
import type { DemandMap } from "../../domain/types";
|
||||||
|
import { isThreadsSearchable } from "../../lib/threadsTerm";
|
||||||
|
|
||||||
const map: DemandMap = {
|
const map: DemandMap = {
|
||||||
product_id: "p1", demand_input_version: "v1", map_version: 2, state: "ready",
|
product_id: "p1", demand_input_version: "v1", map_version: 2, state: "ready",
|
||||||
|
|
@ -13,16 +14,32 @@ const map: DemandMap = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("QueryPlanPreview", () => {
|
describe("QueryPlanPreview", () => {
|
||||||
it("generates at most six groups with pain/context/outcome and exclusions", () => {
|
it("generates at most six searchable groups and keeps exclusions", () => {
|
||||||
const groups = buildQueryPlan(map);
|
const groups = buildQueryPlan(map);
|
||||||
expect(groups).toHaveLength(2);
|
expect(groups.length).toBeGreaterThan(0);
|
||||||
expect(groups[0].terms).toEqual(["漏水", "天花板滴水", "找人處理"]);
|
expect(groups.length).toBeLessThanOrEqual(6);
|
||||||
expect(groups[0].exclusion).toEqual(["徵才"]);
|
expect(groups[0].exclusion).toEqual(["徵才"]);
|
||||||
expect(groups.every((group) => group.terms.length > 0 && group.terms.every((term) => term !== "安心抓漏"))).toBe(true);
|
expect(groups.every((group) => {
|
||||||
|
const query = group.terms.join(" ");
|
||||||
|
return group.terms.length > 0 && group.terms.length <= 2 && isThreadsSearchable(query)
|
||||||
|
&& group.terms.every((term) => term !== "安心抓漏" && isThreadsSearchable(term));
|
||||||
|
})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to scenario/outcome when the pain list is empty", () => {
|
it("falls back to scenario/outcome when the pain list is empty", () => {
|
||||||
const groups = buildQueryPlan({ ...map, pain_phrases: [] });
|
const groups = buildQueryPlan({ ...map, pain_phrases: [] });
|
||||||
expect(groups.map((group) => group.terms[0])).toEqual(["天花板滴水", "找人處理"]);
|
expect(groups.length).toBeGreaterThan(0);
|
||||||
|
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortens long demand sentences into searchable queries", () => {
|
||||||
|
const groups = buildQueryPlan({
|
||||||
|
...map,
|
||||||
|
pain_phrases: [{ text: "晚上睡覺容易口乾舌燥怎麼辦", kind: "pain", origin: "product", enabled: true }],
|
||||||
|
scenario_phrases: [{ text: "換季日常修護", kind: "scenario", origin: "product", enabled: true }],
|
||||||
|
});
|
||||||
|
expect(groups.length).toBeGreaterThan(0);
|
||||||
|
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
|
||||||
|
expect(groups.some((group) => group.terms.includes("口乾舌燥") || group.terms.includes("晚上睡覺"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,109 @@
|
||||||
import type { DemandMap } from "../../domain/types";
|
import type { DemandMap, DemandMapPhrase } from "../../domain/types";
|
||||||
import { Badge } from "../ui";
|
import { isThreadsSearchable, normalizeSearchTerm, searchableTermVariants } from "../../lib/threadsTerm";
|
||||||
|
import { Badge, Button } from "../ui";
|
||||||
|
|
||||||
export type QueryPlanGroup = { terms: string[]; basis: string; exclusion: string[] };
|
export type QueryPlanGroup = { terms: string[]; basis: string; exclusion: string[] };
|
||||||
|
|
||||||
/** Deterministic preview used by mock UX; the live planner will keep this shape. */
|
const MAX_GROUPS = 6;
|
||||||
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
|
|
||||||
const pain = map.pain_phrases.filter((item) => item.enabled);
|
function expandPhrases(list: DemandMapPhrase[] = []): DemandMapPhrase[] {
|
||||||
const scenario = map.scenario_phrases.filter((item) => item.enabled);
|
const out: DemandMapPhrase[] = [];
|
||||||
const outcomes = map.desired_outcomes.filter((item) => item.enabled);
|
for (const item of list.filter((phrase) => phrase.enabled)) {
|
||||||
const exclusion = map.exclusion_signals.filter((item) => item.enabled).map((item) => item.text);
|
const variants = searchableTermVariants(item.text).slice(0, 3);
|
||||||
const groups = pain.slice(0, 6).map((item, index) => ({
|
const basis = item.basis_text || item.text;
|
||||||
terms: [item.text, scenario[index % Math.max(1, scenario.length)]?.text, outcomes[index % Math.max(1, outcomes.length)]?.text].filter(Boolean) as string[],
|
for (const text of variants) {
|
||||||
basis: item.basis_text || "產品痛點",
|
out.push({ ...item, text, basis_text: basis });
|
||||||
exclusion,
|
}
|
||||||
}));
|
}
|
||||||
if (groups.length) return groups;
|
return out;
|
||||||
const fallback = [...scenario, ...outcomes].slice(0, 6);
|
|
||||||
return fallback.map((item) => ({ terms: [item.text], basis: item.basis_text || "產品需求地圖", exclusion }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryPlanPreview({ map }: { map: DemandMap }) {
|
/** Deterministic preview aligned with backend BuildQueryPlan: max 2 searchable tokens. */
|
||||||
|
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
|
||||||
|
const pain = expandPhrases(map.pain_phrases);
|
||||||
|
const scenario = expandPhrases(map.scenario_phrases);
|
||||||
|
const outcomes = expandPhrases(map.desired_outcomes);
|
||||||
|
const solutions = expandPhrases(map.solution_signals);
|
||||||
|
const exclusion = (map.exclusion_signals ?? []).filter((item) => item.enabled).map((item) => item.text);
|
||||||
|
const groups: QueryPlanGroup[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const add = (...parts: DemandMapPhrase[]) => {
|
||||||
|
if (groups.length >= MAX_GROUPS) return;
|
||||||
|
const terms = parts.map((part) => part.text).filter(Boolean);
|
||||||
|
const query = normalizeSearchTerm(terms.join(" "));
|
||||||
|
if (!query || !isThreadsSearchable(query) || seen.has(query.toLowerCase())) return;
|
||||||
|
seen.add(query.toLowerCase());
|
||||||
|
groups.push({ terms, basis: parts[0]?.basis_text || "產品痛點", exclusion });
|
||||||
|
};
|
||||||
|
for (const item of pain) {
|
||||||
|
add(item);
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
for (const next of scenario) {
|
||||||
|
add(item, next);
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
}
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
for (const next of outcomes) {
|
||||||
|
add(item, next);
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
}
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
}
|
||||||
|
if (!groups.length) {
|
||||||
|
for (const item of scenario) {
|
||||||
|
for (const next of solutions) {
|
||||||
|
add(item, next);
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!groups.length) {
|
||||||
|
for (const item of [...scenario, ...outcomes, ...solutions]) {
|
||||||
|
add(item);
|
||||||
|
if (groups.length >= MAX_GROUPS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryPlanPreview({
|
||||||
|
map,
|
||||||
|
onAdoptQueries,
|
||||||
|
}: {
|
||||||
|
map: DemandMap;
|
||||||
|
onAdoptQueries?: (queries: string[]) => void;
|
||||||
|
}) {
|
||||||
const groups = buildQueryPlan(map);
|
const groups = buildQueryPlan(map);
|
||||||
|
const queries = groups.map((group) => group.terms.join(" ")).filter(isThreadsSearchable);
|
||||||
return (
|
return (
|
||||||
<section className="hb-query-plan" aria-label="查詢計畫預覽">
|
<section className="hb-query-plan" aria-label="查詢計畫預覽">
|
||||||
<div className="hb-query-plan__head">
|
<div className="hb-query-plan__head">
|
||||||
<div><h4>系統會用這些人話找需求</h4><p className="hb-radar-section__hint">產品名稱只作輔助,不會單獨變成查詢組。</p></div>
|
<div><h4>系統會用這些短詞搜尋</h4><p className="hb-radar-section__hint">每組最多兩個詞、中文每詞 2–4 字,才能在 Threads 搜到。產品名稱只作輔助。</p></div>
|
||||||
<span className="hb-radar-section__hint">輸入 {map.demand_input_version} · 地圖 v{map.map_version}</span>
|
<span className="hb-radar-section__hint">輸入 {map.demand_input_version} · 地圖 v{map.map_version}</span>
|
||||||
</div>
|
</div>
|
||||||
{groups.length ? <div className="hb-query-plan__groups">{groups.map((group, index) => <article className="hb-query-plan__group" key={`${group.basis}-${index}`}><strong>查詢組 {index + 1}</strong><div className="hb-demand-map-editor__basis">{group.terms.map((term) => <Badge key={term} tone="brand">{term}</Badge>)}</div><small>依據:{group.basis}</small>{group.exclusion.length ? <small>排除:{group.exclusion.join("、")}</small> : null}</article>)}</div> : <p className="hb-radar-empty">需求地圖尚未有足夠的痛點/情境,暫時無法產生查詢組。</p>}
|
{groups.length ? (
|
||||||
|
<>
|
||||||
|
<div className="hb-query-plan__groups">
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<article className="hb-query-plan__group" key={`${group.basis}-${index}`}>
|
||||||
|
<strong>查詢組 {index + 1}</strong>
|
||||||
|
<div className="hb-demand-map-editor__basis">
|
||||||
|
{group.terms.map((term) => <Badge key={term} tone="brand">{term}</Badge>)}
|
||||||
|
</div>
|
||||||
|
<small>依據:{group.basis}</small>
|
||||||
|
{group.exclusion.length ? <small>排除:{group.exclusion.join("、")}</small> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{onAdoptQueries && queries.length ? (
|
||||||
|
<div className="hb-radar-actions">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onAdoptQueries(queries)}>
|
||||||
|
採用這些查詢詞
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : <p className="hb-radar-empty">需求地圖尚未有足夠的可搜痛點/情境,暫時無法產生查詢組。</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import type { RadarSweep } from "../../domain/types";
|
import type { RadarSweep } from "../../domain/types";
|
||||||
import { Badge } from "../ui";
|
import { Badge } from "../ui";
|
||||||
|
|
||||||
|
function isCrawlerSessionFailure(reason: string): boolean {
|
||||||
|
return /crawler session|Chrome crawler|Chrome 登入已過期/i.test(reason);
|
||||||
|
}
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
complete: "完成", partial_budget: "預算暫停", blocked_budget: "點數不足", failed: "失敗",
|
complete: "完成", partial_budget: "預算暫停", blocked_budget: "點數不足", failed: "失敗",
|
||||||
};
|
};
|
||||||
|
|
@ -17,7 +22,17 @@ export function SweepFunnelSummary({ sweep }: { sweep: RadarSweep }) {
|
||||||
<div className="hb-sweep-funnel__head"><div><h3>這次巡邏跑到哪裡</h3><p className="hb-radar-section__hint">不把合併匹配誤算成新增商機。</p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{statusLabel[status] ?? status}</Badge></div>
|
<div className="hb-sweep-funnel__head"><div><h3>這次巡邏跑到哪裡</h3><p className="hb-radar-section__hint">不把合併匹配誤算成新增商機。</p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{statusLabel[status] ?? status}</Badge></div>
|
||||||
<div className="hb-sweep-funnel__grid">{cells.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
|
<div className="hb-sweep-funnel__grid">{cells.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
|
||||||
<div className="hb-sweep-funnel__credits"><span>點數:搜尋 {sweep.credit_search ?? 0} · 需求地圖 {sweep.credit_demand_map ?? 0} · 判定 {sweep.credit_judge ?? 0}</span><strong>合計 {sweep.credits_used}</strong></div>
|
<div className="hb-sweep-funnel__credits"><span>點數:搜尋 {sweep.credit_search ?? 0} · 需求地圖 {sweep.credit_demand_map ?? 0} · 判定 {sweep.credit_judge ?? 0}</span><strong>合計 {sweep.credits_used}</strong></div>
|
||||||
{sweep.failed_reason ? <p className="hb-banner-error">{sweep.failed_reason}</p> : null}
|
{sweep.failed_reason ? (
|
||||||
|
<p className="hb-banner-error" role="alert">
|
||||||
|
{sweep.failed_reason}
|
||||||
|
{isCrawlerSessionFailure(sweep.failed_reason) ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<Link to="/app/settings">去設定重新同步 Chrome</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">預算未使用的候選會保留,下次可續跑;不會重複扣已成功判定的筆數。</p> : null}
|
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">預算未使用的候選會保留,下次可續跑;不會重複扣已成功判定的筆數。</p> : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ type Props = {
|
||||||
/** 採用一條:include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
|
/** 採用一條:include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
|
||||||
onAdopt: (suggestion: WatchTermSuggestion) => void;
|
onAdopt: (suggestion: WatchTermSuggestion) => void;
|
||||||
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
|
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
|
||||||
|
/** 採用查詢計畫裡已收成的可搜短詞 */
|
||||||
|
onAdoptQueries?: (queries: string[]) => void;
|
||||||
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
|
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
|
||||||
adopted: string[];
|
adopted: string[];
|
||||||
context?: { brand_id?: string; product_id?: string };
|
context?: { brand_id?: string; product_id?: string };
|
||||||
|
|
@ -23,7 +25,7 @@ const SUGGEST_LIMIT = 8;
|
||||||
*
|
*
|
||||||
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
||||||
*/
|
*/
|
||||||
export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, demandMap }: Props) {
|
export function WatchSuggestPanel({ onAdopt, onAdoptAll, onAdoptQueries, adopted, context, demandMap }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const formatError = useFormatApiError();
|
const formatError = useFormatApiError();
|
||||||
|
|
@ -52,7 +54,7 @@ export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, deman
|
||||||
<div className="hb-radar-section">
|
<div className="hb-radar-section">
|
||||||
<h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3>
|
<h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3>
|
||||||
<p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p>
|
<p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p>
|
||||||
{demandMap ? <QueryPlanPreview map={demandMap} /> : null}
|
{demandMap ? <QueryPlanPreview map={demandMap} onAdoptQueries={onAdoptQueries} /> : null}
|
||||||
|
|
||||||
<div className="hb-radar-actions">
|
<div className="hb-radar-actions">
|
||||||
<Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}>
|
<Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Brand, BrandProduct, Opportunity } from "../../domain/types";
|
import type { Brand, BrandProduct, Opportunity } from "../../domain/types";
|
||||||
|
import { isThreadsSearchable } from "../../lib/threadsTerm";
|
||||||
import { createMockRadarRepo } from "./radarRepo";
|
import { createMockRadarRepo } from "./radarRepo";
|
||||||
|
|
||||||
const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" };
|
const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" };
|
||||||
|
|
@ -53,6 +54,26 @@ describe("mock product radar repository", () => {
|
||||||
await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 });
|
await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stamps last_matched_at on sweep so older posted hits stay on today", async () => {
|
||||||
|
const yesterday = Date.now() * 1e6 - 2 * 86_400_000_000_000;
|
||||||
|
const repo = createMockRadarRepo({
|
||||||
|
watches: [{
|
||||||
|
id: "w1", terms: ["敏感肌"], exclude_terms: [], regions: [], status: "active",
|
||||||
|
created_at: 1, updated_at: 1,
|
||||||
|
}],
|
||||||
|
opportunities: [
|
||||||
|
opportunity("old-hit", yesterday, { created_at: yesterday, watch_id: "w1" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((await repo.listOpportunities({ review_state: "pending", time_scope: "today" })).list.map((o) => o.id))
|
||||||
|
.not.toContain("old-hit");
|
||||||
|
await repo.triggerWatchSweep("w1");
|
||||||
|
const after = await repo.listOpportunities({ review_state: "pending", time_scope: "today" });
|
||||||
|
expect(after.list.map((o) => o.id)).toContain("old-hit");
|
||||||
|
expect(after.list.find((o) => o.id === "old-hit")).toMatchObject({ review_state: "pending" });
|
||||||
|
expect(after.list.find((o) => o.id === "old-hit")?.last_matched_at).toBeGreaterThan(yesterday);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps legacy status and supports an idempotent review-state lifecycle", async () => {
|
it("maps legacy status and supports an idempotent review-state lifecycle", async () => {
|
||||||
const repo = createMockRadarRepo({
|
const repo = createMockRadarRepo({
|
||||||
opportunities: [
|
opportunities: [
|
||||||
|
|
@ -121,4 +142,22 @@ describe("mock product radar repository", () => {
|
||||||
expect(updated.custom_phrases[0].text).toBe("晚上漏水");
|
expect(updated.custom_phrases[0].text).toBe("晚上漏水");
|
||||||
await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 });
|
await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shortens long product sentences into searchable demand phrases and suggestions", async () => {
|
||||||
|
const longProduct: BrandProduct = {
|
||||||
|
...product,
|
||||||
|
id: "p-long",
|
||||||
|
product_context: "換季日常修護",
|
||||||
|
pain_points: ["晚上睡覺容易口乾舌燥怎麼辦"],
|
||||||
|
match_tags: ["求推薦"],
|
||||||
|
provider_capability_terms: ["保濕"],
|
||||||
|
};
|
||||||
|
const repo = createMockRadarRepo({ brands: [brand], products: [longProduct] });
|
||||||
|
const baseline = await repo.getDemandMap("p-long");
|
||||||
|
expect(baseline.pain_phrases.every((item) => item.text.length <= 4)).toBe(true);
|
||||||
|
expect(baseline.pain_phrases.some((item) => item.text === "口乾舌燥" || item.text === "晚上睡覺")).toBe(true);
|
||||||
|
const suggestions = await repo.suggestWatchTerms({ product_id: "p-long", limit: 8 });
|
||||||
|
expect(suggestions.length).toBeGreaterThan(0);
|
||||||
|
expect(suggestions.filter((item) => item.usage === "include").every((item) => isThreadsSearchable(item.term))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import type {
|
||||||
RadarSweep,
|
RadarSweep,
|
||||||
RadarWatch,
|
RadarWatch,
|
||||||
ServiceProfile,
|
ServiceProfile,
|
||||||
|
WatchTermSuggestion,
|
||||||
} from "../../domain/types";
|
} from "../../domain/types";
|
||||||
import type { RadarRepo } from "../repos";
|
import type { RadarRepo } from "../repos";
|
||||||
import { ApiError } from "../live/http";
|
import { ApiError } from "../live/http";
|
||||||
import { productRadarSeed } from "../fixtures/radarProduct";
|
import { productRadarSeed } from "../fixtures/radarProduct";
|
||||||
|
import { expandIncludeTerms, searchableTermVariants } from "../../lib/threadsTerm";
|
||||||
|
|
||||||
type MockRadarSeed = {
|
type MockRadarSeed = {
|
||||||
brands?: Brand[];
|
brands?: Brand[];
|
||||||
|
|
@ -41,12 +43,28 @@ function phrase(text: string, kind: string, origin: DemandMapPhrase["origin"] =
|
||||||
return { text, kind, origin, basis_kind: origin === "product" ? kind : "custom", basis_text: basisText, enabled: true };
|
return { text, kind, origin, basis_kind: origin === "product" ? kind : "custom", basis_text: basisText, enabled: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchablePhrases(items: string[], kind: string, basisLabel: string, include: boolean): DemandMapPhrase[] {
|
||||||
|
const out: DemandMapPhrase[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const raw = item.trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
if (!include) {
|
||||||
|
out.push(phrase(raw, kind, "product", basisLabel));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const text of searchableTermVariants(raw).slice(0, 3)) {
|
||||||
|
out.push(phrase(text, kind, "product", raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function baselineDemandMap(product: BrandProduct): DemandMap {
|
function baselineDemandMap(product: BrandProduct): DemandMap {
|
||||||
const pain = product.pain_points.map((text) => phrase(text, "pain", "product", "產品痛點"));
|
const pain = searchablePhrases(product.pain_points, "pain", "產品痛點", true);
|
||||||
const scenario = product.product_context.trim() ? [phrase(product.product_context.trim(), "scenario", "product", "產品情境")] : [];
|
const scenario = searchablePhrases(product.product_context.trim() ? [product.product_context.trim()] : [], "scenario", "產品情境", true);
|
||||||
const outcome = product.match_tags.map((text) => phrase(text, "outcome", "product", "產品標籤"));
|
const outcome = searchablePhrases(product.match_tags, "outcome", "產品標籤", true);
|
||||||
const solution = product.provider_capability_terms.map((text) => phrase(text, "solution", "product", "服務能力"));
|
const solution = searchablePhrases(product.provider_capability_terms, "solution", "產品能力", true);
|
||||||
const exclusion = product.provider_exclude_terms.map((text) => phrase(text, "exclusion", "product", "排除詞"));
|
const exclusion = searchablePhrases(product.provider_exclude_terms, "exclusion", "排除詞", false);
|
||||||
const ready = pain.length > 0 && scenario.length > 0 && solution.length > 0;
|
const ready = pain.length > 0 && scenario.length > 0 && solution.length > 0;
|
||||||
return {
|
return {
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
|
|
@ -173,11 +191,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together");
|
if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together");
|
||||||
if (productMode) productFor(input.brand_id!, input.product_id!);
|
if (productMode) productFor(input.brand_id!, input.product_id!);
|
||||||
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
|
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
|
||||||
|
const terms = expandIncludeTerms(input.terms);
|
||||||
|
if (!terms.length) error("terms required");
|
||||||
const id = `watch-${watches.size + 1}`;
|
const id = `watch-${watches.size + 1}`;
|
||||||
const now = nanoNow();
|
const now = nanoNow();
|
||||||
const row: RadarWatch = {
|
const row: RadarWatch = {
|
||||||
id,
|
id,
|
||||||
terms: copy(input.terms),
|
terms,
|
||||||
exclude_terms: copy(input.exclude_terms ?? []),
|
exclude_terms: copy(input.exclude_terms ?? []),
|
||||||
regions: copy(input.regions ?? []),
|
regions: copy(input.regions ?? []),
|
||||||
status: input.enabled === false ? "paused" : "active",
|
status: input.enabled === false ? "paused" : "active",
|
||||||
|
|
@ -211,7 +231,12 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
},
|
},
|
||||||
async updateWatch(id, patch) {
|
async updateWatch(id, patch) {
|
||||||
const row = watch(id);
|
const row = watch(id);
|
||||||
Object.assign(row, copy(patch), { updated_at: nanoNow() });
|
const next = { ...copy(patch) };
|
||||||
|
if (patch.terms) {
|
||||||
|
next.terms = expandIncludeTerms(patch.terms);
|
||||||
|
if (!next.terms.length) error("terms required");
|
||||||
|
}
|
||||||
|
Object.assign(row, next, { updated_at: nanoNow() });
|
||||||
return copy(row);
|
return copy(row);
|
||||||
},
|
},
|
||||||
async pauseWatch(id) {
|
async pauseWatch(id) {
|
||||||
|
|
@ -239,8 +264,44 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
if (row.status !== "archived") error("only archived watch can be permanently deleted", 400100);
|
if (row.status !== "archived") error("only archived watch can be permanently deleted", 400100);
|
||||||
watches.delete(id);
|
watches.delete(id);
|
||||||
},
|
},
|
||||||
async suggestWatchTerms() {
|
async suggestWatchTerms(input) {
|
||||||
return [];
|
const parsed = typeof input === "number" ? { limit: input } : input ?? {};
|
||||||
|
const limit = parsed.limit ?? 8;
|
||||||
|
const product = parsed.product_id ? products.get(parsed.product_id) : undefined;
|
||||||
|
if (!product) return [];
|
||||||
|
const fields: Array<{ kind: WatchTermSuggestion["basis_kind"]; label: string; terms: string[]; usage: WatchTermSuggestion["usage"] }> = [
|
||||||
|
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
|
||||||
|
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },
|
||||||
|
{ kind: "capability", label: "能力", terms: product.provider_capability_terms, usage: "include" },
|
||||||
|
{ kind: "audience", label: "受眾", terms: [], usage: "include" },
|
||||||
|
{ kind: "context", label: "情境", terms: product.product_context.trim() ? [product.product_context.trim()] : [], usage: "include" },
|
||||||
|
{ kind: "exclude", label: "排除詞", terms: product.provider_exclude_terms, usage: "exclude" },
|
||||||
|
];
|
||||||
|
const out: WatchTermSuggestion[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const field of fields) {
|
||||||
|
for (const basis of field.terms) {
|
||||||
|
const raw = basis.trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
const variants = field.usage === "include" ? searchableTermVariants(raw) : [raw];
|
||||||
|
for (const term of variants) {
|
||||||
|
const key = `${field.usage}:${term.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({
|
||||||
|
term,
|
||||||
|
reason: field.usage === "exclude"
|
||||||
|
? `產品設定將「${raw}」列為排除詞,避免混入非目標貼文`
|
||||||
|
: `依產品設定的${field.label}「${raw}」,可找相關需求貼文`,
|
||||||
|
usage: field.usage,
|
||||||
|
basis_kind: field.kind,
|
||||||
|
basis_text: raw,
|
||||||
|
});
|
||||||
|
if (out.length >= limit) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
},
|
},
|
||||||
async triggerWatchSweep(id) {
|
async triggerWatchSweep(id) {
|
||||||
const row = watch(id);
|
const row = watch(id);
|
||||||
|
|
@ -280,6 +341,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
sweeps.set(sweep.id, sweep);
|
sweeps.set(sweep.id, sweep);
|
||||||
row.last_swept_at = ended;
|
row.last_swept_at = ended;
|
||||||
row.updated_at = ended;
|
row.updated_at = ended;
|
||||||
|
for (const opportunity of candidates) {
|
||||||
|
opportunity.last_matched_at = ended;
|
||||||
|
const state = reviewState(opportunity);
|
||||||
|
if (state !== "removed" && state !== "completed") {
|
||||||
|
opportunity.review_state = "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
return { job_id: sweep.job_id!, sweep_id: sweep.id };
|
return { job_id: sweep.job_id!, sweep_id: sweep.id };
|
||||||
},
|
},
|
||||||
async getToday(filter) {
|
async getToday(filter) {
|
||||||
|
|
@ -297,9 +365,11 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
if (filter.time_scope === "all" || !filter.time_scope) return true;
|
if (filter.time_scope === "all" || !filter.time_scope) return true;
|
||||||
const now = Date.now() * 1_000_000;
|
const now = Date.now() * 1_000_000;
|
||||||
const start = filter.time_scope === "today"
|
const start = filter.time_scope === "today"
|
||||||
? new Date().setUTCHours(0, 0, 0, 0) * 1_000_000
|
? new Date(new Date().setHours(0, 0, 0, 0)).getTime() * 1_000_000
|
||||||
: now - 7 * DAY_NS;
|
: now - 7 * DAY_NS;
|
||||||
return o.posted_at >= start;
|
const posted = o.posted_at ?? 0;
|
||||||
|
const discovered = o.last_matched_at || o.created_at || 0;
|
||||||
|
return posted >= start || discovered >= start;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
switch (filter.sort) {
|
switch (filter.sort) {
|
||||||
|
|
|
||||||
|
|
@ -104,19 +104,19 @@ export const zhTW: MessageDict = {
|
||||||
"help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
|
"help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
|
||||||
"help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
|
"help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
|
||||||
|
|
||||||
"help.page.radar_today.title": "今日商機(自動名單)",
|
"help.page.radar_today.title": "商機巡邏",
|
||||||
"help.page.radar_today.what": "依產品痛點與需求意圖排序的工作收件匣;每張卡只需決定加入名單、只標示已處理,或標示不適合。",
|
"help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
|
||||||
"help.page.radar_today.step1": "先讀貼文與「為什麼推薦」,預設由最值得跟進的結果開始。",
|
"help.page.radar_today.step1": "看頁頂巡邏狀態:每日定時是否開著、上次何時巡、要不要立即再巡一輪。",
|
||||||
"help.page.radar_today.step2": "值得跟進按「加入名單並追蹤」;已自行看完按「只標示已處理」。",
|
"help.page.radar_today.step2": "讀「為什麼推薦」。對得上就「留下」,不是你的就「丟掉」。",
|
||||||
"help.page.radar_today.step3": "不是你的客戶按「不適合」並選原因;需要時可從已移除還原。",
|
"help.page.radar_today.step3": "只有真的要追這個人時才「加入名單」。名單不是看結果的必要步驟。",
|
||||||
"help.page.radar_today.tips": "找客戶=這裡(訂閱/立即探索/匯入)。找內容話題用側欄「話題」。",
|
"help.page.radar_today.tips": "立即巡邏與每日定時可同時開著。關掉其中一個不會藏掉另一個。",
|
||||||
|
|
||||||
"help.page.radar_watches.title": "商機訂閱",
|
"help.page.radar_watches.title": "設定巡邏",
|
||||||
"help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。",
|
"help.page.radar_watches.what": "選定產品與關鍵字後,每日定時巡邏會自動跑;也可隨時按立即巡邏。結果回到側欄「商機」。",
|
||||||
"help.page.radar_watches.step1": "先到「商機政策」填服務與判定資料(沒填不能啟用)。",
|
"help.page.radar_watches.step1": "選品牌與產品,補需求地圖裡的痛點。",
|
||||||
"help.page.radar_watches.step2": "新增訂閱:客人會搜的關鍵字、排除詞、可選地區。",
|
"help.page.radar_watches.step2": "填客人會搜的關鍵字與排除詞。",
|
||||||
"help.page.radar_watches.step3": "啟用後等每日排程,或按「立即巡」補一輪。",
|
"help.page.radar_watches.step3": "打開每日定時,或按「立即巡邏」現在跑一輪。",
|
||||||
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停或封存一組。",
|
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
|
||||||
|
|
||||||
"help.page.crm_board.title": "名單管理",
|
"help.page.crm_board.title": "名單管理",
|
||||||
"help.page.crm_board.what": "從「今日商機」加入後的聯絡人工作清單,可搜尋、篩選、排序、備註、成交與查看時間軸。",
|
"help.page.crm_board.what": "從「今日商機」加入後的聯絡人工作清單,可搜尋、篩選、排序、備註、成交與查看時間軸。",
|
||||||
|
|
@ -972,6 +972,7 @@ export const zhTW: MessageDict = {
|
||||||
"jobs.template.personaAnalyzeText": "人設分析 · 文字來源",
|
"jobs.template.personaAnalyzeText": "人設分析 · 文字來源",
|
||||||
"jobs.template.composeMimic": "仿寫貼文",
|
"jobs.template.composeMimic": "仿寫貼文",
|
||||||
"jobs.template.playGenerateScript": "劇本一次產全文",
|
"jobs.template.playGenerateScript": "劇本一次產全文",
|
||||||
|
"jobs.template.radarSweep": "商機巡邏",
|
||||||
"jobs.template.unknown": "其他任務",
|
"jobs.template.unknown": "其他任務",
|
||||||
"jobs.stripMore": "還有 {n} 個進行中…",
|
"jobs.stripMore": "還有 {n} 個進行中…",
|
||||||
"jobs.nextRun": "下次執行:{time}",
|
"jobs.nextRun": "下次執行:{time}",
|
||||||
|
|
@ -2197,9 +2198,10 @@ export const zhTW: MessageDict = {
|
||||||
"radar.watches.editTitle": "編輯商機訂閱",
|
"radar.watches.editTitle": "編輯商機訂閱",
|
||||||
"radar.watches.requiredHint": "為必填欄位",
|
"radar.watches.requiredHint": "為必填欄位",
|
||||||
"radar.watches.terms": "關鍵字",
|
"radar.watches.terms": "關鍵字",
|
||||||
"radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。",
|
"radar.watches.termsHint": "一行一個短詞。每組最多 2 詞、中文每詞 2–4 字,才能在 Threads 搜到;長句儲存時會自動收成短詞。",
|
||||||
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 2–4 字、整組 ≤12 字、勿用標點/#/emoji,否則常搜不到。",
|
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 2–4 字、整組 ≤12 字、勿用標點/#/emoji。儲存時會自動收成可搜短詞。",
|
||||||
"radar.watches.termsPh": "推薦室內設計\n找設計師",
|
"radar.watches.threadsRequired": "這些關鍵字收不成 Threads 可搜的短詞。請改成每組最多 2 詞、中文每詞 2–4 字。",
|
||||||
|
"radar.watches.termsPh": "室內設計\n找設計師",
|
||||||
"radar.watches.excludeTerms": "排除詞",
|
"radar.watches.excludeTerms": "排除詞",
|
||||||
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
|
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
|
||||||
"radar.watches.excludePh": "徵才\n抽獎",
|
"radar.watches.excludePh": "徵才\n抽獎",
|
||||||
|
|
@ -2228,12 +2230,12 @@ export const zhTW: MessageDict = {
|
||||||
"radar.watches.lastSwept": "上次巡:{at}",
|
"radar.watches.lastSwept": "上次巡:{at}",
|
||||||
"radar.watches.neverSwept": "還沒巡過",
|
"radar.watches.neverSwept": "還沒巡過",
|
||||||
"radar.watches.empty": "還沒有商機訂閱",
|
"radar.watches.empty": "還沒有商機訂閱",
|
||||||
"radar.watches.emptyHint": "加客人會用的說法(例如「推薦室內設計」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
|
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
|
||||||
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
|
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
|
||||||
"radar.watches.scheduleTitle": "每日自動排程:UTC 22:00(台北隔日 06:00)",
|
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00(UTC 22:00)",
|
||||||
"radar.watches.scheduleHint": "啟用中的訂閱會在每天排程建立巡邏任務;想現在跑一次,請到該列按「立即補巡」。排程時間目前固定,不能個別設定。",
|
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
|
||||||
"radar.watches.openToday": "查看今日商機",
|
"radar.watches.openToday": "回商機結果",
|
||||||
"radar.watches.sweepNow": "立即補巡",
|
"radar.watches.sweepNow": "立即巡邏",
|
||||||
"radar.watches.sweepQueued": "已排入商機巡檢",
|
"radar.watches.sweepQueued": "已排入商機巡檢",
|
||||||
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
|
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
|
||||||
|
|
||||||
|
|
@ -2324,7 +2326,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。",
|
"radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。",
|
||||||
"radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。",
|
"radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。",
|
||||||
"radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。",
|
"radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。",
|
||||||
"radar.today.empty.reason.not_swept_yet": "今日自動巡還沒跑完,也可在商機訂閱頁按「立即補巡」。",
|
"radar.today.empty.reason.not_swept_yet": "每日定時還沒跑完,也可在商機頁按「立即巡邏」。",
|
||||||
"radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。",
|
"radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。",
|
||||||
"radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。",
|
"radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。",
|
||||||
"radar.today.msg.accepted": "已加入名單",
|
"radar.today.msg.accepted": "已加入名單",
|
||||||
|
|
@ -2585,19 +2587,19 @@ export const en: MessageDict = {
|
||||||
"help.page.scout.step3": "Draft, open Threads to reply, mark done.",
|
"help.page.scout.step3": "Draft, open Threads to reply, mark done.",
|
||||||
"help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.",
|
"help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.",
|
||||||
|
|
||||||
"help.page.radar_today.title": "Today's demand (auto list)",
|
"help.page.radar_today.title": "Demand patrol",
|
||||||
"help.page.radar_today.what": "A work inbox ranked by product pain fit and demand intent. Decide whether to track, mark handled, or remove each result.",
|
"help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
|
||||||
"help.page.radar_today.step1": "Read the post and Why recommended; the best opportunities come first by default.",
|
"help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
|
||||||
"help.page.radar_today.step2": "Use Add to contacts and track for a real lead, or Mark handled if no follow-up is needed.",
|
"help.page.radar_today.step2": "Read why it was recommended. Keep a fit, discard the rest.",
|
||||||
"help.page.radar_today.step3": "Use Not a fit with a reason for irrelevant results; removed items remain restorable.",
|
"help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
|
||||||
"help.page.radar_today.tips": "Finding customers = this page (watches / Explore / import). Content topics live under Topics.",
|
"help.page.radar_today.tips": "Immediate and daily patrol can both stay on. Turning one off does not hide the other.",
|
||||||
|
|
||||||
"help.page.radar_watches.title": "Demand watches",
|
"help.page.radar_watches.title": "Patrol setup",
|
||||||
"help.page.radar_watches.what": "Always-on keywords swept daily into Today’s demand. This is not Patrol’s “run once” scan.",
|
"help.page.radar_watches.what": "Pick a product and keywords. Daily patrol runs on a schedule; you can also run one immediately. Results land on Demand.",
|
||||||
"help.page.radar_watches.step1": "Fill services and qualification rules under Opportunity policy first.",
|
"help.page.radar_watches.step1": "Choose brand and product, then fill the pain map.",
|
||||||
"help.page.radar_watches.step2": "Add a watch: terms buyers type, excludes, optional regions.",
|
"help.page.radar_watches.step2": "Add terms buyers type and excludes.",
|
||||||
"help.page.radar_watches.step3": "Stay active for the daily job, or hit “Sweep now” for an extra pass.",
|
"help.page.radar_watches.step3": "Leave daily patrol on, or hit Run now.",
|
||||||
"help.page.radar_watches.tips": "Active slots are plan-capped; pause or archive to free one.",
|
"help.page.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
|
||||||
|
|
||||||
"help.page.crm_board.title": "Contact management",
|
"help.page.crm_board.title": "Contact management",
|
||||||
"help.page.crm_board.what": "A searchable, filterable work list of contacts accepted from Today’s demand, with notes, wins, and timelines.",
|
"help.page.crm_board.what": "A searchable, filterable work list of contacts accepted from Today’s demand, with notes, wins, and timelines.",
|
||||||
|
|
@ -3452,6 +3454,7 @@ export const en: MessageDict = {
|
||||||
"jobs.template.personaAnalyzeText": "Persona analyze · from text",
|
"jobs.template.personaAnalyzeText": "Persona analyze · from text",
|
||||||
"jobs.template.composeMimic": "Mimic post",
|
"jobs.template.composeMimic": "Mimic post",
|
||||||
"jobs.template.playGenerateScript": "Play full-script AI",
|
"jobs.template.playGenerateScript": "Play full-script AI",
|
||||||
|
"jobs.template.radarSweep": "Demand patrol",
|
||||||
"jobs.template.unknown": "Other job",
|
"jobs.template.unknown": "Other job",
|
||||||
"jobs.stripMore": "+{n} more running…",
|
"jobs.stripMore": "+{n} more running…",
|
||||||
"jobs.nextRun": "Next run: {time}",
|
"jobs.nextRun": "Next run: {time}",
|
||||||
|
|
@ -4681,9 +4684,10 @@ export const en: MessageDict = {
|
||||||
"radar.watches.editTitle": "Edit demand watch",
|
"radar.watches.editTitle": "Edit demand watch",
|
||||||
"radar.watches.requiredHint": "Required field",
|
"radar.watches.requiredHint": "Required field",
|
||||||
"radar.watches.terms": "Terms",
|
"radar.watches.terms": "Terms",
|
||||||
"radar.watches.termsHint": "One per line, commas work too. Phrases buyers type; hits go to intent scoring.",
|
"radar.watches.termsHint": "One short query per line. Max 2 tokens, CJK 2–4 chars each — long sentences are shortened on save so Threads can search them.",
|
||||||
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 2–4 chars each, ≤12 chars total, no punctuation/#/emoji — long queries often return nothing.",
|
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 2–4 chars each, ≤12 chars total, no punctuation/#/emoji. Save shortens them into searchable queries.",
|
||||||
"radar.watches.termsPh": "interior designer recommendation\nlooking for a designer",
|
"radar.watches.threadsRequired": "These terms cannot be shortened into Threads-searchable queries. Use at most 2 tokens, CJK 2–4 chars each.",
|
||||||
|
"radar.watches.termsPh": "interior design\nfind designer",
|
||||||
"radar.watches.excludeTerms": "Exclude terms",
|
"radar.watches.excludeTerms": "Exclude terms",
|
||||||
"radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.",
|
"radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.",
|
||||||
"radar.watches.excludePh": "hiring\ngiveaway",
|
"radar.watches.excludePh": "hiring\ngiveaway",
|
||||||
|
|
@ -4714,12 +4718,12 @@ export const en: MessageDict = {
|
||||||
"radar.watches.neverSwept": "Never swept",
|
"radar.watches.neverSwept": "Never swept",
|
||||||
"radar.watches.empty": "No demand watches yet",
|
"radar.watches.empty": "No demand watches yet",
|
||||||
"radar.watches.emptyHint":
|
"radar.watches.emptyHint":
|
||||||
"Add phrases buyers type (e.g. “looking for a designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
|
"Add short buyer phrases (e.g. “find designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
|
||||||
"radar.watches.emptyFiltered": "No watches in this status",
|
"radar.watches.emptyFiltered": "No watches in this status",
|
||||||
"radar.watches.scheduleTitle": "Daily schedule: 22:00 UTC (06:00 next day Taipei)",
|
"radar.watches.scheduleTitle": "Daily patrol: 06:00 Taipei (22:00 UTC)",
|
||||||
"radar.watches.scheduleHint": "Active watches create a patrol job at the daily schedule. To run one now, use “Sweep now” on that watch. The time is currently fixed and cannot be customized per watch.",
|
"radar.watches.scheduleHint": "Active watches run once a day. Use Run now for an extra pass. Turning off Run now does not stop the daily patrol.",
|
||||||
"radar.watches.openToday": "Open today's demand",
|
"radar.watches.openToday": "Back to findings",
|
||||||
"radar.watches.sweepNow": "Sweep now",
|
"radar.watches.sweepNow": "Run now",
|
||||||
"radar.watches.sweepQueued": "Demand sweep queued",
|
"radar.watches.sweepQueued": "Demand sweep queued",
|
||||||
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
|
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
|
||||||
|
|
||||||
|
|
@ -4810,7 +4814,7 @@ export const en: MessageDict = {
|
||||||
"radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.",
|
"radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.",
|
||||||
"radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrol’s manual scan).",
|
"radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrol’s manual scan).",
|
||||||
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
|
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
|
||||||
"radar.today.empty.reason.not_swept_yet": "Today’s auto sweep isn’t done; you can also hit Sweep now on watches.",
|
"radar.today.empty.reason.not_swept_yet": "Daily patrol hasn’t finished yet; you can also hit Run now on Demand.",
|
||||||
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
|
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
|
||||||
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
|
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
|
||||||
"radar.today.msg.accepted": "Added to CRM",
|
"radar.today.msg.accepted": "Added to CRM",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export function jobTemplateLabel(
|
||||||
return t("jobs.template.composeMimic");
|
return t("jobs.template.composeMimic");
|
||||||
case "play_generate_script":
|
case "play_generate_script":
|
||||||
return t("jobs.template.playGenerateScript");
|
return t("jobs.template.playGenerateScript");
|
||||||
|
case "radar_sweep":
|
||||||
|
return t("jobs.template.radarSweep");
|
||||||
default:
|
default:
|
||||||
return templateType || t("jobs.template.unknown");
|
return templateType || t("jobs.template.unknown");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const primaryNav: NavItem[] = [
|
||||||
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
||||||
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
|
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
|
||||||
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
|
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
|
||||||
{ key: "radar", path: "/app/radar/today", labelKey: "nav.radar", label: "商機", en: "Demand" },
|
{ key: "radar", path: "/app/radar", labelKey: "nav.radar", label: "商機", en: "Demand" },
|
||||||
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
|
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
|
||||||
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
||||||
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
|
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
|
||||||
|
|
@ -145,9 +145,9 @@ export function pathForNotification(n: {
|
||||||
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
|
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
|
||||||
if (n.ref_type === "contact") return "/app/crm/followups";
|
if (n.ref_type === "contact") return "/app/crm/followups";
|
||||||
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
|
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
|
||||||
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar/today";
|
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar";
|
||||||
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
|
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
|
||||||
if (n.ref_type === "radar") return "/app/radar/today";
|
if (n.ref_type === "radar") return "/app/radar";
|
||||||
// 任務/系統/無 ref:任務中心
|
// 任務/系統/無 ref:任務中心
|
||||||
return "/app/jobs";
|
return "/app/jobs";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { primaryNav } from "./nav";
|
||||||
import { normalizeAppPath, pageHelpKeys, resolvePageHelpId } from "./pageHelp";
|
import { normalizeAppPath, pageHelpKeys, resolvePageHelpId } from "./pageHelp";
|
||||||
|
|
||||||
describe("pageHelp", () => {
|
describe("pageHelp", () => {
|
||||||
|
|
@ -6,6 +7,7 @@ describe("pageHelp", () => {
|
||||||
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
||||||
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
||||||
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
|
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
|
||||||
|
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar");
|
||||||
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
|
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
|
||||||
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
||||||
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
|
||||||
/** 每個說明可連到的相關頁(可選) */
|
/** 每個說明可連到的相關頁(可選) */
|
||||||
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
|
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
|
||||||
today: [
|
today: [
|
||||||
{ path: "/app/radar/today", labelKey: "nav.radar" },
|
{ path: "/app/radar", labelKey: "nav.radar" },
|
||||||
{ path: "/app/scout", labelKey: "nav.scout" },
|
{ path: "/app/scout", labelKey: "nav.scout" },
|
||||||
{ path: "/app/outbox", labelKey: "nav.outbox" },
|
{ path: "/app/outbox", labelKey: "nav.outbox" },
|
||||||
],
|
],
|
||||||
|
|
@ -79,21 +79,21 @@ export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> =
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/policy", labelKey: "nav.policy" },
|
||||||
],
|
],
|
||||||
radar_watches: [
|
radar_watches: [
|
||||||
{ path: "/app/radar/today", labelKey: "nav.radar" },
|
{ path: "/app/radar", labelKey: "nav.radar" },
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/policy", labelKey: "nav.policy" },
|
||||||
],
|
],
|
||||||
crm_board: [
|
crm_board: [
|
||||||
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
||||||
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
||||||
{ path: "/app/radar/today", labelKey: "nav.radar" },
|
{ path: "/app/radar", labelKey: "nav.radar" },
|
||||||
],
|
],
|
||||||
crm_followups: [
|
crm_followups: [
|
||||||
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
|
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
|
||||||
{ path: "/app/radar/today", labelKey: "nav.radar" },
|
{ path: "/app/radar", labelKey: "nav.radar" },
|
||||||
],
|
],
|
||||||
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
|
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
|
||||||
scout: [
|
scout: [
|
||||||
{ path: "/app/radar/today", labelKey: "nav.radar" },
|
{ path: "/app/radar", labelKey: "nav.radar" },
|
||||||
{ path: "/app/brands", labelKey: "nav.brands" },
|
{ path: "/app/brands", labelKey: "nav.brands" },
|
||||||
],
|
],
|
||||||
brands: [
|
brands: [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
checkThreadsTerm,
|
checkThreadsTerm,
|
||||||
|
expandIncludeTerms,
|
||||||
isThreadsSearchable,
|
isThreadsSearchable,
|
||||||
normalizeSearchTerm,
|
normalizeSearchTerm,
|
||||||
|
searchableTermVariants,
|
||||||
selectThreadsSearchTerms,
|
selectThreadsSearchTerms,
|
||||||
threadsPostIdentity,
|
threadsPostIdentity,
|
||||||
} from "./threadsTerm";
|
} from "./threadsTerm";
|
||||||
|
|
@ -27,6 +29,19 @@ describe("threadsTerm", () => {
|
||||||
expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" });
|
expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shortens long CJK sentences into searchable windows", () => {
|
||||||
|
expect(searchableTermVariants("保母 求推薦")).toEqual(["保母 求推薦"]);
|
||||||
|
const variants = searchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦");
|
||||||
|
expect(variants.length).toBeGreaterThan(0);
|
||||||
|
expect(variants.every(isThreadsSearchable)).toBe(true);
|
||||||
|
expect(variants).toContain("晚上睡覺");
|
||||||
|
expect(variants).toContain("口乾舌燥");
|
||||||
|
expect(searchableTermVariants("台北 婚攝 推薦 價格")[0]).toBe("台北 婚攝");
|
||||||
|
expect(expandIncludeTerms(["求推薦", "晚上睡覺容易口乾舌燥怎麼辦"])).toEqual(
|
||||||
|
expect.arrayContaining(["求推薦", "晚上睡覺", "口乾舌燥"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("selects inferred work queries when the original has too many tokens", () => {
|
it("selects inferred work queries when the original has too many tokens", () => {
|
||||||
expect(
|
expect(
|
||||||
selectThreadsSearchTerms("外包 工程師 後端", [
|
selectThreadsSearchTerms("外包 工程師 後端", [
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,141 @@ export function isThreadsSearchable(term: string): boolean {
|
||||||
return checkThreadsTerm(term).ok;
|
return checkThreadsTerm(term).ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_FILLERS = [
|
||||||
|
"怎麼辦",
|
||||||
|
"求推薦",
|
||||||
|
"有沒有人",
|
||||||
|
"有人知道",
|
||||||
|
"請問一下",
|
||||||
|
"請問",
|
||||||
|
"想問",
|
||||||
|
"想找",
|
||||||
|
"有沒有",
|
||||||
|
"可以嗎",
|
||||||
|
"好不好",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_VARIANTS = 8;
|
||||||
|
const MAX_VARIANTS_PER_TERM = 3;
|
||||||
|
|
||||||
|
function splitSearchParts(raw: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let buf = "";
|
||||||
|
const flush = () => {
|
||||||
|
const value = buf.trim();
|
||||||
|
if (value) parts.push(value);
|
||||||
|
buf = "";
|
||||||
|
};
|
||||||
|
for (const ch of raw) {
|
||||||
|
if (/[0-9A-Za-z]/.test(ch) || isCJK(ch)) buf += ch;
|
||||||
|
else flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSearchFillers(value: string): string {
|
||||||
|
let next = value;
|
||||||
|
for (const filler of SEARCH_FILLERS) next = next.split(filler).join("");
|
||||||
|
return next.replace(/[ ,,、。!?!?::的了嗎呢啊喔唷]+$/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把使用者/產品長句收成 Threads 可搜短詞(與後端 domain.SearchableTermVariants 對齊)。
|
||||||
|
* 已合規的原詞只回一則;過長句拆 token 對、去掉求助語尾,再取頭尾 2–4 字。
|
||||||
|
*/
|
||||||
|
export function searchableTermVariants(raw: string, include = true): string[] {
|
||||||
|
const normalized = normalizeSearchTerm(raw);
|
||||||
|
if (!normalized) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
const add = (term: string) => {
|
||||||
|
const next = normalizeSearchTerm(term);
|
||||||
|
if (!next || seen.has(next.toLowerCase())) return;
|
||||||
|
if (include) {
|
||||||
|
if (!isThreadsSearchable(next)) return;
|
||||||
|
} else if ([...next].length < 2 || [...next].length > 60) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(next.toLowerCase());
|
||||||
|
out.push(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (include && isThreadsSearchable(normalized)) {
|
||||||
|
add(normalized);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (!include) {
|
||||||
|
add(normalized);
|
||||||
|
if (out.length) return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = normalized.split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length > MAX_TOKENS) {
|
||||||
|
for (let i = 0; i + 1 < tokens.length && out.length < MAX_VARIANTS; i += 1) {
|
||||||
|
add(`${tokens[i]} ${tokens[i + 1]}`);
|
||||||
|
}
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (out.length >= MAX_VARIANTS) break;
|
||||||
|
for (const variant of searchableTermVariants(tok, include)) {
|
||||||
|
add(variant);
|
||||||
|
if (out.length >= MAX_VARIANTS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = splitSearchParts(normalized);
|
||||||
|
for (const part of parts.length ? parts : [normalized]) {
|
||||||
|
if (out.length >= MAX_VARIANTS) break;
|
||||||
|
if (include && isThreadsSearchable(part)) {
|
||||||
|
add(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stripped = stripSearchFillers(part);
|
||||||
|
if (stripped && stripped !== part) add(stripped);
|
||||||
|
const runes = [...(stripped || part)];
|
||||||
|
if (runes.length === 5) {
|
||||||
|
add(runes.slice(0, 3).join(""));
|
||||||
|
add(runes.slice(3).join(""));
|
||||||
|
}
|
||||||
|
for (const width of [4, 3, 2]) {
|
||||||
|
if (runes.length < width) continue;
|
||||||
|
add(runes.slice(0, width).join(""));
|
||||||
|
if (runes.length > width) add(runes.slice(-width).join(""));
|
||||||
|
}
|
||||||
|
if (out.length >= 3) continue;
|
||||||
|
for (let width = 4; width >= 2; width -= 1) {
|
||||||
|
if (runes.length < width) continue;
|
||||||
|
for (let start = 0; start + width <= runes.length && out.length < MAX_VARIANTS; start += 1) {
|
||||||
|
add(runes.slice(start, start + width).join(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 訂閱關鍵字:已合規保留,長句各收成最多 3 則可搜短詞。 */
|
||||||
|
export function expandIncludeTerms(terms: string[], maxTotal = 20): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of terms) {
|
||||||
|
const normalized = normalizeSearchTerm(raw);
|
||||||
|
if (!normalized) continue;
|
||||||
|
const variants = isThreadsSearchable(normalized)
|
||||||
|
? [normalized]
|
||||||
|
: searchableTermVariants(normalized).slice(0, MAX_VARIANTS_PER_TERM);
|
||||||
|
for (const term of variants) {
|
||||||
|
const key = term.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(term);
|
||||||
|
if (out.length >= maxTotal) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 依後端產詞順序選出實際要 fan-out 的查詢。
|
* 依後端產詞順序選出實際要 fan-out 的查詢。
|
||||||
* 原輸入若本身合規就保留為第一組;否則使用後端推論出的第一組。
|
* 原輸入若本身合規就保留為第一組;否則使用後端推論出的第一組。
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ export function CrmBoardPage() {
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("crm.board.title")} />
|
<PageHeader title={t("crm.board.title")} />
|
||||||
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
|
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
|
||||||
{t("crm.board.link.today")}
|
{t("crm.board.link.today")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
||||||
|
|
@ -262,7 +262,7 @@ export function CrmBoardPage() {
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t("crm.board.empty")}
|
title={t("crm.board.empty")}
|
||||||
description={t("crm.board.emptyHint")}
|
description={t("crm.board.emptyHint")}
|
||||||
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">{t("crm.board.link.today")}</Link>}
|
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar">{t("crm.board.link.today")}</Link>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "../data/live/http";
|
import { ApiError } from "../data/live/http";
|
||||||
import { KEYS } from "../data/mock/keys";
|
import { KEYS } from "../data/mock/keys";
|
||||||
import type { Opportunity } from "../domain/types";
|
import type { Opportunity, RadarWatch } from "../domain/types";
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
import { I18nProvider } from "../i18n/I18nContext";
|
||||||
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
||||||
|
|
||||||
|
|
@ -12,7 +12,12 @@ const backend = vi.hoisted(() => ({
|
||||||
calls: [] as Array<Record<string, unknown>>,
|
calls: [] as Array<Record<string, unknown>>,
|
||||||
accepted: [] as string[],
|
accepted: [] as string[],
|
||||||
reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>,
|
reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>,
|
||||||
|
sweeps: [] as string[],
|
||||||
result: null as { list: Opportunity[]; total: number } | null,
|
result: null as { list: Opportunity[]; total: number } | null,
|
||||||
|
byScope: null as null | ((filter: Record<string, unknown>) => { list: Opportunity[]; total: number }),
|
||||||
|
watches: [] as RadarWatch[],
|
||||||
|
jobResult: null as null | ((id: string) => { status: "succeeded" | "failed" | "running"; progress_summary: string; error?: string }),
|
||||||
|
lastSweptAt: Date.now() * 1e6,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function opportunity(): Opportunity {
|
function opportunity(): Opportunity {
|
||||||
|
|
@ -49,12 +54,52 @@ vi.mock("../data/DataContext", () => ({
|
||||||
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
||||||
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
|
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
|
||||||
},
|
},
|
||||||
|
jobs: {
|
||||||
|
async get(id: string) {
|
||||||
|
const configured = backend.jobResult?.(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
template_type: "radar_sweep",
|
||||||
|
status: configured?.status ?? "succeeded",
|
||||||
|
progress_summary: configured?.progress_summary ?? "雷達巡檢完成 · 新建 1 · 再次命中 0 · 判定 1 · 截斷 0",
|
||||||
|
error: configured?.error,
|
||||||
|
progress_percent: 100,
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
radar: {
|
radar: {
|
||||||
async listOpportunities(filter: Record<string, unknown>) {
|
async listOpportunities(filter: Record<string, unknown>) {
|
||||||
backend.calls.push(filter);
|
backend.calls.push(filter);
|
||||||
if (backend.error) throw backend.error;
|
if (backend.error) throw backend.error;
|
||||||
|
if (backend.byScope) return backend.byScope(filter);
|
||||||
return backend.result ?? { list: [opportunity()], total: 1 };
|
return backend.result ?? { list: [opportunity()], total: 1 };
|
||||||
},
|
},
|
||||||
|
async listWatches() {
|
||||||
|
return {
|
||||||
|
list: backend.watches,
|
||||||
|
total: backend.watches.length,
|
||||||
|
active_count: backend.watches.filter((w) => w.status === "active").length,
|
||||||
|
max_active: 5,
|
||||||
|
profile_exists: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getToday() {
|
||||||
|
return {
|
||||||
|
stats: { total: 0, high: 0, mid: 0, low: 0 },
|
||||||
|
high: [], mid: [], low: [],
|
||||||
|
truncated_count: 0,
|
||||||
|
last_swept_at: backend.lastSweptAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async triggerWatchSweep(id: string) {
|
||||||
|
backend.sweeps.push(id);
|
||||||
|
return { job_id: `job-${id}` };
|
||||||
|
},
|
||||||
|
async listSweeps() {
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
},
|
||||||
async acceptOpportunity(id: string) {
|
async acceptOpportunity(id: string) {
|
||||||
backend.accepted.push(id);
|
backend.accepted.push(id);
|
||||||
return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" };
|
return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" };
|
||||||
|
|
@ -69,9 +114,9 @@ vi.mock("../data/DataContext", () => ({
|
||||||
})(),
|
})(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(path = "/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted") {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted"]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
|
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
@ -83,10 +128,52 @@ beforeEach(() => {
|
||||||
backend.calls = [];
|
backend.calls = [];
|
||||||
backend.accepted = [];
|
backend.accepted = [];
|
||||||
backend.reviews = [];
|
backend.reviews = [];
|
||||||
|
backend.sweeps = [];
|
||||||
backend.result = null;
|
backend.result = null;
|
||||||
|
backend.byScope = null;
|
||||||
|
backend.jobResult = null;
|
||||||
|
backend.lastSweptAt = Date.now() * 1e6;
|
||||||
|
backend.watches = [{
|
||||||
|
id: "w1",
|
||||||
|
terms: ["敏感肌"],
|
||||||
|
exclude_terms: [],
|
||||||
|
regions: [],
|
||||||
|
status: "active",
|
||||||
|
last_swept_at: backend.lastSweptAt,
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
}];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RadarOpportunitiesPage", () => {
|
describe("RadarOpportunitiesPage", () => {
|
||||||
|
it("shows scheduled patrol, last run, and run-now on the primary workplace", async () => {
|
||||||
|
renderPage("/app/radar");
|
||||||
|
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
|
||||||
|
expect(screen.getByText("每日定時巡邏:開著")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/上次巡邏:/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "立即巡邏" })).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "立即巡邏" }));
|
||||||
|
await waitFor(() => expect(backend.sweeps).toEqual(["w1"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs every active watch and reports a real patrol failure", async () => {
|
||||||
|
backend.watches.push({
|
||||||
|
...backend.watches[0],
|
||||||
|
id: "w2",
|
||||||
|
terms: ["求推薦"],
|
||||||
|
});
|
||||||
|
backend.jobResult = (id) => id === "job-w2"
|
||||||
|
? { status: "failed", progress_summary: "今天沒巡到:Chrome 登入已過期。" }
|
||||||
|
: { status: "succeeded", progress_summary: "雷達巡檢完成 · 新建 1" };
|
||||||
|
|
||||||
|
renderPage("/app/radar");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "立即巡邏" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(backend.sweeps).toEqual(["w1", "w2"]));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("今天沒巡到:Chrome 登入已過期。");
|
||||||
|
expect(screen.queryByText("這一輪巡邏跑完了。找到的痛點會留在下面。")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores URL filters and removes product when brand is cleared", async () => {
|
it("restores URL filters and removes product when brand is cleared", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByText("正在找適合敏感肌的日常修護產品")).toBeTruthy();
|
expect(await screen.findByText("正在找適合敏感肌的日常修護產品")).toBeTruthy();
|
||||||
|
|
@ -109,37 +196,62 @@ describe("RadarOpportunitiesPage", () => {
|
||||||
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "先看哪些" }), { target: { value: "newest" } });
|
fireEvent.change(screen.getByRole("combobox", { name: "先看哪些" }), { target: { value: "newest" } });
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "看哪段時間" }), { target: { value: "7d" } });
|
fireEvent.change(screen.getByRole("combobox", { name: "看哪段時間" }), { target: { value: "7d" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "已處理" }));
|
fireEvent.click(screen.getByRole("button", { name: "已看過" }));
|
||||||
await waitFor(() => expect(backend.calls.at(-1)).toMatchObject({ sort: "newest", time_scope: "7d", review_state: "completed" }));
|
await waitFor(() => expect(backend.calls.at(-1)).toMatchObject({ sort: "newest", time_scope: "7d", review_state: "completed" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("explains a filtered empty result instead of rendering a blank page", async () => {
|
it("explains a filtered empty result instead of rendering a blank page", async () => {
|
||||||
backend.result = { list: [], total: 0 };
|
backend.result = { list: [], total: 0 };
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByText("目前沒有待決定的商機")).toBeTruthy();
|
expect(await screen.findByText("這個篩選下沒有結果")).toBeTruthy();
|
||||||
expect(screen.getByText("清除篩選或改看其他時間範圍。")).toBeTruthy();
|
expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("把最重要的三個決策直接放在卡片,加入名單後提供下一步", async () => {
|
it("widens today to 7d when patrol hits are older posts", async () => {
|
||||||
|
const older = opportunity();
|
||||||
|
older.id = "older-hit";
|
||||||
|
older.text = "舊文但是這輪才判定";
|
||||||
|
backend.result = { list: [], total: 0 };
|
||||||
|
backend.byScope = (filter: Record<string, unknown>) => {
|
||||||
|
if (filter.time_scope === "7d" && filter.review_state === "pending") {
|
||||||
|
return { list: [older], total: 1 };
|
||||||
|
}
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
};
|
||||||
|
renderPage("/app/radar");
|
||||||
|
expect(await screen.findByText("舊文但是這輪才判定")).toBeTruthy();
|
||||||
|
expect(await screen.findByText(/已改看近 7 天/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains an unfiltered empty workplace in patrol language", async () => {
|
||||||
|
backend.result = { list: [], total: 0 };
|
||||||
|
renderPage("/app/radar");
|
||||||
|
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("這輪有巡,但沒找到符合的痛點")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/全部/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default actions are keep or discard; joining 名單 is optional", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "加入名單並追蹤" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "留下" })).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "只標示已處理" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "丟掉" })).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "不適合" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "加入名單(可選)" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "加入名單並追蹤" }));
|
fireEvent.click(screen.getByRole("button", { name: "加入名單(可選)" }));
|
||||||
await screen.findByText("已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。");
|
await screen.findByText("已加入名單。這步是可選的,之後要追蹤再去名單即可。");
|
||||||
expect(backend.accepted).toEqual(["opp-archive"]);
|
expect(backend.accepted).toEqual(["opp-archive"]);
|
||||||
expect(screen.getByRole("link", { name: "現在前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
|
expect(screen.getByRole("link", { name: "前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("只標示已處理不會誤建名單", async () => {
|
it("留下不會誤建名單", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
await screen.findByText("正在找適合敏感肌的日常修護產品");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "只標示已處理" }));
|
fireEvent.click(screen.getByRole("button", { name: "留下" }));
|
||||||
|
|
||||||
await screen.findByText("已標示為已處理;沒有建立聯絡人名單。");
|
await screen.findByText("已留下。沒有建立名單。");
|
||||||
expect(backend.accepted).toEqual([]);
|
expect(backend.accepted).toEqual([]);
|
||||||
expect(backend.reviews).toEqual([{ id: "opp-archive", patch: { state: "completed" } }]);
|
expect(backend.reviews).toEqual([{ id: "opp-archive", patch: { state: "completed" } }]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
import { PageHeader } from "../components/layout/PageHeader";
|
||||||
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
||||||
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
||||||
|
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
||||||
import { Button, EmptyState, Select } from "../components/ui";
|
import { Button, EmptyState, Select } from "../components/ui";
|
||||||
import { useRepos } from "../data/DataContext";
|
import { useRepos } from "../data/DataContext";
|
||||||
import type { Brand, BrandProduct, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope } from "../domain/types";
|
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
import { useFormatApiError } from "../lib/apiErrors";
|
||||||
|
import { formatLocalDateTime } from "../lib/time";
|
||||||
import "../styles/radar.css";
|
import "../styles/radar.css";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
@ -38,6 +40,12 @@ export function RadarOpportunitiesPage() {
|
||||||
const [products, setProducts] = useState<BrandProduct[]>([]);
|
const [products, setProducts] = useState<BrandProduct[]>([]);
|
||||||
const [selected, setSelected] = useState<Opportunity | null>(null);
|
const [selected, setSelected] = useState<Opportunity | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [sweeping, setSweeping] = useState(false);
|
||||||
|
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
|
||||||
|
const [watches, setWatches] = useState<RadarWatch[]>([]);
|
||||||
|
const [activeCount, setActiveCount] = useState(0);
|
||||||
|
const [watchTotal, setWatchTotal] = useState(0);
|
||||||
|
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
|
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
|
||||||
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
||||||
|
|
||||||
|
|
@ -53,20 +61,70 @@ export function RadarOpportunitiesPage() {
|
||||||
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
|
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
|
||||||
}, [scout, brandId]);
|
}, [scout, brandId]);
|
||||||
|
|
||||||
|
const loadPatrol = useCallback(async () => {
|
||||||
|
const [watchRes, today] = await Promise.all([
|
||||||
|
repos.radar.listWatches(1, 50).catch(() => null),
|
||||||
|
repos.radar.getToday().catch(() => null),
|
||||||
|
]);
|
||||||
|
if (watchRes) {
|
||||||
|
setWatches(watchRes.list);
|
||||||
|
setActiveCount(watchRes.active_count);
|
||||||
|
setWatchTotal(watchRes.total);
|
||||||
|
}
|
||||||
|
setTodayMeta(today);
|
||||||
|
if (typeof repos.radar.listSweeps === "function") {
|
||||||
|
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
|
||||||
|
setLastSweep(sweeps?.list[0] ?? null);
|
||||||
|
}
|
||||||
|
}, [repos.radar]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) =>
|
||||||
const result = await repos.radar.listOpportunities({
|
repos.radar.listOpportunities({
|
||||||
page,
|
page: scope === timeScope ? page : 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
band: band || undefined,
|
band: band || undefined,
|
||||||
match_state: state || undefined,
|
match_state: state || undefined,
|
||||||
brand_id: brandId || undefined,
|
brand_id: brandId || undefined,
|
||||||
product_id: productId || undefined,
|
product_id: productId || undefined,
|
||||||
review_state: reviewState,
|
review_state: review,
|
||||||
time_scope: timeScope,
|
time_scope: scope,
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
let result = await query(timeScope);
|
||||||
|
const canWiden = reviewState === "pending" && timeScope === "today" && !band && !state && !brandId && !productId;
|
||||||
|
if (canWiden && result.total === 0) {
|
||||||
|
const week = await query("7d");
|
||||||
|
if (week.total > 0) {
|
||||||
|
setTimeScope("7d");
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
||||||
|
next.set("time_scope", "7d");
|
||||||
|
next.delete("page");
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
result = week;
|
||||||
|
setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` });
|
||||||
|
} else {
|
||||||
|
const all = await query("all");
|
||||||
|
if (all.total > 0) {
|
||||||
|
setTimeScope("all");
|
||||||
|
setPage(1);
|
||||||
|
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
||||||
|
next.set("time_scope", "all");
|
||||||
|
next.delete("page");
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
result = all;
|
||||||
|
setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` });
|
||||||
|
} else {
|
||||||
|
const seen = await query("7d", "completed");
|
||||||
|
if (seen.total > 0) {
|
||||||
|
setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
setList(result.list);
|
setList(result.list);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -75,9 +133,10 @@ export function RadarOpportunitiesPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError]);
|
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
||||||
|
|
||||||
async function updateReviewState(
|
async function updateReviewState(
|
||||||
opportunity: Opportunity,
|
opportunity: Opportunity,
|
||||||
|
|
@ -108,7 +167,7 @@ export function RadarOpportunitiesPage() {
|
||||||
await load();
|
await load();
|
||||||
setError("");
|
setError("");
|
||||||
setNotice({
|
setNotice({
|
||||||
text: "已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。",
|
text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。",
|
||||||
contactId: result.contact_id,
|
contactId: result.contact_id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -118,6 +177,84 @@ export function RadarOpportunitiesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
||||||
|
status: "queued",
|
||||||
|
progress_summary: "立即巡邏已排程 · 等待 worker",
|
||||||
|
error: "",
|
||||||
|
};
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const job = await repos.jobs.get(jobId);
|
||||||
|
if (job) {
|
||||||
|
last = { status: job.status, progress_summary: job.progress_summary, error: job.error || "" };
|
||||||
|
if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") {
|
||||||
|
return { ...last, timedOut: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 1500));
|
||||||
|
}
|
||||||
|
return { ...last, timedOut: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow() {
|
||||||
|
const active = watches.filter((watch) => watch.status === "active");
|
||||||
|
if (!active.length) {
|
||||||
|
setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSweeping(true);
|
||||||
|
setError("");
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const jobIds: string[] = [];
|
||||||
|
for (const watch of active) {
|
||||||
|
const res = await repos.radar.triggerWatchSweep(watch.id);
|
||||||
|
if (res.job_id) jobIds.push(res.job_id);
|
||||||
|
}
|
||||||
|
if (!jobIds.length) {
|
||||||
|
setError("沒有排到巡邏任務。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" });
|
||||||
|
// All active watches were queued together, so observe them together too.
|
||||||
|
// Waiting serially made the page look frozen for up to 120s per watch.
|
||||||
|
const finished = await Promise.all(jobIds.map((id) => waitForJob(id)));
|
||||||
|
const failed = finished.find((job) => job.status === "failed");
|
||||||
|
const cancelled = finished.find((job) => job.status === "cancelled");
|
||||||
|
const running = finished.filter((job) => job.timedOut || job.status === "pending" || job.status === "queued" || job.status === "running" || job.status === "cancel_requested");
|
||||||
|
const terminalError = failed
|
||||||
|
? failed.progress_summary || failed.error || "巡邏失敗。"
|
||||||
|
: cancelled
|
||||||
|
? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。"
|
||||||
|
: "";
|
||||||
|
if (typeof repos.radar.listSweeps === "function") {
|
||||||
|
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
|
||||||
|
setLastSweep(sweeps?.list[0] ?? null);
|
||||||
|
}
|
||||||
|
await Promise.all([load(), loadPatrol()]);
|
||||||
|
const summary = finished.map((job) => job.progress_summary).filter(Boolean).join(" ");
|
||||||
|
// load() clears stale list errors on a successful refresh. Re-apply the
|
||||||
|
// job outcome afterwards so the actual patrol failure stays visible.
|
||||||
|
if (terminalError) {
|
||||||
|
setError(terminalError);
|
||||||
|
setNotice(null);
|
||||||
|
} else if (running.length > 0) {
|
||||||
|
setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` });
|
||||||
|
} else {
|
||||||
|
setNotice({
|
||||||
|
text: summary.includes("新建")
|
||||||
|
? `${summary} 不是今天發的文也會留在下面。`
|
||||||
|
: "這一輪巡邏跑完了。找到的痛點會留在下面。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatError(e));
|
||||||
|
} finally {
|
||||||
|
setSweeping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeParams(changes: Record<string, string>) {
|
function writeParams(changes: Record<string, string>) {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
for (const [name, value] of Object.entries(changes)) {
|
for (const [name, value] of Object.entries(changes)) {
|
||||||
|
|
@ -137,60 +274,144 @@ export function RadarOpportunitiesPage() {
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams();
|
||||||
next.set("review_state", reviewState);
|
if (reviewState !== "pending") next.set("review_state", reviewState);
|
||||||
next.set("time_scope", "today");
|
|
||||||
setParams(next, { replace: true });
|
setParams(next, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastSweptAt = Math.max(
|
||||||
|
todayMeta?.last_swept_at ?? 0,
|
||||||
|
...watches.map((watch) => watch.last_swept_at ?? 0),
|
||||||
|
);
|
||||||
|
const scheduledOn = activeCount > 0;
|
||||||
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
||||||
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
|
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
||||||
|
if (filtered) {
|
||||||
|
return {
|
||||||
|
title: reviewState === "pending" ? "這個篩選下沒有結果" : reviewState === "completed" ? "目前沒有已看過的結果" : "目前沒有已丟掉的結果",
|
||||||
|
description: "清除篩選或改看其他時間範圍。巡邏剛跑完的結果也可能在「近 7 天」或「全部」。",
|
||||||
|
action: <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reviewState === "completed") {
|
||||||
|
return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
||||||
|
}
|
||||||
|
if (reviewState === "removed") {
|
||||||
|
return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
||||||
|
}
|
||||||
|
if (watchTotal === 0) {
|
||||||
|
return {
|
||||||
|
title: "還沒設定巡邏",
|
||||||
|
description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
|
||||||
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">設定巡邏</Link>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!scheduledOn) {
|
||||||
|
return {
|
||||||
|
title: "每日定時巡邏關著",
|
||||||
|
description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
|
||||||
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">打開每日定時巡邏</Link>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!lastSweptAt) {
|
||||||
|
return {
|
||||||
|
title: "還沒巡邏過",
|
||||||
|
description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。",
|
||||||
|
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "立即巡邏"}</Button>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (todayMeta?.empty_reason === "sweep_failed") {
|
||||||
|
const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test(
|
||||||
|
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
title: "上一輪巡邏沒跑完",
|
||||||
|
description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。",
|
||||||
|
action: (
|
||||||
|
<>
|
||||||
|
{crawlerDead ? <Link className="hb-btn hb-btn--secondary" to="/app/settings">重新同步 Chrome</Link> : null}
|
||||||
|
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "再巡一次"}</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lastSweep && lastSweep.hit_count === 0) {
|
||||||
|
return {
|
||||||
|
title: "搜尋沒撈到貼文",
|
||||||
|
description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 2–4 字痛點詞再巡。",
|
||||||
|
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">改關鍵字</Link>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "這輪有巡,但沒找到符合的痛點",
|
||||||
|
description: lastSweep
|
||||||
|
? `搜尋命中 ${lastSweep.hit_count}、判定 ${lastSweep.judged_count}、新建 ${lastSweep.created_count}。不是今天發的文可改看近 7 天/全部。`
|
||||||
|
: "新文章或產品對得上的需求會出現在這裡。也可改看「近 7 天」或「全部」,或調整要巡的關鍵字。",
|
||||||
|
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>看近 7 天</Button>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = emptyCopy();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="商機收件匣" />
|
<PageHeader title="商機" />
|
||||||
|
|
||||||
|
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label="巡邏狀態">
|
||||||
|
<div className="hb-radar-patrol__status">
|
||||||
|
<p>
|
||||||
|
<strong>{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"}</strong>
|
||||||
|
<span>每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"}</strong>
|
||||||
|
<span>{scheduledOn ? `啟用中 ${activeCount} 組` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hb-radar-patrol__actions">
|
||||||
|
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
|
||||||
|
{sweeping ? "巡邏中…" : "立即巡邏"}
|
||||||
|
</Button>
|
||||||
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">設定巡邏</Link>
|
||||||
|
<small>Chrome 失效時會備援改用 API;只有 provider 成功回傳才計點。</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
|
||||||
|
|
||||||
<section className="hb-radar-intro">
|
<section className="hb-radar-intro">
|
||||||
<div>
|
<div>
|
||||||
<strong>每張商機只要做一個決定</strong>
|
<strong>巡邏到痛點就看這裡</strong>
|
||||||
<p>值得繼續接觸就加入名單;已自行看完就標示已處理;不是你的客戶就標示不適合。這些整理動作都不扣點。</p>
|
<p>先讀「為什麼推薦」,留下或丟掉即可。加入名單是可選的,不是看結果的必要步驟。</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="hb-radar-intro__actions" aria-label="商機結果導覽">
|
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">管理巡邏</Link>
|
|
||||||
</nav>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ol className="hb-inbox-decision-guide" aria-label="商機收件匣操作方式">
|
<section className="hb-radar-filter-panel" aria-label="商機結果">
|
||||||
<li><span>1</span><div><strong>先看需求</strong><small>閱讀原文與「為什麼推薦」。</small></div></li>
|
|
||||||
<li><span>2</span><div><strong>值得跟進</strong><small>加入名單,後續做備註與追蹤。</small></div></li>
|
|
||||||
<li><span>3</span><div><strong>不需要跟進</strong><small>只標示已處理,或選原因標示不適合。</small></div></li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<section className="hb-radar-filter-panel" aria-label="篩選商機收件匣">
|
|
||||||
<div className="hb-radar-filter-panel__head">
|
<div className="hb-radar-filter-panel__head">
|
||||||
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="商機工作狀態">
|
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="結果狀態">
|
||||||
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
|
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
|
||||||
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
|
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
|
||||||
setReviewState(value); setPage(1); writeParams({ review_state: value, page: "" });
|
setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
|
||||||
}}>
|
}}>
|
||||||
{value === "pending" ? "待決定" : value === "completed" ? "已處理" : "已移除"}
|
{value === "pending" ? "新找到" : value === "completed" ? "已看過" : "已丟掉"}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
<span>共 {total} 筆</span>
|
<span>共 {total} 筆</span>
|
||||||
</div>
|
</div>
|
||||||
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button> : <span>預設先顯示最值得跟進的結果</span>}
|
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button> : <span>預設先看今天剛巡到的結果</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="hb-radar-inbox-essential-filters">
|
<div className="hb-radar-inbox-essential-filters">
|
||||||
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
|
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
|
||||||
const value = e.target.value as OpportunityTimeScope;
|
const value = e.target.value as OpportunityTimeScope;
|
||||||
setTimeScope(value); setPage(1); writeParams({ time_scope: value, page: "" });
|
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
|
||||||
}}>
|
}}>
|
||||||
<option value="today">今天</option><option value="7d">近 7 天</option><option value="all">全部</option>
|
<option value="today">今天</option><option value="7d">近 7 天</option><option value="all">全部</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
|
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
|
||||||
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
|
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
|
||||||
}}>
|
}}>
|
||||||
<option value="recommended">最值得跟進</option><option value="newest">最新貼文</option><option value="oldest">最舊貼文</option><option value="product_fit">最符合產品</option><option value="demand_intent">需求最明確</option>
|
<option value="recommended">最對得上產品</option><option value="newest">最新貼文</option><option value="oldest">最舊貼文</option><option value="product_fit">最符合產品</option><option value="demand_intent">需求最明確</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
|
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
|
||||||
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `(${advancedFilterCount})` : ""}`}
|
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `(${advancedFilterCount})` : ""}`}
|
||||||
|
|
@ -230,14 +451,10 @@ export function RadarOpportunitiesPage() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
||||||
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>現在前往名單</Link> : null}</div> : null}
|
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>前往名單</Link> : null}</div> : null}
|
||||||
{loading ? <p className="hb-radar-section__hint" role="status">正在整理商機結果…</p> : null}
|
{loading ? <p className="hb-radar-section__hint" role="status">正在整理巡邏結果…</p> : null}
|
||||||
{!loading && !error && !list.length ? (
|
{!loading && !error && !list.length ? (
|
||||||
<EmptyState
|
<EmptyState title={empty.title} description={empty.description} action={empty.action} />
|
||||||
title={reviewState === "pending" ? "目前沒有待決定的商機" : reviewState === "completed" ? "目前沒有已處理的商機" : "目前沒有已移除的商機"}
|
|
||||||
description={filtered ? "清除篩選或改看其他時間範圍。" : reviewState === "pending" ? "等待下一輪巡邏,或先建立產品巡邏。" : "切換到「待決定」繼續處理商機。"}
|
|
||||||
action={filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button> : reviewState === "pending" ? <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">管理巡邏</Link> : undefined}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!error ? (
|
{!error ? (
|
||||||
|
|
@ -248,9 +465,9 @@ export function RadarOpportunitiesPage() {
|
||||||
busy={busyId === o.id}
|
busy={busyId === o.id}
|
||||||
onOpen={setSelected}
|
onOpen={setSelected}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")}
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
||||||
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已標示為不適合,可從「已移除」還原。")}
|
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已丟掉。可從「已丟掉」還原。")}
|
||||||
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原商機。")}
|
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原。")}
|
||||||
/>)}
|
/>)}
|
||||||
{total > PAGE_SIZE ? (
|
{total > PAGE_SIZE ? (
|
||||||
<div className="hb-radar-pager">
|
<div className="hb-radar-pager">
|
||||||
|
|
@ -266,7 +483,7 @@ export function RadarOpportunitiesPage() {
|
||||||
busy={busyId === selected.id}
|
busy={busyId === selected.id}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")}
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
||||||
/> : null}
|
/> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -175,29 +175,29 @@ describe("RadarWatchesPage", () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText(t("radar.watches.empty"));
|
await screen.findByText(t("radar.watches.empty"));
|
||||||
|
|
||||||
await createWatch("推薦室內設計\n找設計師");
|
await createWatch("室內設計\n找設計師");
|
||||||
await screen.findByText(t("radar.watches.created"));
|
await screen.findByText(t("radar.watches.created"));
|
||||||
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
||||||
expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy();
|
expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
|
||||||
await screen.findByText(t("radar.watches.paused"));
|
await screen.findByText(t("radar.watches.paused"));
|
||||||
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
|
||||||
expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy();
|
expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
|
||||||
await screen.findByText(t("radar.watches.resumed"));
|
await screen.findByText(t("radar.watches.resumed"));
|
||||||
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
|
||||||
await screen.findByText(t("radar.watches.archived"));
|
await screen.findByText(t("radar.watches.archived"));
|
||||||
const archived = row("推薦室內設計");
|
const archived = row("室內設計");
|
||||||
expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy();
|
expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy();
|
||||||
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
|
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
|
||||||
const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") });
|
const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") });
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
await screen.findByText(t("radar.watches.deletedArchived"));
|
await screen.findByText(t("radar.watches.deletedArchived"));
|
||||||
expect(screen.queryByText("推薦室內設計")).toBeNull();
|
expect(screen.queryByText("室內設計")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => {
|
it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => {
|
||||||
|
|
@ -205,7 +205,7 @@ describe("RadarWatchesPage", () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText(t("radar.watches.empty"));
|
await screen.findByText(t("radar.watches.empty"));
|
||||||
|
|
||||||
await createWatch("推薦室內設計");
|
await createWatch("室內設計");
|
||||||
await screen.findByText(t("radar.watches.created"));
|
await screen.findByText(t("radar.watches.created"));
|
||||||
expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy();
|
expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy();
|
||||||
|
|
||||||
|
|
@ -218,7 +218,7 @@ describe("RadarWatchesPage", () => {
|
||||||
|
|
||||||
it("關鍵字建議可逐條採用,include 進關鍵字、exclude 進排除詞", async () => {
|
it("關鍵字建議可逐條採用,include 進關鍵字、exclude 進排除詞", async () => {
|
||||||
backend.suggestions = [
|
backend.suggestions = [
|
||||||
{ term: "推薦室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
|
{ term: "室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
|
||||||
{ term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" },
|
{ term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" },
|
||||||
];
|
];
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
@ -233,7 +233,7 @@ describe("RadarWatchesPage", () => {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value,
|
(screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value,
|
||||||
).toBe("推薦室內設計");
|
).toBe("室內設計");
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
|
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
|
||||||
.value,
|
.value,
|
||||||
|
|
@ -259,4 +259,13 @@ describe("RadarWatchesPage", () => {
|
||||||
expect(banner.textContent).toContain("service profile required before suggesting watch terms");
|
expect(banner.textContent).toContain("service profile required before suggesting watch terms");
|
||||||
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
|
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("長句關鍵字儲存時收成可搜短詞", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText(t("radar.watches.empty"));
|
||||||
|
await createWatch("晚上睡覺容易口乾舌燥怎麼辦");
|
||||||
|
await screen.findByText(t("radar.watches.created"));
|
||||||
|
expect(screen.getByText("晚上睡覺")).toBeTruthy();
|
||||||
|
expect(screen.getByText("口乾舌燥")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import type { Brand, BrandProduct, RadarSweep, RadarWatch, RadarWatchStatus, Wat
|
||||||
import type { DemandMap } from "../domain/types";
|
import type { DemandMap } from "../domain/types";
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
import { useI18n } from "../i18n/I18nContext";
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
import { useFormatApiError } from "../lib/apiErrors";
|
||||||
import { isThreadsSearchable } from "../lib/threadsTerm";
|
import { expandIncludeTerms, isThreadsSearchable } from "../lib/threadsTerm";
|
||||||
|
import { buildQueryPlan } from "../components/radar/QueryPlanPreview";
|
||||||
import { formatLocalDateTime } from "../lib/time";
|
import { formatLocalDateTime } from "../lib/time";
|
||||||
import { ProductWatchForm } from "../components/radar/ProductWatchForm";
|
import { ProductWatchForm } from "../components/radar/ProductWatchForm";
|
||||||
import { DemandMapEditor, type DemandMapPatch } from "../components/radar/DemandMapEditor";
|
import { DemandMapEditor, type DemandMapPatch } from "../components/radar/DemandMapEditor";
|
||||||
|
|
@ -96,6 +97,16 @@ export function RadarWatchesPage() {
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [draft.productId, productContextAvailable, repos.radar, formatError]);
|
}, [draft.productId, productContextAvailable, repos.radar, formatError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!formOpen || draft.id || !demandMap || demandMap.state !== "ready") return;
|
||||||
|
setDraft((d) => {
|
||||||
|
if (splitTerms(d.terms).length > 0) return d;
|
||||||
|
const seeded = expandIncludeTerms(buildQueryPlan(demandMap).map((group) => group.terms.join(" ")));
|
||||||
|
if (!seeded.length) return d;
|
||||||
|
return { ...d, terms: seeded.join("\n") };
|
||||||
|
});
|
||||||
|
}, [demandMap, formOpen, draft.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scout) return;
|
if (!scout) return;
|
||||||
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
|
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
|
||||||
|
|
@ -167,9 +178,14 @@ export function RadarWatchesPage() {
|
||||||
function adopt(s: WatchTermSuggestion) {
|
function adopt(s: WatchTermSuggestion) {
|
||||||
setDraft((d) => {
|
setDraft((d) => {
|
||||||
const key = s.usage === "exclude" ? "excludeTerms" : "terms";
|
const key = s.usage === "exclude" ? "excludeTerms" : "terms";
|
||||||
|
const incoming = s.usage === "exclude" ? [s.term] : expandIncludeTerms([s.term]);
|
||||||
const current = splitTerms(d[key]);
|
const current = splitTerms(d[key]);
|
||||||
if (current.some((x) => x.toLowerCase() === s.term.toLowerCase())) return d;
|
const next = [...current];
|
||||||
return { ...d, [key]: [...current, s.term].join("\n") };
|
for (const term of incoming) {
|
||||||
|
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
|
||||||
|
}
|
||||||
|
if (next.length === current.length) return d;
|
||||||
|
return { ...d, [key]: next.join("\n") };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,6 +193,19 @@ export function RadarWatchesPage() {
|
||||||
for (const s of list) adopt(s);
|
for (const s of list) adopt(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adoptQueries(queries: string[]) {
|
||||||
|
setDraft((d) => {
|
||||||
|
const incoming = expandIncludeTerms(queries);
|
||||||
|
const current = splitTerms(d.terms);
|
||||||
|
const next = [...current];
|
||||||
|
for (const term of incoming) {
|
||||||
|
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
|
||||||
|
}
|
||||||
|
if (next.length === current.length) return d;
|
||||||
|
return { ...d, terms: next.join("\n") };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
|
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
|
||||||
async function run(
|
async function run(
|
||||||
key: string,
|
key: string,
|
||||||
|
|
@ -201,8 +230,12 @@ export function RadarWatchesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const terms = splitTerms(draft.terms);
|
const terms = expandIncludeTerms(splitTerms(draft.terms));
|
||||||
const excludeTerms = splitTerms(draft.excludeTerms);
|
const excludeTerms = splitTerms(draft.excludeTerms);
|
||||||
|
if (!terms.length) {
|
||||||
|
setError(t("radar.watches.threadsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
|
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
|
||||||
setError("請先選擇品牌與產品,產品型雷達才能啟用。" );
|
setError("請先選擇品牌與產品,產品型雷達才能啟用。" );
|
||||||
return;
|
return;
|
||||||
|
|
@ -308,7 +341,7 @@ export function RadarWatchesPage() {
|
||||||
{justTriggeredFirstSweep ? (
|
{justTriggeredFirstSweep ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Link to="/app/radar/today">{t("today.radar.open")}</Link>
|
<Link to="/app/radar">{t("today.radar.open")}</Link>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -326,7 +359,7 @@ export function RadarWatchesPage() {
|
||||||
<strong>{t("radar.watches.scheduleTitle")}</strong>
|
<strong>{t("radar.watches.scheduleTitle")}</strong>
|
||||||
<p>{t("radar.watches.scheduleHint")}</p>
|
<p>{t("radar.watches.scheduleHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities?review_state=pending&time_scope=today">
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar">
|
||||||
{t("radar.watches.openToday")}
|
{t("radar.watches.openToday")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -366,7 +399,7 @@ export function RadarWatchesPage() {
|
||||||
brandId={draft.brandId}
|
brandId={draft.brandId}
|
||||||
productId={draft.productId}
|
productId={draft.productId}
|
||||||
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
|
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
|
||||||
onProductChange={(productId) => setDraft((d) => ({ ...d, productId }))}
|
onProductChange={(productId) => setDraft((d) => ({ ...d, productId, terms: d.id ? d.terms : "" }))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{productContextAvailable && draft.productId ? (() => {
|
{productContextAvailable && draft.productId ? (() => {
|
||||||
|
|
@ -449,9 +482,10 @@ export function RadarWatchesPage() {
|
||||||
<WatchSuggestPanel
|
<WatchSuggestPanel
|
||||||
onAdopt={adopt}
|
onAdopt={adopt}
|
||||||
onAdoptAll={adoptAll}
|
onAdoptAll={adoptAll}
|
||||||
|
onAdoptQueries={adoptQueries}
|
||||||
adopted={[...splitTerms(draft.terms), ...splitTerms(draft.excludeTerms)]}
|
adopted={[...splitTerms(draft.terms), ...splitTerms(draft.excludeTerms)]}
|
||||||
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
|
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
|
||||||
demandMap={demandMap ?? undefined}
|
demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="hb-radar-actions">
|
<div className="hb-radar-actions">
|
||||||
|
|
@ -472,12 +506,12 @@ export function RadarWatchesPage() {
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-muted">{t("common.loading")}</p>
|
<p className="text-muted">{t("common.loading")}</p>
|
||||||
) : error ? null : watches.length === 0 ? (
|
) : watches.length === 0 && !error ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={statusFilter ? t("radar.watches.emptyFiltered") : t("radar.watches.empty")}
|
title={statusFilter ? t("radar.watches.emptyFiltered") : t("radar.watches.empty")}
|
||||||
description={statusFilter ? undefined : t("radar.watches.emptyHint")}
|
description={statusFilter ? undefined : t("radar.watches.emptyHint")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : watches.length === 0 ? null : (
|
||||||
<>
|
<>
|
||||||
<div className="hb-radar-watch-list">
|
<div className="hb-radar-watch-list">
|
||||||
{watches.map((w) => (
|
{watches.map((w) => (
|
||||||
|
|
|
||||||
|
|
@ -356,7 +356,7 @@ export function TodayPage() {
|
||||||
{
|
{
|
||||||
key: "opportunity",
|
key: "opportunity",
|
||||||
done: onboardingOpportunityDone,
|
done: onboardingOpportunityDone,
|
||||||
to: "/app/radar/today",
|
to: "/app/radar",
|
||||||
label: t("today.onboarding.step.opportunity"),
|
label: t("today.onboarding.step.opportunity"),
|
||||||
hint: t("today.onboarding.step.opportunityHint"),
|
hint: t("today.onboarding.step.opportunityHint"),
|
||||||
},
|
},
|
||||||
|
|
@ -408,7 +408,7 @@ export function TodayPage() {
|
||||||
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
|
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
|
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
|
||||||
{t("today.radar.open")}
|
{t("today.radar.open")}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,52 @@
|
||||||
color: var(--hb-muted);
|
color: var(--hb-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--hb-space-4);
|
||||||
|
margin-bottom: var(--hb-space-5);
|
||||||
|
padding: var(--hb-space-4);
|
||||||
|
border: 1px solid var(--hb-line);
|
||||||
|
border-radius: var(--hb-radius-lg);
|
||||||
|
background: var(--hb-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol__status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--hb-space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol__status p {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol__status span {
|
||||||
|
font-size: var(--hb-text-sm);
|
||||||
|
color: var(--hb-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--hb-space-2);
|
||||||
|
max-width: 25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-radar-patrol__actions small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--hb-muted);
|
||||||
|
font-size: var(--hb-text-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/** 空狀態一律要說「為什麼空」與「下一步做什麼」,不是只寫「沒有資料」。 */
|
/** 空狀態一律要說「為什麼空」與「下一步做什麼」,不是只寫「沒有資料」。 */
|
||||||
.hb-radar-empty {
|
.hb-radar-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue