feat/rebone #3

Merged
daniel.w merged 8 commits from feat/rebone into main 2026-07-06 06:13:18 +00:00
296 changed files with 23086 additions and 2381 deletions
Showing only changes of commit ce7d01c263 - Show all commits

View File

@ -14,6 +14,11 @@ test:
go test ./...
run:
mkdir -p bin
go build -o bin/gateway gateway.go
./bin/gateway -f etc/gateway.yaml
run-dev:
go run gateway.go -f etc/gateway.yaml
web-dev:

View File

@ -5,27 +5,27 @@ Timeout: 120000
# 本機開發 worker 設定go worker。搭配 `make dev-infra` 的 Mongo/Redis。
Mongo:
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
Database: haixun
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DB}
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
Password: change-me-redis-pass
Addr: ${HAIXUN_REDIS_ADDR}
Password: ${HAIXUN_REDIS_PASSWORD}
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
Secrets:
EncryptionKey: ""
EncryptionKey: ${HAIXUN_SECRETS_KEY}
InternalWorker:
Secret: haixun-dev-worker-secret
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true

View File

@ -46,7 +46,7 @@ ThreadsOAuth:
AppID: "${THREADS_APP_ID}"
AppSecret: "${THREADS_APP_SECRET}"
RedirectURI: "${THREADS_REDIRECT_URI}"
# OAuth 完成後瀏覽器導回(Dev Console 預設 http://127.0.0.1:5173
# OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net
SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job

View File

@ -31,6 +31,7 @@ func main() {
defer sc.Close(context.Background())
handler.RegisterHandlers(server, sc)
handler.RegisterExtraHandlers(server, sc)
fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
server.Start()

View File

@ -36,6 +36,7 @@ type (
ID string `json:"id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
PlacementURL string `json:"placement_url,omitempty"`
MatchTags []string `json:"match_tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
@ -65,12 +66,14 @@ type (
CreateBrandProductReq {
Label string `json:"label" validate:"required"`
ProductContext string `json:"product_context" validate:"required"`
PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
UpdateBrandProductReq {
Label *string `json:"label,optional"`
ProductContext *string `json:"product_context,optional"`
PlacementURL string `json:"placement_url,optional"`
MatchTags []string `json:"match_tags,optional"`
}
@ -183,6 +186,7 @@ type (
NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"`
TestPatrol bool `json:"test_patrol,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
@ -219,6 +223,7 @@ type (
PublishedPermalink string `json:"published_permalink,omitempty"`
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
RecencyDays int `json:"recency_days,omitempty"`
Replies []ScanReplyData `json:"replies,omitempty"`
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
CreateAt int64 `json:"create_at"`
@ -235,6 +240,7 @@ type (
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
Regenerate bool `json:"regenerate,optional"`
}
OutreachDraftItemData {
@ -258,6 +264,11 @@ type (
Confirm bool `json:"confirm"`
}
UpdateOutreachDraftReq {
DraftIndex int `json:"draft_index"`
Text string `json:"text" validate:"required"`
}
PublishOutreachDraftData {
ScanPostID string `json:"scan_post_id"`
ReplyID string `json:"reply_id"`
@ -355,6 +366,12 @@ type (
PublishOutreachDraftReq
}
UpdateOutreachDraftHandlerReq {
BrandPath
DraftID string `path:"draftId" validate:"required"`
UpdateOutreachDraftReq
}
PatchScanPostOutreachHandlerReq {
BrandPath
PostID string `path:"postId"`
@ -428,6 +445,9 @@ service gateway {
@handler publishOutreachDraft
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler updateOutreachDraft
patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
@handler patchScanPostOutreach
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)

View File

@ -39,6 +39,7 @@ type (
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
DevModeEnabled bool `json:"dev_mode_enabled"`
}
UpdateMemberPlacementSettingsReq {
@ -49,6 +50,7 @@ type (
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
}
MemberCapabilitiesData {

View File

@ -115,6 +115,7 @@ type (
PersonaID string `json:"persona_id"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
FormulaID string `json:"formula_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
@ -229,6 +230,65 @@ type (
DraftID string `json:"draft_id"`
Message string `json:"message"`
}
ListPersonaContentInboxReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
Status string `form:"status,optional"`
RangeStart int64 `form:"rangeStart,optional"`
RangeEnd int64 `form:"rangeEnd,optional"`
}
ListPersonaContentInboxHandlerReq {
PersonaPath
ListPersonaContentInboxReq
}
ContentInboxItemData {
CopyDraftData
ScheduledAt int64 `json:"scheduled_at,omitempty"`
Lifecycle string `json:"lifecycle"`
GroupDate int64 `json:"group_date"`
FormulaLabel string `json:"formula_label,omitempty"`
}
ListPersonaContentInboxData {
Pagination PaginationData `json:"pagination"`
List []ContentInboxItemData `json:"list"`
}
SchedulePersonaCopyDraftReq {
AccountID string `json:"account_id" validate:"required"`
ScheduledAt int64 `json:"scheduled_at,optional"`
}
SchedulePersonaCopyDraftHandlerReq {
CopyDraftPath
SchedulePersonaCopyDraftReq
}
ContentFormulaPath {
ID string `path:"id" validate:"required"`
FormulaID string `path:"formulaId" validate:"required"`
}
GenerateFromContentFormulaReq {
AccountID string `json:"account_id" validate:"required"`
Topic string `json:"topic" validate:"required"`
Brief string `json:"brief,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
DraftCount int `json:"draft_count,optional"`
}
GenerateFromContentFormulaHandlerReq {
ContentFormulaPath
GenerateFromContentFormulaReq
}
GenerateFromContentFormulaData {
List []CopyDraftData `json:"list"`
Message string `json:"message"`
}
)
@server(
@ -266,6 +326,9 @@ service gateway {
@handler listPersonaCopyDrafts
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
@handler listPersonaContentInbox
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@ -278,6 +341,12 @@ service gateway {
@handler schedulePersonaDrafts
post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
@handler schedulePersonaCopyDraft
post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData)
@handler generateFromContentFormula
post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
@handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)

View File

@ -83,6 +83,12 @@ type (
PublishOutreachDraftReq
}
UpdatePlacementTopicOutreachDraftHandlerReq {
PlacementTopicPath
DraftID string `path:"draftId" validate:"required"`
UpdateOutreachDraftReq
}
PatchPlacementTopicScanPostOutreachHandlerReq {
PlacementTopicPath
PostID string `path:"postId"`
@ -116,6 +122,25 @@ type (
PlacementTopicPath
UpsertBrandScanScheduleReq
}
StartPlacementTopicOutreachDraftJobReq {
ScanPostID string `json:"scan_post_id,optional"`
ScanPostIDs []string `json:"scan_post_ids,optional"`
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
Regenerate bool `json:"regenerate,optional"`
}
StartPlacementTopicOutreachDraftJobHandlerReq {
PlacementTopicPath
StartPlacementTopicOutreachDraftJobReq
}
StartPlacementTopicOutreachDraftJobsData {
Jobs []StartBrandScanJobData `json:"jobs"`
Message string `json:"message"`
}
)
@server(
@ -159,9 +184,15 @@ service gateway {
@handler generatePlacementTopicOutreachDrafts
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
@handler startPlacementTopicOutreachDraftJob
post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
@handler publishPlacementTopicOutreachDraft
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler updatePlacementTopicOutreachDraft
patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
@handler patchPlacementTopicScanPostOutreach
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)

View File

@ -206,6 +206,9 @@ type (
ReplyID string `json:"reply_id,optional"`
ReplyToID string `json:"reply_to_id,optional"`
Text string `json:"text,optional"`
Permalink string `json:"permalink,optional"`
HintText string `json:"hint_text,optional"`
SkipPrime bool `json:"skip_prime,optional"`
LocationQuery string `json:"location_query,optional"`
Limit int `json:"limit,optional"`
Hide bool `json:"hide,optional"`
@ -257,6 +260,11 @@ type (
Text string `json:"text,optional"`
Permalink string `json:"permalink,optional"`
Timestamp string `json:"timestamp,optional"`
MediaType string `json:"media_type,optional"`
MediaURL string `json:"media_url,optional"`
ThumbnailURL string `json:"thumbnail_url,optional"`
TopicTag string `json:"topic_tag,optional"`
Shortcode string `json:"shortcode,optional"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count,optional"`
@ -547,6 +555,145 @@ type (
TotalReplies7d int `json:"total_replies_7d"`
}
GenerateOwnPostReplyDraftReq {
MediaID string `json:"media_id"`
PersonaID string `json:"persona_id,optional"`
ReplyToID string `json:"reply_to_id,optional"`
PostText string `json:"post_text,optional"`
ReplyText string `json:"reply_text,optional"`
ThreadPostText string `json:"thread_post_text,optional"`
ParentReplyText string `json:"parent_reply_text,optional"`
}
GenerateOwnPostReplyDraftHandlerReq {
ThreadsAccountPath
GenerateOwnPostReplyDraftReq
}
GenerateOwnPostReplyDraftData {
Text string `json:"text"`
}
MentionInboxItemData {
MediaID string `json:"media_id"`
AuthorUsername string `json:"author_username,omitempty"`
Text string `json:"text,omitempty"`
Permalink string `json:"permalink,omitempty"`
Shortcode string `json:"shortcode,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
MediaType string `json:"media_type,omitempty"`
IsReply bool `json:"is_reply"`
IsQuotePost bool `json:"is_quote_post"`
HasReplies bool `json:"has_replies"`
RootPostID string `json:"root_post_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ThreadPostText string `json:"thread_post_text,omitempty"`
ParentReplyText string `json:"parent_reply_text,omitempty"`
ReplyReady bool `json:"reply_ready"`
ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
SyncedAt int64 `json:"synced_at"`
}
SyncMentionInboxReq {
Limit int `json:"limit,optional"`
MaxPages int `json:"max_pages,optional"`
}
SyncMentionInboxHandlerReq {
ThreadsAccountPath
SyncMentionInboxReq
}
SyncMentionInboxData {
Synced int `json:"synced"`
Ready int `json:"ready"`
Total int64 `json:"total"`
}
ListMentionInboxQuery {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
ListMentionInboxHandlerReq {
ThreadsAccountPath
ListMentionInboxQuery
}
ListMentionInboxData {
List []MentionInboxItemData `json:"list"`
Pagination PaginationData `json:"pagination"`
ReadyTotal int64 `json:"ready_total"`
NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
}
PublishMentionReplyReq {
Text string `json:"text"`
}
PublishMentionReplyPath {
ID string `path:"id" validate:"required"`
MediaID string `path:"media_id" validate:"required"`
}
PublishMentionReplyHandlerReq {
PublishMentionReplyPath
PublishMentionReplyReq
}
PublishMentionReplyData {
MediaID string `json:"media_id,omitempty"`
Permalink string `json:"permalink,omitempty"`
}
GenerateOwnPostFormulaReq {
MediaID string `json:"media_id"`
PersonaID string `json:"persona_id,optional"`
PostText string `json:"post_text,optional"`
TopicTag string `json:"topic_tag,optional"`
MediaType string `json:"media_type,optional"`
Timestamp string `json:"timestamp,optional"`
LikeCount int `json:"like_count,optional"`
ReplyCount int `json:"reply_count,optional"`
Views int `json:"views,optional"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Shares int `json:"shares,optional"`
Force bool `json:"force,optional"`
}
GenerateOwnPostFormulaHandlerReq {
ThreadsAccountPath
GenerateOwnPostFormulaReq
}
OwnPostFormulaReviewData {
MediaID string `json:"media_id"`
ReviewedAt int64 `json:"reviewed_at"`
FromCache bool `json:"from_cache,omitempty"`
Summary string `json:"summary"`
Wins []string `json:"wins"`
Improvements []string `json:"improvements"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips"`
Avoid []string `json:"avoid"`
}
GenerateOwnPostFormulaData {
OwnPostFormulaReviewData
}
ListOwnPostFormulasHandlerReq {
ThreadsAccountPath
}
ListOwnPostFormulasData {
List []OwnPostFormulaReviewData `json:"list"`
}
ThreadsDiagnosticsData {
AccountID string `json:"account_id"`
CheckedAt int64 `json:"checked_at"`
@ -556,6 +703,149 @@ type (
Items []ThreadsAPISmokeTestItem `json:"items"`
Message string `json:"message"`
}
ContentFormulaSourceMetricsData {
LikeCount int `json:"like_count,omitempty"`
ReplyCount int `json:"reply_count,omitempty"`
Views int `json:"views,omitempty"`
RepostCount int `json:"repost_count,omitempty"`
QuoteCount int `json:"quote_count,omitempty"`
Shares int `json:"shares,omitempty"`
}
ContentFormulaData {
ID string `json:"id"`
AccountID string `json:"account_id"`
Label string `json:"label"`
SourceType string `json:"source_type"`
SourceRef string `json:"source_ref,omitempty"`
SourcePostText string `json:"source_post_text,omitempty"`
SourceAuthor string `json:"source_author,omitempty"`
SourcePermalink string `json:"source_permalink,omitempty"`
SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
Summary string `json:"summary"`
Wins []string `json:"wins,omitempty"`
Improvements []string `json:"improvements,omitempty"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Tags []string `json:"tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListContentFormulasReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
SourceType string `form:"source_type,optional"`
Tag string `form:"tag,optional"`
}
ListContentFormulasHandlerReq {
ThreadsAccountPath
ListContentFormulasReq
}
ListContentFormulasData {
Pagination PaginationData `json:"pagination"`
List []ContentFormulaData `json:"list"`
}
ContentFormulaItemPath {
ID string `path:"id" validate:"required"`
FormulaID string `path:"formulaId" validate:"required"`
}
GetContentFormulaHandlerReq {
ContentFormulaItemPath
}
AnalyzeContentFormulaReq {
SourceType string `json:"source_type" validate:"required"`
PersonaID string `json:"persona_id,optional"`
PostText string `json:"post_text,optional"`
MediaID string `json:"media_id,optional"`
ScanPostID string `json:"scan_post_id,optional"`
Keyword string `json:"keyword,optional"`
SaveLabel string `json:"save_label,optional"`
AuthorName string `json:"author_name,optional"`
Permalink string `json:"permalink,optional"`
LikeCount int `json:"like_count,optional"`
ReplyCount int `json:"reply_count,optional"`
Views int `json:"views,optional"`
RepostCount int `json:"repost_count,optional"`
QuoteCount int `json:"quote_count,optional"`
Shares int `json:"shares,optional"`
ForceRefresh bool `json:"force_refresh,optional"`
}
AnalyzeContentFormulaHandlerReq {
ThreadsAccountPath
AnalyzeContentFormulaReq
}
AnalyzeContentFormulaData {
Formula ContentFormulaData `json:"formula"`
Message string `json:"message"`
}
SearchContentFormulaPostsReq {
Keyword string `json:"keyword" validate:"required"`
SearchType string `json:"search_type,optional"`
Limit int `json:"limit,optional"`
}
SearchContentFormulaPostsHandlerReq {
ThreadsAccountPath
SearchContentFormulaPostsReq
}
ContentFormulaSearchPostData {
Text string `json:"text"`
Author string `json:"author"`
Permalink string `json:"permalink,omitempty"`
MediaID string `json:"media_id,omitempty"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
EngagementScore int `json:"engagement_score"`
}
SearchContentFormulaPostsData {
List []ContentFormulaSearchPostData `json:"list"`
Message string `json:"message"`
}
PatchContentFormulaReq {
Label *string `json:"label,optional"`
Formula *string `json:"formula,optional"`
PostTemplate *string `json:"post_template,optional"`
HookPattern *string `json:"hook_pattern,optional"`
Structure *string `json:"structure,optional"`
Tags []string `json:"tags,optional"`
}
PatchContentFormulaHandlerReq {
ContentFormulaItemPath
PatchContentFormulaReq
}
DeleteContentFormulaData {
FormulaID string `json:"formula_id"`
Message string `json:"message"`
}
ImportOwnPostFormulaReq {
MediaID string `json:"media_id" validate:"required"`
Label string `json:"label,optional"`
}
ImportOwnPostFormulaHandlerReq {
ThreadsAccountPath
ImportOwnPostFormulaReq
}
)
@server(
@ -680,8 +970,47 @@ service gateway {
@handler getThreadsDiagnostics
get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData)
@handler generateOwnPostReplyDraft
post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData)
@handler syncMentionInbox
post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData)
@handler listMentionInbox
get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData)
@handler publishMentionReply
post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData)
@handler generateOwnPostFormula
post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData)
@handler listOwnPostFormulas
get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData)
@handler getPublishDashboardSummary
get /publish-dashboard-summary returns (PublishDashboardSummaryData)
@handler listContentFormulas
get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData)
@handler getContentFormula
get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData)
@handler analyzeContentFormula
post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData)
@handler searchContentFormulaPosts
post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData)
@handler patchContentFormula
patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData)
@handler deleteContentFormula
delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData)
@handler importOwnPostToContentFormula
post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData)
}
@server(

View File

@ -0,0 +1,30 @@
package brand
import (
"net/http"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateOutreachDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := brand.NewUpdateOutreachDraftLogic(r.Context(), svcCtx)
data, err := l.UpdateOutreachDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateFromContentFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewGenerateFromContentFormulaLogic(r.Context(), svcCtx)
data, err := l.GenerateFromContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListPersonaContentInboxHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewListPersonaContentInboxLogic(r.Context(), svcCtx)
data, err := l.ListPersonaContentInbox(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package persona
import (
"net/http"
"haixun-backend/internal/logic/persona"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SchedulePersonaCopyDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := persona.NewSchedulePersonaCopyDraftLogic(r.Context(), svcCtx)
data, err := l.SchedulePersonaCopyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package placement_topic
import (
"net/http"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func StartPlacementTopicOutreachDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPlacementTopicOutreachDraftJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := placement_topic.NewStartPlacementTopicOutreachDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartPlacementTopicOutreachDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,30 @@
package placement_topic
import (
"net/http"
"haixun-backend/internal/logic/placement_topic"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdatePlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdatePlacementTopicOutreachDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := placement_topic.NewUpdatePlacementTopicOutreachDraftLogic(r.Context(), svcCtx)
data, err := l.UpdatePlacementTopicOutreachDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,24 @@
package handler
import (
"net/http"
"haixun-backend/internal/handler/static"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
// RegisterExtraHandlers mounts routes that are not generated by goctl.
func RegisterExtraHandlers(server *rest.Server, _ *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/downloads/haixun-threads-sync.zip",
Handler: static.ExtensionDownloadHandler(),
},
},
rest.WithPrefix("/api/v1"),
)
}

View File

@ -162,6 +162,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/outreach-drafts/:draftId",
Handler: brand.UpdateOutreachDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@ -341,6 +346,65 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/personas"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -425,65 +489,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -577,6 +582,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id",
Handler: persona.DeletePersonaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/:formulaId/generate",
Handler: persona.GenerateFromContentFormulaHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-inbox",
Handler: persona.ListPersonaContentInboxHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/copy-drafts",
@ -597,6 +612,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/:draftId/publish",
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/:draftId/schedule",
Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/generate",
@ -696,6 +716,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes",
Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-draft-jobs",
Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/outreach-drafts/:draftId",
Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate",
@ -849,11 +879,76 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/connection",
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-formulas",
Handler: threads_account.ListContentFormulasHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.GetContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.PatchContentFormulaHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:id/content-formulas/:formulaId",
Handler: threads_account.DeleteContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/analyze",
Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/import-own-post",
Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/content-formulas/search-posts",
Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/diagnostics",
Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/mention-inbox",
Handler: threads_account.ListMentionInboxHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/mention-inbox/:media_id/reply",
Handler: threads_account.PublishMentionReplyHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/mention-inbox/sync",
Handler: threads_account.SyncMentionInboxHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/own-post-formula",
Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/own-post-formulas",
Handler: threads_account.ListOwnPostFormulasHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/:id/own-post-reply-draft",
Handler: threads_account.GenerateOwnPostReplyDraftHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:id/post-performance",

View File

@ -0,0 +1,42 @@
package static
import (
"net/http"
"os"
"path/filepath"
)
func ExtensionDownloadHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path, ok := resolveExtensionZipPath()
if !ok {
http.Error(w, "extension package not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`)
http.ServeFile(w, r, path)
}
}
func resolveExtensionZipPath() (string, bool) {
candidates := []string{
filepath.Join("web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("..", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join("extension", "haixun-threads-sync.zip"),
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join(wd, "backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
filepath.Join(wd, "..", "extension", "haixun-threads-sync.zip"),
)
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 {
return candidate, true
}
}
return "", false
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func AnalyzeContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AnalyzeContentFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewAnalyzeContentFormulaLogic(r.Context(), svcCtx)
data, err := l.AnalyzeContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetContentFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewDeleteContentFormulaLogic(r.Context(), svcCtx)
data, err := l.DeleteContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func GenerateOwnPostFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateOwnPostFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewGenerateOwnPostFormulaLogic(r.Context(), svcCtx)
data, err := l.GenerateOwnPostFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func GenerateOwnPostReplyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateOwnPostReplyDraftHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewGenerateOwnPostReplyDraftLogic(r.Context(), svcCtx)
data, err := l.GenerateOwnPostReplyDraft(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetContentFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewGetContentFormulaLogic(r.Context(), svcCtx)
data, err := l.GetContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ImportOwnPostToContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ImportOwnPostFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewImportOwnPostToContentFormulaLogic(r.Context(), svcCtx)
data, err := l.ImportOwnPostToContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListContentFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListContentFormulasHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewListContentFormulasLogic(r.Context(), svcCtx)
data, err := l.ListContentFormulas(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListMentionInboxHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewListMentionInboxLogic(r.Context(), svcCtx)
data, err := l.ListMentionInbox(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListOwnPostFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListOwnPostFormulasHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewListOwnPostFormulasLogic(r.Context(), svcCtx)
data, err := l.ListOwnPostFormulas(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PatchContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PatchContentFormulaHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewPatchContentFormulaLogic(r.Context(), svcCtx)
data, err := l.PatchContentFormula(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func PublishMentionReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PublishMentionReplyHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewPublishMentionReplyLogic(r.Context(), svcCtx)
data, err := l.PublishMentionReply(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package threads_account
import (
"net/http"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SearchContentFormulaPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SearchContentFormulaPostsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewSearchContentFormulaPostsLogic(r.Context(), svcCtx)
data, err := l.SearchContentFormulaPosts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func SyncMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SyncMentionInboxHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewSyncMentionInboxLogic(r.Context(), svcCtx)
data, err := l.SyncMentionInbox(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,3 +1,6 @@
// Package embedding provides an optional OpenAI embeddings client.
// It is intentionally not wired into patrol/graph flows; relevance uses
// offline lexical intent scoring in knowledge/semantic_fit.go instead.
package embedding
import (

View File

@ -0,0 +1,165 @@
package formula
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
libownpost "haixun-backend/internal/library/ownpost"
domai "haixun-backend/internal/model/ai/domain/usecase"
)
var pastedFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
type AnalyzePastedInput struct {
PostText string
AuthorName string
PersonaBlock string
LikeCount int
ReplyCount int
}
type PastedAnalysis struct {
Summary string `json:"summary"`
Wins []string `json:"wins"`
Improvements []string `json:"improvements"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips"`
Avoid []string `json:"avoid"`
}
func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string) {
personaBlock := strings.TrimSpace(in.PersonaBlock)
if personaBlock == "" {
personaBlock = "(未指定人設,請依貼文本身語氣分析)"
}
postText := strings.TrimSpace(in.PostText)
if postText == "" {
postText = "(貼文內容未提供)"
}
author := strings.TrimSpace(in.AuthorName)
if author == "" {
author = "匿名"
}
system = strings.TrimSpace(`
你是 Threads 爆款結構分析師任務是拆解外部貼文為什麼會紅怎麼用結構仿寫不要建議抄襲原文
規則
- 用台灣繁體中文
- formula整理成步驟化公式Hook情境轉折觀點收尾/互動
- post_template formula 產出可複製的發文骨架 [括號] 標示可替換欄位
- 只輸出一個 JSON 物件不要 markdown欄位
summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
- wins / improvements / replication_tips / avoid 25 點字串陣列`)
var b strings.Builder
b.WriteString("【人設脈絡】\n")
b.WriteString(personaBlock)
b.WriteString("\n\n【貼文】\n作者")
b.WriteString(author)
if in.LikeCount > 0 || in.ReplyCount > 0 {
b.WriteString(fmt.Sprintf(" · %d 讚 · %d 留言", in.LikeCount, in.ReplyCount))
}
b.WriteString("\n")
b.WriteString(postText)
b.WriteString("\n\n請分析爆款結構並輸出 JSON。")
user = b.String()
return system, user
}
func AnalyzePasted(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in AnalyzePastedInput) (*PastedAnalysis, error) {
system, user := BuildAnalyzePastedPrompt(in)
temp := 0.65
req.System = system
req.Messages = []domai.Message{{Role: "user", Content: user}}
req.Temperature = &temp
out, err := ai.GenerateText(ctx, req)
if err != nil {
return nil, err
}
return ParsePastedAnalysis(out.Text)
}
func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) {
payload, err := extractPastedJSON(raw)
if err != nil {
return nil, err
}
var review PastedAnalysis
if err := json.Unmarshal(payload, &review); err != nil {
return nil, fmt.Errorf("parse pasted formula json: %w", err)
}
review.Summary = strings.TrimSpace(review.Summary)
review.Formula = strings.TrimSpace(review.Formula)
review.PostTemplate = strings.TrimSpace(review.PostTemplate)
review.HookPattern = strings.TrimSpace(review.HookPattern)
review.Structure = strings.TrimSpace(review.Structure)
if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
return nil, fmt.Errorf("AI 未回傳有效公式內容")
}
return &review, nil
}
func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
if review == nil {
return nil
}
return &PastedAnalysis{
Summary: review.Summary,
Wins: review.Wins,
Improvements: review.Improvements,
Formula: review.Formula,
PostTemplate: review.PostTemplate,
HookPattern: review.HookPattern,
Structure: review.Structure,
ReplicationTips: review.ReplicationTips,
Avoid: review.Avoid,
}
}
func FromViralAnalysis(hook, structure, emotion, strategy string, takeaways []string) *PastedAnalysis {
summary := strings.TrimSpace(emotion)
if summary == "" {
summary = "爆款結構分析"
}
formula := strings.TrimSpace(strategy)
if structure != "" {
if formula != "" {
formula = structure + " → " + formula
} else {
formula = structure
}
}
template := ""
if hook != "" && structure != "" {
template = "[" + hook + "] → [" + structure + "] → [你的觀點] → [互動收尾]"
}
return &PastedAnalysis{
Summary: summary,
Wins: takeaways,
Formula: formula,
PostTemplate: template,
HookPattern: hook,
Structure: structure,
ReplicationTips: takeaways,
}
}
func extractPastedJSON(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := pastedFenceRE.FindStringSubmatch(raw); len(m) == 2 {
raw = strings.TrimSpace(m[1])
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("formula output missing json object")
}
return []byte(raw[start : end+1]), nil
}

View File

@ -0,0 +1,110 @@
package formula
import (
"context"
"encoding/json"
"fmt"
"strings"
domai "haixun-backend/internal/model/ai/domain/usecase"
cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
)
type GenerateInput struct {
Topic string
Brief string
PersonaBlock string
ResearchNotes string
Formula cfdom.FormulaSummary
}
type GeneratedDraft struct {
Text string `json:"text"`
Hook string `json:"hook"`
Angle string `json:"angle"`
Rationale string `json:"rationale"`
StructureNotes string `json:"structure_notes"`
}
func BuildGenerateSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 文案寫手爆款公式人設為指定主題寫一篇新貼文
規則
- 用台灣繁體中文符合人設語氣
- 套用公式結構但內容必須圍繞使用者主題不可抄襲範例貼文
- 只回傳 JSONtext, hook, angle, rationale, structure_notes繁體中文`)
}
func BuildGenerateUserPrompt(in GenerateInput) string {
var b strings.Builder
b.WriteString("【主題】\n")
b.WriteString(strings.TrimSpace(in.Topic))
if brief := strings.TrimSpace(in.Brief); brief != "" {
b.WriteString("\n補充")
b.WriteString(brief)
}
b.WriteString("\n\n【人設】\n")
b.WriteString(strings.TrimSpace(in.PersonaBlock))
b.WriteString("\n\n【公式】\n")
b.WriteString(strings.TrimSpace(in.Formula.Formula))
if tpl := strings.TrimSpace(in.Formula.PostTemplate); tpl != "" {
b.WriteString("\n\n【發文模板】\n")
b.WriteString(tpl)
}
if hook := strings.TrimSpace(in.Formula.HookPattern); hook != "" {
b.WriteString("\n\n【Hook 模式】\n")
b.WriteString(hook)
}
if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
b.WriteString("\n\n【結構節奏】\n")
b.WriteString(structure)
}
if len(in.Formula.ReplicationTips) > 0 {
b.WriteString("\n\n【複製技巧】\n")
b.WriteString(strings.Join(in.Formula.ReplicationTips, "\n"))
}
if len(in.Formula.Avoid) > 0 {
b.WriteString("\n\n【避免】\n")
b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
}
if notes := strings.TrimSpace(in.ResearchNotes); notes != "" {
b.WriteString("\n\n【周邊知識參考】\n")
b.WriteString(notes)
}
if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
b.WriteString("\n\n【參考原文僅學結構勿抄內容】\n")
b.WriteString(src)
}
b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。")
return b.String()
}
func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
temp := 0.75
req.System = BuildGenerateSystemPrompt()
req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
req.Temperature = &temp
out, err := ai.GenerateText(ctx, req)
if err != nil {
return nil, err
}
return ParseGeneratedDraft(out.Text)
}
func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
raw = strings.TrimSpace(raw)
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("generate output missing json object")
}
var draft GeneratedDraft
if err := json.Unmarshal([]byte(raw[start:end+1]), &draft); err != nil {
return nil, fmt.Errorf("parse generated draft: %w", err)
}
draft.Text = strings.TrimSpace(draft.Text)
if draft.Text == "" {
return nil, fmt.Errorf("generated draft missing text")
}
return &draft, nil
}

View File

@ -80,6 +80,32 @@ func TotalTagCandidates(nodes []Node) int {
return count
}
// PreserveNodeSelectionByLabel reapplies user scan selections after graph regen changes node IDs.
func PreserveNodeSelectionByLabel(nodes []Node, previous []Node) {
if len(nodes) == 0 || len(previous) == 0 {
return
}
selected := map[string]struct{}{}
for _, node := range previous {
if !node.SelectedForScan {
continue
}
label := strings.ToLower(strings.TrimSpace(node.Label))
if label != "" {
selected[label] = struct{}{}
}
}
if len(selected) == 0 {
return
}
for i := range nodes {
label := strings.ToLower(strings.TrimSpace(nodes[i].Label))
if _, ok := selected[label]; ok {
nodes[i].SelectedForScan = true
}
}
}
func NodeByID(nodes []Node, id string) (Node, bool) {
id = strings.TrimSpace(id)
for _, node := range nodes {

View File

@ -0,0 +1,110 @@
package knowledge
// RescoreGraphIntentFit recomputes node productFitScore using:
// 1. weighted intent similarity (product + research map)
// 2. graph proximity propagation from high-fit core nodes
// 3. tangential-topic penalty
func RescoreGraphIntentFit(graph *Graph, in PatrolTagInput) {
if graph == nil || len(graph.Nodes) == 0 {
return
}
profile := BuildIntentProfile(in)
intentScores := make([]int, len(graph.Nodes))
for i, node := range graph.Nodes {
intentScores[i] = ScoreIntentSimilarity(NodeIntentText(node), profile)
}
propagated := propagateIntentAlongGraph(graph.Nodes, graph.Edges, intentScores)
for i := range graph.Nodes {
node := &graph.Nodes[i]
semantic := intentScores[i]
if propagated[i] > semantic {
semantic = propagated[i]
}
llmScore := node.ProductFitScore
if llmScore <= 0 {
llmScore = defaultProductFit(node.NodeKind, node.Layer)
}
layerBlend := 0.5 + float64(minInt(node.Layer, 3))*0.08
blended := int(float64(semantic)*layerBlend + float64(llmScore)*(1.0-layerBlend))
if IsTangentialToIntent(NodeIntentText(*node), profile) {
blended = minInt(blended, 28)
}
if blended < 0 {
blended = 0
}
if blended > 100 {
blended = 100
}
node.ProductFitScore = blended
}
}
func propagateIntentAlongGraph(nodes []Node, edges []Edge, intentScores []int) []int {
if len(nodes) == 0 {
return nil
}
idIndex := map[string]int{}
for i, node := range nodes {
idIndex[node.ID] = i
}
adj := make([][]int, len(nodes))
addEdge := func(from, to string) {
fi, okFrom := idIndex[from]
ti, okTo := idIndex[to]
if !okFrom || !okTo || fi == ti {
return
}
adj[fi] = append(adj[fi], ti)
adj[ti] = append(adj[ti], fi)
}
for _, edge := range edges {
addEdge(edge.From, edge.To)
}
out := make([]int, len(nodes))
copy(out, intentScores)
visited := make([]bool, len(nodes))
type queueItem struct {
idx int
dist int
}
queue := make([]queueItem, 0, len(nodes))
for i, score := range intentScores {
if score >= 55 || nodes[i].Layer == 0 {
queue = append(queue, queueItem{idx: i, dist: 0})
visited[i] = true
}
}
for len(queue) > 0 {
item := queue[0]
queue = queue[1:]
if item.dist >= 3 {
continue
}
base := intentScores[item.idx]
if base <= 0 {
base = out[item.idx]
}
decayed := int(float64(base) * powDecay(0.74, item.dist+1))
for _, next := range adj[item.idx] {
if decayed > out[next] {
out[next] = decayed
}
if !visited[next] {
visited[next] = true
queue = append(queue, queueItem{idx: next, dist: item.dist + 1})
}
}
}
return out
}
func powDecay(base float64, exp int) float64 {
out := 1.0
for i := 0; i < exp; i++ {
out *= base
}
return out
}

View File

@ -0,0 +1,21 @@
package knowledge
import "testing"
func TestPreserveNodeSelectionByLabel(t *testing.T) {
previous := []Node{
{ID: "old-1", Label: "化療後 皮膚乾", SelectedForScan: true},
{ID: "old-2", Label: "無關節點", SelectedForScan: false},
}
nodes := []Node{
{ID: "new-1", Label: "化療後 皮膚乾"},
{ID: "new-2", Label: "新痛點"},
}
PreserveNodeSelectionByLabel(nodes, previous)
if !nodes[0].SelectedForScan {
t.Fatal("expected first node selection to be preserved by label")
}
if nodes[1].SelectedForScan {
t.Fatal("expected unrelated node to stay unselected")
}
}

View File

@ -13,6 +13,7 @@ type PatrolTagInput struct {
ProductName string
ProductFeatures string
AudienceSummary string
TargetAudience string
MatchTags []string
Questions []string
Pillars []string
@ -65,6 +66,7 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
ProductName: productName,
ProductFeatures: features,
AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
TargetAudience: strings.TrimSpace(brand.TargetAudience),
MatchTags: matchTagsFromBrand(brand),
Questions: append([]string{}, brand.ResearchMap.Questions...),
Pillars: append([]string{}, brand.ResearchMap.Pillars...),
@ -72,6 +74,23 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
}
}
// OverlayResearchMap copies topic-level research map fields onto patrol input.
func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput {
if strings.TrimSpace(audienceSummary) != "" {
in.AudienceSummary = strings.TrimSpace(audienceSummary)
}
if len(questions) > 0 {
in.Questions = append([]string{}, questions...)
}
if len(pillars) > 0 {
in.Pillars = append([]string{}, pillars...)
}
if len(patrolKeywords) > 0 {
in.PatrolKeywords = append([]string{}, patrolKeywords...)
}
return in
}
func productLabelFromBrand(brand *branddomain.BrandSummary) string {
if brand == nil {
return ""
@ -134,9 +153,33 @@ func (in PatrolTagInput) HasProductContext() bool {
len(in.MatchTags) > 0
}
var patrolCoreValueTokens = []string{
"敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚",
"化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友",
}
func primaryPatrolValueAnchor(in PatrolTagInput) string {
blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " +
strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " +
in.AudienceSummary + " " + in.TargetAudience))
priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"}
for _, token := range priority {
if strings.Contains(blob, token) {
return token
}
}
for _, token := range patrolCoreValueTokens {
if strings.Contains(blob, token) {
return token
}
}
return ""
}
func productCategoryHint(productName, features string) string {
combined := productName + " " + features
for _, hint := range []string{
"洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
"防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
} {

View File

@ -162,6 +162,48 @@ func splitPatrolChunks(text string) []string {
return []string{head, tail}
}
// AnchorPatrolTagToProduct injects product category tokens when a tag lacks product context.
func AnchorPatrolTagToProduct(tag string, in PatrolTagInput) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
hint := productCategoryHint(in.ProductName, in.ProductFeatures)
if hint == "" {
for _, matchTag := range in.MatchTags {
if h := productCategoryHint(matchTag, ""); h != "" {
hint = h
break
}
}
}
if hint == "" {
return tag
}
lowerTag := strings.ToLower(tag)
if strings.Contains(lowerTag, strings.ToLower(hint)) {
return tag
}
for _, matchTag := range in.MatchTags {
matchTag = strings.TrimSpace(strings.ToLower(matchTag))
if len([]rune(matchTag)) >= 2 && strings.Contains(lowerTag, matchTag) {
return tag
}
}
anchor := hint
if valueAnchor := primaryPatrolValueAnchor(in); valueAnchor != "" {
anchor = strings.TrimSpace(valueAnchor + " " + hint)
}
anchored := strings.TrimSpace(anchor + " " + tag)
if utf8.RuneCountInString(anchored) > maxPatrolTagRunes {
anchored = truncateRunes(anchor+" "+compressPatrolKeywords(tag), maxPatrolTagRunes)
}
if utf8.RuneCountInString(anchored) < minPatrolTagRunes {
return tag
}
return anchored
}
func ensurePatrolIntent(phrase, intent string) string {
phrase = strings.TrimSpace(phrase)
if phrase == "" {

View File

@ -1,6 +1,9 @@
package knowledge
import "testing"
import (
"strings"
"testing"
)
func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳")
@ -9,6 +12,35 @@ func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
}
}
func TestAnchorPatrolTagToProductInjectsCategory(t *testing.T) {
in := PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精", "衣物清潔"},
}
got := AnchorPatrolTagToProduct("敏感肌 推薦", in)
if got == "敏感肌 推薦" {
t.Fatal("expected product category to be injected into generic tag")
}
if !strings.Contains(got, "洗衣") {
t.Fatalf("expected laundry anchor in %q", got)
}
}
func TestAnchorPatrolTagToProductPrefersValueAnchor(t *testing.T) {
in := PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏、適合化療族群",
MatchTags: []string{"無香", "洗衣精"},
}
got := AnchorPatrolTagToProduct("皮膚癢 推薦", in)
if !strings.Contains(got, "無香") {
t.Fatalf("expected value anchor in %q", got)
}
if !strings.Contains(got, "洗衣精") {
t.Fatalf("expected product form in %q", got)
}
}
func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) {
got := PatrolTagFromQuestion("敏感肌保養")
if got == "敏感肌保養" {

View File

@ -5,7 +5,27 @@ import (
"strings"
)
const MaxTopPatrolTags = 8
const MaxTopPatrolTags = 10
const MaxScanPatrolKeywords = 10
// SelectedPatrolNodes returns nodes flagged for scan.
func SelectedPatrolNodes(nodes []Node) []Node {
out := make([]Node, 0, len(nodes))
for _, node := range nodes {
if node.SelectedForScan {
out = append(out, node)
}
}
return out
}
// NodesForPatrolKeywordDerivation prefers user-selected graph nodes when present.
func NodesForPatrolKeywordDerivation(nodes []Node) []Node {
if selected := SelectedPatrolNodes(nodes); len(selected) > 0 {
return selected
}
return nodes
}
type PatrolTagCandidate struct {
Tag string
@ -24,19 +44,20 @@ func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate
addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
}
for i, q := range in.Questions {
addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
}
for i, pillar := range in.Pillars {
addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
}
for i, matchTag := range in.MatchTags {
addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
if in.ProductName != "" {
addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
}
for _, node := range nodes {
if !IsPainNode(node) && node.Layer > 1 {
nodesToUse := NodesForPatrolKeywordDerivation(nodes)
for _, node := range nodesToUse {
if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 {
continue
}
nodeScore := scoreNode(node)
@ -216,7 +237,7 @@ func scoreNode(node Node) int {
base += 8
}
if node.ProductFitScore > 0 {
base += node.ProductFitScore / 6
base += node.ProductFitScore / 5
}
return base
}

View File

@ -13,9 +13,14 @@ func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) {
{Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance},
{Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance},
{Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance},
{Tag: "標靶 治療 沐浴", Score: 58, Reason: "周邊情境", Intent: patrolIntentRelevance},
{Tag: "無香 沐浴乳 請問", Score: 56, Reason: "受眾提問", Intent: patrolIntentRelevance},
}, MaxTopPatrolTags)
if len(tags) != MaxTopPatrolTags {
t.Fatalf("expected %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
if len(tags) > MaxTopPatrolTags {
t.Fatalf("expected at most %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
}
if len(tags) < 8 {
t.Fatalf("expected at least 8 deduped tags, got %d: %v", len(tags), tags)
}
if tags[0] != "化療 沐浴乳" {
t.Fatalf("expected highest score first, got %q", tags[0])

View File

@ -1,13 +1,16 @@
package knowledge
// ResolveScanPatrolKeywords picks keywords for placement-scan in UI/API order:
// explicit request > saved research map > auto-ranked from map + graph nodes.
// ResolveScanPatrolKeywords picks the most relevant search phrases for the Search API.
// Research map and graph nodes are inputs; UI display order is not preserved.
func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string {
if keywords := NormalizePatrolKeywordList(explicit); len(keywords) > 0 {
return keywords
}
if keywords := NormalizePatrolKeywordList(saved); len(keywords) > 0 {
return keywords
}
return NormalizePatrolKeywordList(CollectPatrolTagsFromGraph(input, nodes))
nodes = NodesForPatrolKeywordDerivation(nodes)
return SelectBestSearchKeywords(explicit, saved, input, nodes, MaxScanPatrolKeywords)
}
func capPatrolKeywordList(keywords []string, limit int) []string {
keywords = NormalizePatrolKeywordList(keywords)
if limit <= 0 || len(keywords) <= limit {
return keywords
}
return keywords[:limit]
}

View File

@ -2,15 +2,25 @@ package knowledge
import "testing"
func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) {
func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) {
got := ResolveScanPatrolKeywords(
[]string{"手動 關鍵字"},
[]string{"已儲存"},
PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
nil,
)
if len(got) != 1 || got[0] != "手動 關鍵字" {
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got)
if len(got) == 0 {
t.Fatal("expected ranked keywords")
}
found := false
for _, kw := range got {
if kw == "手動 關鍵字" {
found = true
break
}
}
if !found {
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit hint included", got)
}
}
@ -22,3 +32,29 @@ func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) {
t.Fatal("expected research map questions to produce patrol keywords without graph nodes")
}
}
func TestResolveScanPatrolKeywordsPrefersSelectedNodes(t *testing.T) {
nodes := []Node{
{ID: "pain", Label: "化療後 皮膚乾", SelectedForScan: true, ProductFitScore: 88, NodeKind: "pain", DerivedTags: DerivedTags{Relevance: []string{"化療後 皮膚乾 沐浴乳"}}},
{ID: "skip", Label: "無關節點", SelectedForScan: false, ProductFitScore: 90, DerivedTags: DerivedTags{Relevance: []string{"不該出現"}}},
}
got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{}, nodes)
if len(got) == 0 {
t.Fatal("expected selected node to produce patrol keywords")
}
found := false
for _, kw := range got {
if kw == "化療後 皮膚乾 沐浴乳" || kw == "化療後 皮膚乾" {
found = true
break
}
}
if !found {
t.Fatalf("expected selected node keyword, got %#v", got)
}
for _, kw := range got {
if kw == "不該出現" {
t.Fatalf("unselected node leaked into keywords: %#v", got)
}
}
}

View File

@ -0,0 +1,82 @@
package knowledge
import (
"sort"
"strings"
)
// SelectBestSearchKeywords pools research map, graph, product, and optional hints,
// then returns the most intent-relevant phrases for Search API (not UI display order).
func SelectBestSearchKeywords(explicit, saved []string, in PatrolTagInput, nodes []Node, limit int) []string {
if limit <= 0 {
limit = MaxScanPatrolKeywords
}
profile := BuildIntentProfile(in)
candidates := BuildPatrolCandidates(in, nodes)
for i, tag := range NormalizePatrolKeywordList(explicit) {
addPatrolCandidate(&candidates, tag, 156-i, "掃描指定", patrolIntentRelevance)
}
for i, tag := range NormalizePatrolKeywordList(saved) {
if containsNormalizedTag(explicit, tag) {
continue
}
addPatrolCandidate(&candidates, tag, 88-i, "研究地圖儲存", patrolIntentRelevance)
}
type ranked struct {
tag string
score int
}
scored := make([]ranked, 0, len(candidates))
seen := map[string]struct{}{}
for _, item := range selectTopPatrolCandidates(candidates, len(candidates)+32) {
tag := strings.TrimSpace(item.Tag)
if tag == "" {
continue
}
key := patrolTagDedupeKey(tag)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if IsTangentialToIntent(tag, profile) {
continue
}
intent := ScoreIntentSimilarity(tag, profile)
if intent < 8 && item.Score < 65 {
continue
}
final := (item.Score*2 + intent*4) / 6
if final <= 0 {
continue
}
scored = append(scored, ranked{tag: tag, score: final})
}
sort.SliceStable(scored, func(i, j int) bool {
if scored[i].score == scored[j].score {
return scored[i].tag < scored[j].tag
}
return scored[i].score > scored[j].score
})
out := make([]string, 0, limit)
for _, item := range scored {
out = append(out, item.tag)
if len(out) >= limit {
break
}
}
return out
}
func containsNormalizedTag(items []string, target string) bool {
targetKey := patrolTagDedupeKey(target)
for _, item := range items {
if patrolTagDedupeKey(item) == targetKey {
return true
}
}
return false
}

View File

@ -0,0 +1,58 @@
package knowledge
import (
"strings"
"testing"
)
func TestSelectBestSearchKeywordsRanksByIntentNotSavedOrder(t *testing.T) {
in := PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏、適合化療族群",
MatchTags: []string{"無香", "抗敏", "洗衣精"},
Questions: []string{"化療後想找不會刺激的洗衣精"},
Pillars: []string{"無香洗衣", "化療後衣物清潔"},
}
got := SelectBestSearchKeywords(
nil,
[]string{"洗衣機 推薦", "無香 洗衣精 請問"},
in,
nil,
4,
)
if len(got) == 0 {
t.Fatal("expected ranked keywords")
}
for _, kw := range got {
if strings.Contains(kw, "洗衣機") {
t.Fatalf("expected weak saved keyword to lose ranking, got %#v", got)
}
}
found := false
for _, kw := range got {
if strings.Contains(kw, "無香") || strings.Contains(kw, "化療") {
found = true
break
}
}
if !found {
t.Fatalf("expected intent-aligned keyword in %#v", got)
}
}
func TestSelectBestSearchKeywordsIncludesExplicitHint(t *testing.T) {
in := PatrolTagInput{
ProductName: "抗敏無香洗衣精",
MatchTags: []string{"無香", "洗衣精"},
}
got := SelectBestSearchKeywords(
[]string{"化療 無香 洗衣精"},
[]string{"洗衣機 推薦"},
in,
nil,
3,
)
if len(got) == 0 || got[0] != "化療 無香 洗衣精" {
t.Fatalf("expected explicit hint to rank first, got %#v", got)
}
}

View File

@ -55,23 +55,23 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
matches := isCoreNode(node)
for i, q := range in.Questions {
if matches || nodeMatchesResearchText(node, q) {
addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
if matches || nodeMatchesResearchText(node, q, in) {
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
}
}
for i, pillar := range in.Pillars {
if matches || nodeMatchesResearchText(node, pillar) {
addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
if matches || nodeMatchesResearchText(node, pillar, in) {
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
}
}
for i, matchTag := range in.MatchTags {
if matches || nodeMatchesResearchText(node, matchTag) {
addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
if matches || nodeMatchesResearchText(node, matchTag, in) {
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
}
if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) {
addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) {
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
}
if len(candidates) == 0 && IsPainNode(node) {
@ -79,16 +79,16 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
if i >= 2 {
break
}
addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
}
}
if len(candidates) == 0 && IsPainNode(node) {
addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
for i, matchTag := range in.MatchTags {
if i >= 1 {
break
}
addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
}
}
@ -107,7 +107,7 @@ func isCoreNode(node Node) bool {
return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core")
}
func nodeMatchesResearchText(node Node, text string) bool {
func nodeMatchesResearchText(node Node, text string, in PatrolTagInput) bool {
label := strings.TrimSpace(node.Label)
text = strings.TrimSpace(text)
if label == "" || text == "" {
@ -116,7 +116,12 @@ func nodeMatchesResearchText(node Node, text string) bool {
if label == text || strings.Contains(text, label) || strings.Contains(label, text) {
return true
}
return sharesMeaningfulSubstring(label, text)
if sharesMeaningfulSubstring(label, text) {
return true
}
profile := BuildIntentProfile(in)
joined := strings.TrimSpace(NodeIntentText(node) + " " + text)
return ScoreIntentSimilarity(joined, profile) >= 40
}
func deriveFallbackPatrolTags(node Node) DerivedTags {

View File

@ -0,0 +1,59 @@
package knowledge
import (
"sort"
"strings"
)
// RankStringsByIntent sorts phrases by semantic proximity to product + research map.
func RankStringsByIntent(items []string, in PatrolTagInput) []string {
if len(items) <= 1 {
return append([]string{}, items...)
}
profile := BuildIntentProfile(in)
type scored struct {
text string
score int
}
scores := make([]scored, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
scores = append(scores, scored{
text: item,
score: ScoreIntentSimilarity(item, profile),
})
}
sort.SliceStable(scores, func(i, j int) bool {
if scores[i].score == scores[j].score {
return scores[i].text < scores[j].text
}
return scores[i].score > scores[j].score
})
out := make([]string, 0, len(scores))
for _, item := range scores {
out = append(out, item.text)
}
return out
}
// FilterPatrolKeywordsByIntent drops low-intent patrol keywords from research map output.
func FilterPatrolKeywordsByIntent(items []string, in PatrolTagInput, minScore int) []string {
if minScore <= 0 {
return RankStringsByIntent(items, in)
}
profile := BuildIntentProfile(in)
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if ScoreIntentSimilarity(item, profile) >= minScore {
out = append(out, item)
}
}
return out
}

View File

@ -0,0 +1,122 @@
package knowledge
import (
"strings"
"unicode/utf8"
)
const (
maxThreadsSearchRunes = 14
maxWebRecencyCoreRunes = 12
maxWebRelevanceTagRunes = 16
)
var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"}
// ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API.
// Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness).
func ShortPatrolSearchCore(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
tokens := selectSearchTokens(tag, 3)
if len(tokens) == 0 {
return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
}
core := strings.Join(tokens, " ")
return truncateRunes(core, maxWebRecencyCoreRunes)
}
// ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search.
func ThreadsAPIKeyword(tag string) string {
core := ShortPatrolSearchCore(tag)
if core != "" {
return core
}
return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
}
// FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes).
func FullPatrolWebSearchTag(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if !hasSearchIntentMarker(tag) {
candidate := strings.TrimSpace(tag + " 推薦")
if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes {
tag = candidate
}
}
if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes {
return tag
}
tokens := selectSearchTokens(tag, 4)
if len(tokens) == 0 {
return truncateRunes(tag, maxWebRelevanceTagRunes)
}
full := strings.Join(tokens, " ")
if !hasSearchIntentMarker(full) {
withIntent := strings.TrimSpace(full + " 推薦")
if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes {
return withIntent
}
}
return truncateRunes(full, maxWebRelevanceTagRunes)
}
func selectSearchTokens(tag string, maxTokens int) []string {
tag = stripSearchIntent(tag)
parts := []string{}
seen := map[string]struct{}{}
add := func(token string) {
token = strings.TrimSpace(token)
if isPatrolFiller(token) || len([]rune(token)) < 2 {
return
}
if _, ok := seen[token]; ok {
return
}
seen[token] = struct{}{}
parts = append(parts, token)
}
for _, anchor := range patrolTopicAnchors {
if strings.Contains(tag, anchor) {
add(anchor)
}
}
for _, hint := range productFormHints {
if strings.Contains(tag, hint) {
add(hint)
}
}
for _, token := range intentTokenize(tag) {
add(token)
if len(parts) >= maxTokens {
break
}
}
if len(parts) > maxTokens {
parts = parts[:maxTokens]
}
return parts
}
func stripSearchIntent(tag string) string {
tag = strings.TrimSpace(tag)
for _, marker := range searchIntentMarkers {
tag = strings.ReplaceAll(tag, marker, " ")
}
return strings.Join(strings.Fields(tag), " ")
}
func hasSearchIntentMarker(tag string) bool {
for _, marker := range searchIntentMarkers {
if strings.Contains(tag, marker) {
return true
}
}
return false
}

View File

@ -0,0 +1,37 @@
package knowledge
import (
"strings"
"testing"
)
func TestShortPatrolSearchCoreDropsIntentNoise(t *testing.T) {
got := ShortPatrolSearchCore("化療 無香 洗衣精 推薦 請問")
if got == "" {
t.Fatal("expected non-empty core")
}
if len([]rune(got)) > maxThreadsSearchRunes {
t.Fatalf("core too long: %q", got)
}
if strings.Contains(got, "推薦") || strings.Contains(got, "請問") {
t.Fatalf("expected intent words stripped for API core, got %q", got)
}
if !strings.Contains(got, "無香") && !strings.Contains(got, "洗衣精") {
t.Fatalf("expected pain/product tokens kept, got %q", got)
}
}
func TestFullPatrolWebSearchTagKeepsIntent(t *testing.T) {
got := FullPatrolWebSearchTag("化療 無香 洗衣精")
if !strings.Contains(got, "推薦") && !strings.Contains(got, "請問") && !strings.Contains(got, "怎麼辦") {
t.Fatalf("expected relevance tag to include intent marker, got %q", got)
}
}
func TestThreadsAPIKeywordUsesShortCore(t *testing.T) {
long := "化療後皮膚敏感要換什麼無香洗衣精 推薦"
got := ThreadsAPIKeyword(long)
if got == "" || len([]rune(got)) > maxThreadsSearchRunes {
t.Fatalf("unexpected threads keyword %q", got)
}
}

View File

@ -0,0 +1,289 @@
package knowledge
import (
"strings"
"unicode"
)
// IntentProfile is a weighted lexical intent model for product + research map.
// It generalizes relevance beyond hardcoded category rules (e.g. 洗衣精 vs 洗衣機).
type IntentProfile struct {
TokenWeights map[string]float64
BroadTokens map[string]struct{}
Phrases []weightedPhrase
}
type weightedPhrase struct {
Text string
Weight float64
}
const (
intentWeightProduct = 3.0
intentWeightFeature = 2.8
intentWeightMatchTag = 2.5
intentWeightQuestion = 2.2
intentWeightPillar = 2.0
intentWeightAudience = 1.6
intentWeightPatrol = 1.2
intentWeightSeed = 1.0
tangentialBroadMin = 38
tangentialIntentMax = 30
)
var productFormHints = []string{
"洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳",
}
// BuildIntentProfile materializes product + research-map text into a reusable relevance model.
func BuildIntentProfile(in PatrolTagInput) IntentProfile {
profile := IntentProfile{
TokenWeights: map[string]float64{},
BroadTokens: map[string]struct{}{},
}
addPhrase := func(text string, weight float64) {
text = strings.TrimSpace(text)
if text == "" || weight <= 0 {
return
}
profile.Phrases = append(profile.Phrases, weightedPhrase{Text: text, Weight: weight})
profile.addTokens(text, weight)
}
addPhrase(in.ProductName, intentWeightProduct)
addPhrase(in.ProductFeatures, intentWeightFeature)
for _, tag := range in.MatchTags {
addPhrase(tag, intentWeightMatchTag)
}
for _, q := range in.Questions {
addPhrase(q, intentWeightQuestion)
}
for _, pillar := range in.Pillars {
addPhrase(pillar, intentWeightPillar)
}
addPhrase(in.AudienceSummary, intentWeightAudience)
addPhrase(in.TargetAudience, intentWeightAudience)
for _, kw := range in.PatrolKeywords {
addPhrase(kw, intentWeightPatrol)
}
if hint := productCategoryHint(in.ProductName, in.ProductFeatures); hint != "" {
profile.markBroad(hint)
}
for _, hint := range productFormHints {
blob := strings.ToLower(in.ProductName + " " + in.ProductFeatures + " " + strings.Join(in.MatchTags, " "))
if strings.Contains(blob, hint) {
profile.markBroad(hint)
}
}
return profile
}
func (p *IntentProfile) addTokens(text string, weight float64) {
for _, token := range intentTokenize(text) {
if existing, ok := p.TokenWeights[token]; !ok || weight > existing {
p.TokenWeights[token] = weight
}
}
}
func (p *IntentProfile) markBroad(token string) {
token = strings.TrimSpace(strings.ToLower(token))
if token == "" {
return
}
p.BroadTokens[token] = struct{}{}
for _, part := range intentTokenize(token) {
p.BroadTokens[part] = struct{}{}
}
}
func (p IntentProfile) totalWeight() float64 {
sum := 0.0
for _, w := range p.TokenWeights {
sum += w
}
if sum <= 0 {
for _, phrase := range p.Phrases {
sum += phrase.Weight
}
}
return sum
}
// NodeIntentText combines node fields used for semantic fit.
func NodeIntentText(node Node) string {
return strings.TrimSpace(strings.Join([]string{
node.Label,
node.Relation,
node.PlacementValue,
strings.Join(node.PatrolRelevance, " "),
strings.Join(node.PatrolRecency, " "),
}, " "))
}
// ScoreIntentSimilarity returns 0-100 weighted token overlap against the intent profile.
func ScoreIntentSimilarity(text string, profile IntentProfile) int {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" || len(profile.TokenWeights) == 0 {
return 0
}
matched := 0.0
for token, weight := range profile.TokenWeights {
if strings.Contains(text, token) {
matched += weight
}
}
total := profile.totalWeight()
if total <= 0 {
return 0
}
score := int((matched / total) * 100)
if score > 100 {
return 100
}
return score
}
// ScoreBroadSimilarity measures overlap with category-level tokens only.
func ScoreBroadSimilarity(text string, profile IntentProfile) int {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" || len(profile.BroadTokens) == 0 {
return 0
}
hits := 0
for token := range profile.BroadTokens {
if strings.Contains(text, token) {
hits++
continue
}
runes := []rune(token)
if len(runes) >= 2 && strings.Contains(text, string(runes[:2])) {
hits++
}
}
if hits == 0 {
return 0
}
denom := len(profile.BroadTokens)
if denom > 6 {
denom = 6
}
score := (hits * 100) / denom
if score > 100 {
return 100
}
return score
}
// IsTangentialToIntent detects same broad category but weak product-intent alignment.
// Example: 洗衣機 vs 抗敏無香洗衣精 — shares 洗衣, lacks 無香/敏感/化療 intent.
func IsTangentialToIntent(text string, profile IntentProfile) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" || len(profile.TokenWeights) == 0 {
return false
}
intentScore := ScoreIntentSimilarity(text, profile)
if intentScore >= tangentialIntentMax+10 {
return false
}
if hasCategoryStemDrift(text, profile) {
return true
}
broadScore := ScoreBroadSimilarity(text, profile)
if broadScore >= tangentialBroadMin {
return intentScore <= tangentialIntentMax
}
return false
}
func hasCategoryStemDrift(text string, profile IntentProfile) bool {
for broad := range profile.BroadTokens {
runes := []rune(broad)
if len(runes) < 2 {
continue
}
stem := string(runes[:2])
if !strings.Contains(text, stem) {
continue
}
if strings.Contains(text, broad) {
continue
}
for _, suffix := range []string{"機", "槽", "烘", "劑", "粉", "精", "乳", "露", "液", "皂"} {
alt := stem + suffix
if alt == broad || !strings.Contains(text, alt) {
continue
}
if !profile.matchesIntentToken(alt) {
return true
}
}
}
return false
}
func (p IntentProfile) matchesIntentToken(token string) bool {
if _, ok := p.TokenWeights[token]; ok {
return true
}
for intentToken := range p.TokenWeights {
if strings.Contains(token, intentToken) || strings.Contains(intentToken, token) {
return true
}
}
return false
}
func intentTokenize(text string) []string {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return nil
}
repl := strings.NewReplacer("", " ", "、", " ", "。", " ", "", " ", "/", " ", "", " ", "|", " ", "", " ", "", " ", "(", " ", ")", " ", "", " ", "?", " ", "", " ", "!", " ")
text = repl.Replace(text)
seen := map[string]struct{}{}
var out []string
add := func(token string) {
token = strings.Trim(token, `"'「」『』::`)
if len([]rune(token)) < 2 {
return
}
if _, ok := seen[token]; ok {
return
}
seen[token] = struct{}{}
out = append(out, token)
}
for _, part := range strings.Fields(text) {
add(part)
}
if len(out) == 0 {
for _, chunk := range intentSplitRunes(text) {
add(chunk)
}
}
return out
}
func intentSplitRunes(s string) []string {
var out []string
var buf []rune
flush := func() {
if len(buf) >= 2 {
out = append(out, string(buf))
}
buf = buf[:0]
}
for _, r := range s {
if unicode.Is(unicode.Han, r) {
buf = append(buf, r)
continue
}
flush()
}
flush()
return out
}

View File

@ -0,0 +1,53 @@
package knowledge
import "testing"
func TestIsTangentialToIntentWashingMachineVsHypoallergenicDetergent(t *testing.T) {
profile := BuildIntentProfile(PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏,適合敏感肌與化療族群",
MatchTags: []string{"無香", "抗敏", "洗衣精"},
Pillars: []string{"化療後衣物清潔", "無香洗衣"},
Questions: []string{"化療後想找不會刺激的洗衣精"},
})
text := "請問洗脫烘洗衣機有推薦嗎?"
if !IsTangentialToIntent(text, profile) {
t.Fatal("expected washing machine post to be tangential to hypoallergenic detergent intent")
}
}
func TestIsTangentialToIntentAcceptsCorePain(t *testing.T) {
profile := BuildIntentProfile(PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏",
MatchTags: []string{"無香", "洗衣精"},
})
text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
if IsTangentialToIntent(text, profile) {
t.Fatal("expected core pain post to align with intent")
}
}
func TestRescoreGraphIntentFitPenalizesTangentialNode(t *testing.T) {
graph := Graph{
Seed: "無香 洗衣精",
Nodes: []Node{
{ID: "core", Label: "化療後對香味敏感", NodeKind: "pain", Layer: 0, ProductFitScore: 92, Relation: "需要無香洗衣精"},
{ID: "weak", Label: "洗衣機選購", NodeKind: "knowledge", Layer: 2, ProductFitScore: 72, Relation: "家電預算與容量"},
},
Edges: []Edge{{From: "core", To: "weak", Relation: "延伸"}},
}
in := PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏",
MatchTags: []string{"無香", "洗衣精"},
Questions: []string{"化療後想找無香洗衣精"},
}
RescoreGraphIntentFit(&graph, in)
if graph.Nodes[0].ProductFitScore < 60 {
t.Fatalf("expected core node to stay high, got %d", graph.Nodes[0].ProductFitScore)
}
if graph.Nodes[1].ProductFitScore > 35 {
t.Fatalf("expected tangential node to be penalized, got %d", graph.Nodes[1].ProductFitScore)
}
}

View File

@ -0,0 +1,107 @@
package outreach
import (
"fmt"
"strings"
libkg "haixun-backend/internal/library/knowledge"
libplacement "haixun-backend/internal/library/placement"
brandentity "haixun-backend/internal/model/brand/domain/entity"
branddomain "haixun-backend/internal/model/brand/domain/usecase"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
)
func FindGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node {
nodeID = strings.TrimSpace(nodeID)
if nodeID == "" {
return nil
}
for i := range nodes {
if nodes[i].ID == nodeID {
return &nodes[i]
}
}
return nil
}
func BuildPlacementReason(post *scanpostusecase.ScanPostSummary) string {
if post == nil {
return ""
}
parts := []string{}
if post.Priority == "gold" {
parts = append(parts, "雙軌命中(相關+近期)")
} else if post.Priority == "recent" {
parts = append(parts, "近期軌命中")
}
if post.SolvedByProduct {
parts = append(parts, "產品可解此需求")
}
if post.ProductFitScore > 0 {
parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore))
}
return strings.Join(parts, "")
}
func MergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string {
parts := []string{}
if strings.TrimSpace(base) != "" {
parts = append(parts, strings.TrimSpace(base))
}
if node != nil {
if strings.TrimSpace(node.PlacementValue) != "" {
parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue))
}
if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) {
parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore))
}
}
return strings.Join(parts, "")
}
func ProductBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag, postText string) string {
if brand == nil {
return ""
}
if product := PickProductForOutreach(brand, productID, searchTag, postText); product != nil {
merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label)
merged = libplacement.ApplyPlacementURL(merged, product.PlacementURL)
if pb := libplacement.ProductBriefFromContext(merged); pb != "" {
return pb
}
}
if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" {
return pb
}
return strings.TrimSpace(brand.ProductBrief)
}
func PickProductForOutreach(brand *branddomain.BrandSummary, preferredProductID, searchTag, postText string) *branddomain.ProductSummary {
if brand == nil || len(brand.Products) == 0 {
return nil
}
entities := make([]brandentity.Product, len(brand.Products))
for i := range brand.Products {
entities[i] = brandentity.Product{
ID: brand.Products[i].ID,
Label: brand.Products[i].Label,
ProductContext: brand.Products[i].ProductContext,
MatchTags: append([]string(nil), brand.Products[i].MatchTags...),
}
}
if picked := libplacement.RecommendProductForPost(entities, searchTag, postText, preferredProductID); picked != nil {
for i := range brand.Products {
if brand.Products[i].ID == picked.ID {
return &brand.Products[i]
}
}
}
if id := strings.TrimSpace(preferredProductID); id != "" {
for i := range brand.Products {
if brand.Products[i].ID == id {
return &brand.Products[i]
}
}
}
return &brand.Products[0]
}

View File

@ -0,0 +1,77 @@
package outreach
import (
"strings"
)
func NormalizeDraftText(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = strings.TrimSpace(text)
text = humanizeDraftLineBreaks(text)
return trimDraftText(text)
}
func humanizeDraftLineBreaks(text string) string {
if text == "" || strings.Contains(text, "\n") {
return text
}
if len([]rune(text)) < 40 {
return text
}
sentences := splitSentences(text)
if len(sentences) <= 1 {
return text
}
paragraphs := make([]string, 0, (len(sentences)+1)/2)
for i := 0; i < len(sentences); {
end := i + 2
if end > len(sentences) {
end = len(sentences)
}
paragraphs = append(paragraphs, strings.Join(sentences[i:end], ""))
i = end
}
return strings.Join(paragraphs, "\n\n")
}
func splitSentences(text string) []string {
runes := []rune(text)
if len(runes) == 0 {
return nil
}
out := make([]string, 0, 4)
var current strings.Builder
for i := 0; i < len(runes); i++ {
ch := runes[i]
current.WriteRune(ch)
if !isSentenceEnd(ch) {
continue
}
// Keep ellipsis / repeated punctuation attached to the same sentence.
for i+1 < len(runes) && isSentenceEnd(runes[i+1]) {
i++
current.WriteRune(runes[i])
}
if sentence := strings.TrimSpace(current.String()); sentence != "" {
out = append(out, sentence)
}
current.Reset()
}
if tail := strings.TrimSpace(current.String()); tail != "" {
out = append(out, tail)
}
return out
}
func isSentenceEnd(ch rune) bool {
switch ch {
case '。', '', '', '!', '?', '…':
return true
default:
return false
}
}

View File

@ -0,0 +1,36 @@
package outreach
import "testing"
func TestNormalizeDraftTextPreservesNewlines(t *testing.T) {
raw := "第一段。\n\n第二段。"
got := NormalizeDraftText(raw)
if got != raw {
t.Fatalf("got %q, want %q", got, raw)
}
}
func TestHumanizeDraftLineBreaks(t *testing.T) {
raw := "我懂你的感受。之前我們家也遇過類似狀況。後來換了無香的才比較舒服。希望你有找到適合的。"
got := NormalizeDraftText(raw)
if !containsNewline(got) {
t.Fatalf("expected paragraph breaks, got %q", got)
}
}
func TestNormalizeDraftTextShortSingleParagraph(t *testing.T) {
raw := "加油,會好的。"
got := NormalizeDraftText(raw)
if got != raw {
t.Fatalf("got %q, want %q", got, raw)
}
}
func containsNewline(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
return true
}
}
return false
}

View File

@ -24,14 +24,16 @@ type GenerateResult struct {
}
type GenerateInput struct {
Persona string
TopicLabel string
AudienceBrief string
ProductBrief string
PlacementReason string
TargetText string
AuthorName string
Count int
Persona string
TopicLabel string
AudienceBrief string
ProductBrief string
PlacementReason string
TargetText string
AuthorName string
Count int
Regenerate bool
PreviousDraftsHint string
}
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
@ -65,6 +67,13 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
if author == "" {
author = "匿名"
}
regenerateLine := ""
if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" {
regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿換開頭、換切角、換句式與情境細節不要複製舊文案。"
if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" {
regenerateLine += "\n" + hint
}
}
return libprompt.OutreachPlacementUser(map[string]string{
"persona_block": personaBlock,
"topic_label": topic,
@ -73,6 +82,7 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
"placement_reason_line": reasonLine,
"author_name": author,
"target_text": strings.TrimSpace(in.TargetText),
"regenerate_line": regenerateLine,
"count": fmt.Sprintf("%d", count),
})
}
@ -90,7 +100,7 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
return GenerateResult{}, fmt.Errorf("outreach drafts missing")
}
for i := range out.Drafts {
out.Drafts[i].Text = trimDraftText(out.Drafts[i].Text)
out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text)
if out.Drafts[i].Text == "" {
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
}

View File

@ -0,0 +1,188 @@
package outreach
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
libprompt "haixun-backend/internal/library/prompt"
domai "haixun-backend/internal/model/ai/domain/usecase"
aiusecase "haixun-backend/internal/model/ai/usecase"
branddomain "haixun-backend/internal/model/brand/domain/usecase"
kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)
type GenerateDraftRequest struct {
TenantID string
OwnerUID string
BrandID string
TopicID string
ScanPostID string
Count int
VoicePersonaID string
ProductID string
Regenerate bool
}
type GenerateDraftDeps struct {
Brand branddomain.UseCase
Persona personadomain.UseCase
ScanPost scanpostusecase.UseCase
KnowledgeGraph kgdomain.UseCase
ThreadsAccount threadsaccountdomain.UseCase
AI aiusecase.UseCase
OutreachDraft outreachusecase.UseCase
}
func GenerateDraft(ctx context.Context, deps GenerateDraftDeps, req GenerateDraftRequest) (*outreachusecase.DraftSummary, error) {
scanPostID := strings.TrimSpace(req.ScanPostID)
count := req.Count
if count <= 0 {
count = 2
}
if count > 4 {
count = 4
}
if scanPostID == "" {
return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
}
brand, err := deps.Brand.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID)
if err != nil {
return nil, err
}
voiceID := strings.TrimSpace(req.VoicePersonaID)
if voiceID == "" {
return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
}
vp, err := deps.Persona.Get(ctx, req.TenantID, req.OwnerUID, voiceID)
if err != nil {
return nil, err
}
voicePersona := FormatVoicePersonaBlock(vp)
if voicePersona == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先在人設頁填寫人設描述或完成 8D 風格分析")
}
preferredProductID := strings.TrimSpace(req.ProductID)
if preferredProductID == "" {
preferredProductID = strings.TrimSpace(brand.ProductID)
}
post, err := deps.ScanPost.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID, scanPostID)
if err != nil {
return nil, err
}
pickedProduct := PickProductForOutreach(brand, preferredProductID, post.SearchTag, post.Text)
productID := preferredProductID
if pickedProduct != nil {
productID = pickedProduct.ID
}
topicLabel := strings.TrimSpace(post.SearchTag)
placementReason := BuildPlacementReason(post)
if graph, graphErr := deps.KnowledgeGraph.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID); graphErr == nil && graph != nil {
if node := FindGraphNode(graph.Nodes, post.GraphNodeID); node != nil {
if strings.TrimSpace(node.Label) != "" {
topicLabel = strings.TrimSpace(node.Label)
}
placementReason = MergePlacementReason(placementReason, node, post)
}
}
regenerate := req.Regenerate
previousHint := ""
if latest, latestErr := deps.OutreachDraft.GetLatestByScanPost(
ctx, req.TenantID, req.OwnerUID, req.BrandID, strings.TrimSpace(req.TopicID), scanPostID,
); latestErr == nil && latest != nil && len(latest.Drafts) > 0 {
regenerate = true
previousHint = FormatPreviousDraftsHint(latest)
}
userPrompt, err := BuildUserPrompt(GenerateInput{
Persona: voicePersona,
TopicLabel: topicLabel,
AudienceBrief: brand.TargetAudience,
ProductBrief: ProductBriefForBrand(brand, productID, post.SearchTag, post.Text),
PlacementReason: placementReason,
TargetText: post.Text,
AuthorName: post.Author,
Count: count,
Regenerate: regenerate,
PreviousDraftsHint: previousHint,
})
if err != nil {
return nil, app.For(code.AI).SysInternal("outreach user prompt load failed")
}
systemPrompt, err := libprompt.OutreachPlacementSystem()
if err != nil {
return nil, app.For(code.AI).SysInternal("outreach system prompt load failed")
}
systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n【最高優先】以下人設與語氣是硬約束每則留言都必須像這個角色親自回覆不得變成通用網友口吻\n" + strings.TrimSpace(voicePersona)
credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, req.TenantID, req.OwnerUID)
if err != nil {
return nil, err
}
providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
if err != nil {
return nil, err
}
temp := 0.88
result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
Provider: providerID,
Model: credential.Model,
Credential: domai.Credential{
APIKey: credential.APIKey,
},
System: systemPrompt,
Messages: []domai.Message{
{Role: "user", Content: userPrompt},
},
Temperature: &temp,
})
if err != nil {
return nil, err
}
parsed, err := ParseGenerateOutput(result.Text)
if err != nil {
return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error())
}
draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts))
for _, item := range parsed.Drafts {
draftItems = append(draftItems, outreachusecase.DraftItem{
Text: item.Text,
Angle: item.Angle,
Rationale: item.Rationale,
})
}
saved, err := deps.OutreachDraft.Create(ctx, outreachusecase.CreateRequest{
TenantID: req.TenantID,
OwnerUID: req.OwnerUID,
BrandID: req.BrandID,
TopicID: strings.TrimSpace(req.TopicID),
ScanPostID: scanPostID,
Relevance: parsed.Relevance,
Reason: parsed.Reason,
Drafts: draftItems,
})
if err != nil {
return nil, err
}
_, _ = deps.ScanPost.UpdateOutreach(ctx, scanpostusecase.UpdateOutreachRequest{
TenantID: req.TenantID,
OwnerUID: req.OwnerUID,
BrandID: req.BrandID,
PostID: scanPostID,
Status: scanpostentity.OutreachStatusDrafted,
})
return saved, nil
}

View File

@ -0,0 +1,33 @@
package outreach
import (
"strings"
"haixun-backend/internal/library/style8d"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
)
// FormatVoicePersonaBlock builds the LLM persona block from a full persona profile.
func FormatVoicePersonaBlock(vp *personadomain.PersonaSummary) string {
if vp == nil {
return ""
}
block := style8d.ResolvePersonaBlock(vp.Persona, vp.StyleProfile, vp.Brief)
if block == "" {
return ""
}
extras := make([]string, 0, 3)
if name := strings.TrimSpace(vp.DisplayName); name != "" {
extras = append(extras, "角色名稱:"+name)
}
if goals := strings.TrimSpace(vp.Goals); goals != "" {
extras = append(extras, "回覆目標:"+goals)
}
if benchmark := strings.TrimSpace(vp.StyleBenchmark); benchmark != "" {
extras = append(extras, "語氣參考:"+benchmark)
}
if len(extras) == 0 {
return block
}
return strings.TrimSpace(block + "\n\n" + strings.Join(extras, "\n"))
}

View File

@ -0,0 +1,34 @@
package outreach
import (
"strings"
"testing"
personadomain "haixun-backend/internal/model/persona/domain/usecase"
)
func TestFormatVoicePersonaBlockUsesPersonaAndGoals(t *testing.T) {
block := FormatVoicePersonaBlock(&personadomain.PersonaSummary{
DisplayName: "小安",
Persona: "溫柔媽媽,說話慢一點、會先共情",
Goals: "讓對方覺得被理解,再輕鬆分享經驗",
})
if !strings.Contains(block, "溫柔媽媽") {
t.Fatalf("expected persona text, got %q", block)
}
if !strings.Contains(block, "小安") {
t.Fatalf("expected display name, got %q", block)
}
if !strings.Contains(block, "讓對方覺得被理解") {
t.Fatalf("expected goals, got %q", block)
}
}
func TestFormatVoicePersonaBlockEmpty(t *testing.T) {
if FormatVoicePersonaBlock(nil) != "" {
t.Fatal("expected empty block for nil persona")
}
if FormatVoicePersonaBlock(&personadomain.PersonaSummary{}) != "" {
t.Fatal("expected empty block for blank persona")
}
}

View File

@ -0,0 +1,32 @@
package outreach
import (
"fmt"
"strings"
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
)
func FormatPreviousDraftsHint(latest *outreachusecase.DraftSummary) string {
if latest == nil || len(latest.Drafts) == 0 {
return ""
}
lines := []string{"上一版草稿(請避開相同寫法):"}
for i, item := range latest.Drafts {
angle := strings.TrimSpace(item.Angle)
if angle == "" {
angle = fmt.Sprintf("版本%d", i+1)
}
lines = append(lines, fmt.Sprintf("- %s%s", angle, truncateRunes(item.Text, 140)))
}
return strings.Join(lines, "\n")
}
func truncateRunes(text string, max int) string {
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) <= max {
return text
}
return string(runes[:max]) + "…"
}

View File

@ -0,0 +1,19 @@
package outreach
import (
"strings"
"testing"
outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
)
func TestFormatPreviousDraftsHint(t *testing.T) {
hint := FormatPreviousDraftsHint(&outreachusecase.DraftSummary{
Drafts: []outreachusecase.DraftItem{
{Angle: "共情", Text: "我懂你的感受"},
},
})
if !strings.Contains(hint, "上一版草稿") || !strings.Contains(hint, "共情") {
t.Fatalf("hint = %q", hint)
}
}

View File

@ -0,0 +1,162 @@
package ownpost
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
domai "haixun-backend/internal/model/ai/domain/usecase"
)
var formulaFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
type PostFormulaInput struct {
AccountName string
PersonaBlock string
PostText string
TopicTag string
MediaType string
LikeCount int
ReplyCount int
Views int
RepostCount int
QuoteCount int
Shares int
Timestamp string
}
type PostFormulaReview struct {
Summary string `json:"summary"`
Wins []string `json:"wins"`
Improvements []string `json:"improvements"`
Formula string `json:"formula"`
PostTemplate string `json:"post_template"`
HookPattern string `json:"hook_pattern"`
Structure string `json:"structure"`
ReplicationTips []string `json:"replication_tips"`
Avoid []string `json:"avoid"`
}
func BuildPostFormulaPrompt(in PostFormulaInput) (system string, user string) {
personaBlock := strings.TrimSpace(in.PersonaBlock)
if personaBlock == "" {
personaBlock = "(未指定人設,請依貼文本身語氣分析)"
}
postText := strings.TrimSpace(in.PostText)
if postText == "" {
postText = "(貼文內容未提供)"
}
system = strings.TrimSpace(`
你是 Threads 內容策略教練任務是針對已發布的單篇貼文做誠實覆盤好的要提炼成可複製公式壞的要指出問題並給具體改善做法不要重寫整篇貼文
規則
- 用台灣繁體中文
- 必須同時寫優點與缺點依成效數據判斷成效普通或偏差時更要直言問題
- 分析要具體可執行禁止空泛稱讚或空泛批評
- formula做對的部分整理成步驟化公式Hook情境轉折觀點收尾/互動
- post_template formula 產出可複製的發文骨架 [括號] 標示可替換欄位讓使用者下次填空就能產文
- improvements每點格式固定為問題改善改善必須是可立刻執行的動作改哪一句加什麼刪什麼換什麼角度
- 只輸出一個 JSON 物件不要 markdown欄位
summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
- wins / improvements / replication_tips / avoid 25 點字串陣列`)
var b strings.Builder
b.WriteString("【1. 人設】\n")
b.WriteString(personaBlock)
b.WriteString("\n\n帳號")
b.WriteString(strings.TrimSpace(in.AccountName))
b.WriteString("\n\n【2. 貼文內容】\n")
b.WriteString(postText)
if tag := strings.TrimSpace(in.TopicTag); tag != "" {
b.WriteString("\n話題標籤#")
b.WriteString(tag)
}
if mt := strings.TrimSpace(in.MediaType); mt != "" {
b.WriteString("\n媒體類型")
b.WriteString(mt)
}
if ts := strings.TrimSpace(in.Timestamp); ts != "" {
b.WriteString("\n發布時間")
b.WriteString(ts)
}
b.WriteString("\n\n【3. 成效數據】\n")
b.WriteString(fmt.Sprintf("讚 %d回覆 %d瀏覽 %d轉發 %d引用 %d分享 %d",
in.LikeCount, in.ReplyCount, in.Views, in.RepostCount, in.QuoteCount, in.Shares))
b.WriteString("\n\n請誠實覆盤做對了什麼、哪裡不好、怎麼改並把做對的部分整理成下次可複製的公式與發文模板 JSON。")
user = b.String()
return system, user
}
func GeneratePostFormula(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in PostFormulaInput) (*PostFormulaReview, error) {
system, user := BuildPostFormulaPrompt(in)
temp := 0.65
req.System = system
req.Messages = []domai.Message{{Role: "user", Content: user}}
req.Temperature = &temp
out, err := ai.GenerateText(ctx, req)
if err != nil {
return nil, err
}
review, err := ParsePostFormulaOutput(out.Text)
if err != nil {
return nil, err
}
return review, nil
}
func ParsePostFormulaOutput(raw string) (*PostFormulaReview, error) {
payload, err := extractFormulaJSONObject(raw)
if err != nil {
return nil, err
}
var review PostFormulaReview
if err := json.Unmarshal(payload, &review); err != nil {
return nil, fmt.Errorf("parse post formula json: %w", err)
}
review.Summary = strings.TrimSpace(review.Summary)
review.Formula = strings.TrimSpace(review.Formula)
review.PostTemplate = strings.TrimSpace(review.PostTemplate)
review.HookPattern = strings.TrimSpace(review.HookPattern)
review.Structure = strings.TrimSpace(review.Structure)
review.Wins = trimStringList(review.Wins)
review.Improvements = trimStringList(review.Improvements)
review.ReplicationTips = trimStringList(review.ReplicationTips)
review.Avoid = trimStringList(review.Avoid)
if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
return nil, fmt.Errorf("AI 未回傳有效覆盤內容")
}
return &review, nil
}
func trimStringList(items []string) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
if len(out) == 0 {
return nil
}
return out
}
func extractFormulaJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if m := formulaFenceRE.FindStringSubmatch(raw); len(m) == 2 {
raw = strings.TrimSpace(m[1])
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("formula output missing json object")
}
return []byte(raw[start : end+1]), nil
}

View File

@ -0,0 +1,34 @@
package ownpost
import "testing"
func TestParsePostFormulaOutputIncludesImprovementsAndTemplate(t *testing.T) {
raw := `{
"summary": "整體互動偏低,但開場有吸引力。",
"wins": ["開場直接點痛點"],
"improvements": ["問題:收尾沒有問句|改善:最後加一句開放式提問引導留言"],
"formula": "Hook→情境→觀點→互動問句",
"post_template": "[痛點一句]\n[親身情境]\n[你的觀點]\n[問句收尾]",
"hook_pattern": "先丟痛點再現身說法",
"structure": "4 段,每段 1-2 句",
"replication_tips": ["下次同主題先寫 Hook 再補故事"],
"avoid": ["不要一次講太多資訊"]
}`
review, err := ParsePostFormulaOutput(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(review.Improvements) != 1 {
t.Fatalf("improvements: got %d", len(review.Improvements))
}
if review.PostTemplate == "" {
t.Fatal("expected post_template")
}
}
func TestTrimStringListDropsEmpty(t *testing.T) {
got := trimStringList([]string{" a ", "", "b"})
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected: %#v", got)
}
}

View File

@ -0,0 +1,226 @@
package ownpost
import (
"context"
"fmt"
"regexp"
"strings"
"unicode/utf8"
domai "haixun-backend/internal/model/ai/domain/usecase"
)
type ReplyDraftInput struct {
AccountName string
PersonaBlock string
PostText string
ReplyText string
ReplyToID string
ThreadPostText string
ParentReplyText string
}
var (
wrapQuoteRE = regexp.MustCompile(`^["'「『](.*)["'」』]$`)
multiBlankRE = regexp.MustCompile(`\n{3,}`)
sentenceEndRE = regexp.MustCompile(`([。!?…!?])\s*`)
aiPhraseRE = regexp.MustCompile(`(?i)(很高興|感謝您的|希望這對您|作為一個|總而言之|綜上所述|不妨考慮|值得注意)`)
)
func isMentionReplyDraft(in ReplyDraftInput) bool {
return strings.TrimSpace(in.ThreadPostText) != "" || strings.TrimSpace(in.ParentReplyText) != ""
}
func BuildReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
if isMentionReplyDraft(in) {
return buildMentionReplyDraftPrompt(in)
}
accountName := strings.TrimSpace(in.AccountName)
if accountName == "" {
accountName = "我"
}
postText := strings.TrimSpace(in.PostText)
if postText == "" {
postText = "(貼文內容未提供)"
}
replyText := strings.TrimSpace(in.ReplyText)
replyToID := strings.TrimSpace(in.ReplyToID)
personaBlock := strings.TrimSpace(in.PersonaBlock)
if personaBlock == "" {
personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
}
system = strings.TrimSpace(`
你是 Threads 貼文作者本人要在自己貼文底下回留言或接續串文
請用台灣繁體中文像真人用手機打字完全不像 AI 或客服
硬規則
1. 人設語氣是最高優先用字節奏距離感價值觀必須符合1. 人設不得變成通用網友或官方口吻
2. 只輸出一則回覆正文不要標題不要 JSON不要用引號包裹不要解釋你怎麼寫
3. 先讀2. 我的貼文掌握語境若有3. 要回覆的留言要直接回應對方說的內容不要自說自話
4. 禁止 AI 不要很高興感謝您的希望這對您有幫助作為一個總而言之綜上所述這類句型
5. 禁止客服業配腔不要硬推不要條列式教學不要一次講太多重點
6. 排版要像真人留言24 個短段落每段 12 段落之間必須換行用實際換行不要整段擠在一起
7. 可口語可省略主詞可用 01 emoji總長度約 40180 最多不超過 280
8. 你是貼文作者本人在回不要假裝是粉絲或路人`)
var b strings.Builder
b.WriteString("【1. 人設】\n")
b.WriteString(personaBlock)
b.WriteString("\n\n帳號名稱")
b.WriteString(accountName)
b.WriteString("\n\n【2. 我的貼文】\n")
b.WriteString(postText)
b.WriteString("\n\n【3. 要回覆的留言】\n")
if replyToID != "" && replyText != "" {
b.WriteString(replyText)
b.WriteString("\n\n請依人設語氣回覆這則留言。")
} else {
b.WriteString("(無特定留言,這是接續自己貼文的補充回覆/串文)")
b.WriteString("\n\n請依人設語氣寫一則自然接續這篇貼文的留言。")
}
user = b.String()
return system, user
}
func buildMentionReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
accountName := strings.TrimSpace(in.AccountName)
if accountName == "" {
accountName = "我"
}
threadText := strings.TrimSpace(in.ThreadPostText)
if threadText == "" {
threadText = strings.TrimSpace(in.PostText)
}
if threadText == "" {
threadText = "(主串貼文內容未提供)"
}
parentText := strings.TrimSpace(in.ParentReplyText)
mentionText := strings.TrimSpace(in.ReplyText)
if mentionText == "" {
mentionText = "(對方 @ 你的內容未提供)"
}
personaBlock := strings.TrimSpace(in.PersonaBlock)
if personaBlock == "" {
personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
}
system = strings.TrimSpace(`
你是 Threads 帳號本人有人在你參與的串文裡 @ 你要回覆對方
請用台灣繁體中文像真人用手機打字完全不像 AI 或客服
硬規則
1. 人設語氣是最高優先用字節奏距離感必須符合1. 人設
2. 先讀2. 主串貼文理解整串在講什麼若有3. 上層留言要知道留言脈絡
3. 你的回覆必須直接回應4. 對方 @ 你的內容不要忽略對方 tag 你的那句話
4. 只輸出一則回覆正文不要標題不要 JSON不要用引號包裹不要解釋你怎麼寫
5. 禁止 AI 腔與客服腔24 個短段落段落間換行 40180 最多 280
6. 你是被 @ 的當事人在回語氣自然有來有往`)
var b strings.Builder
b.WriteString("【1. 人設】\n")
b.WriteString(personaBlock)
b.WriteString("\n\n帳號名稱")
b.WriteString(accountName)
b.WriteString("\n\n【2. 主串貼文】\n")
b.WriteString(threadText)
if parentText != "" {
b.WriteString("\n\n【3. 上層留言】\n")
b.WriteString(parentText)
}
b.WriteString("\n\n【4. 對方 @ 你的內容】\n")
b.WriteString(mentionText)
b.WriteString("\n\n請依人設語氣回覆對方 @ 你的這則內容。")
return system, b.String()
}
func GenerateReplyDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in ReplyDraftInput) (string, error) {
system, user := BuildReplyDraftPrompt(in)
temp := 0.9
req.System = system
req.Messages = []domai.Message{{Role: "user", Content: user}}
req.Temperature = &temp
out, err := ai.GenerateText(ctx, req)
if err != nil {
return "", err
}
text := NormalizeHumanReply(out.Text)
if text == "" {
return "", fmt.Errorf("AI 未回傳回覆草稿")
}
return text, nil
}
// NormalizeHumanReply trims AI artifacts and nudges layout toward short mobile-style paragraphs.
func NormalizeHumanReply(raw string) string {
text := strings.TrimSpace(raw)
if text == "" {
return ""
}
if m := wrapQuoteRE.FindStringSubmatch(text); len(m) == 2 {
text = strings.TrimSpace(m[1])
}
text = strings.ReplaceAll(text, `\n`, "\n")
text = multiBlankRE.ReplaceAllString(text, "\n\n")
text = strings.TrimSpace(text)
if !strings.Contains(text, "\n") {
if sentenceCount(text) >= 2 || utf8.RuneCountInString(text) > 72 {
text = breakLongReply(text)
}
}
if aiPhraseRE.MatchString(text) && !strings.Contains(text, "\n") {
text = breakLongReply(text)
}
return strings.TrimSpace(text)
}
func sentenceCount(text string) int {
n := 0
for _, r := range text {
switch r {
case '。', '', '', '…', '!', '?':
n++
}
}
return n
}
func breakLongReply(text string) string {
parts := sentenceEndRE.Split(text, -1)
seps := sentenceEndRE.FindAllString(text, -1)
if len(parts) <= 1 {
return text
}
var chunks []string
var current strings.Builder
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
sep := ""
if i < len(seps) {
sep = strings.TrimSpace(seps[i])
}
segment := part + sep
if current.Len() == 0 {
current.WriteString(segment)
continue
}
if utf8.RuneCountInString(current.String())+utf8.RuneCountInString(segment) > 42 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
current.WriteString(segment)
continue
}
current.WriteString(segment)
}
if current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
}
if len(chunks) <= 1 {
return text
}
return strings.Join(chunks, "\n")
}

View File

@ -0,0 +1,66 @@
package ownpost
import (
"strings"
"testing"
)
func TestBuildReplyDraftPromptIncludesPersonaPostAndReply(t *testing.T) {
_, user := BuildReplyDraftPrompt(ReplyDraftInput{
AccountName: "小安",
PersonaBlock: "溫柔媽媽,說話慢、會先共情",
PostText: "今天帶小孩好累",
ReplyText: "我也是欸",
ReplyToID: "123",
})
for _, want := range []string{"【1. 人設】", "溫柔媽媽", "【2. 我的貼文】", "今天帶小孩好累", "【3. 要回覆的留言】", "我也是欸"} {
if !strings.Contains(user, want) {
t.Fatalf("missing %q in user prompt:\n%s", want, user)
}
}
}
func TestBuildReplyDraftPromptThreadContinuation(t *testing.T) {
_, user := BuildReplyDraftPrompt(ReplyDraftInput{
PersonaBlock: "生活觀察者",
PostText: "最近在想轉職",
})
if !strings.Contains(user, "接續自己貼文") {
t.Fatalf("expected thread continuation hint, got:\n%s", user)
}
if strings.Contains(user, "【3. 要回覆的留言】\n無特定留言") {
// ok
} else if !strings.Contains(user, "無特定留言") {
t.Fatalf("expected empty reply section marker, got:\n%s", user)
}
}
func TestBuildMentionReplyDraftPromptIncludesThreadAndMention(t *testing.T) {
_, user := BuildReplyDraftPrompt(ReplyDraftInput{
AccountName: "小安",
PersonaBlock: "直率科技人",
ThreadPostText: "今天來分享育兒心得",
ParentReplyText: "我覺得放鬆最重要",
ReplyText: "@小安 你覺得呢?",
ReplyToID: "999",
})
for _, want := range []string{
"【2. 主串貼文】", "今天來分享育兒心得",
"【3. 上層留言】", "我覺得放鬆最重要",
"【4. 對方 @ 你的內容】", "@小安 你覺得呢?",
} {
if !strings.Contains(user, want) {
t.Fatalf("missing %q in mention user prompt:\n%s", want, user)
}
}
}
func TestNormalizeHumanReplyStripsQuotesAndBreaksLines(t *testing.T) {
got := NormalizeHumanReply(`"第一段真的超累到不想講話。第二段後來洗個澡就還好一點。第三段你加油,我們一起撐過這週。"`)
if strings.HasPrefix(got, `"`) {
t.Fatalf("expected quotes stripped, got %q", got)
}
if !strings.Contains(got, "\n") {
t.Fatalf("expected line breaks, got %q", got)
}
}

View File

@ -46,8 +46,5 @@ func publishCapabilityHint(member MemberContext) string {
if strings.TrimSpace(member.ActiveAccountID) == "" {
return "請先建立並選定經營帳號"
}
if member.DevMode && !member.AllowsThreadsAPI {
return "開發模式請在連線設定啟用 API 發文"
}
return "請先完成 Threads API 連線後再發布貼文"
}

View File

@ -101,6 +101,27 @@ func BuildMemberContext(
}
}
// MemberContextForAPIOnly forces Threads / web-search discover and disables Playwright routing.
// Used for all flows except explicit test-patrol jobs.
func MemberContextForAPIOnly(base MemberContext) MemberContext {
base.DevMode = false
base.SearchSourceMode = WithoutCrawler(base.SearchSourceMode)
base.AllowsCrawler = false
base.AllowsThreadsAPI = ModeAllowsThreadsAPI(base.SearchSourceMode)
base.AllowsBrave = ModeAllowsBrave(base.SearchSourceMode)
return base
}
// MemberContextForCrawlerOnly forces Playwright-only discover for test patrol jobs.
func MemberContextForCrawlerOnly(base MemberContext) MemberContext {
base.DevMode = true
base.SearchSourceMode = SearchSourceCrawler
base.AllowsCrawler = true
base.AllowsBrave = false
base.AllowsThreadsAPI = false
return base
}
func (c MemberContext) PayloadFields() map[string]any {
return map[string]any{
"tenant_id": c.TenantID,

View File

@ -0,0 +1,33 @@
package placement
import "testing"
func TestMemberContextForAPIOnlyDisablesCrawler(t *testing.T) {
base := BuildMemberContext(
"t", "u", "acc",
ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
true, true, ResearchSettings{}, false, 10,
)
apiOnly := MemberContextForAPIOnly(base)
if apiOnly.AllowsCrawler {
t.Fatal("API-only context must not allow crawler")
}
if apiOnly.DevMode {
t.Fatal("API-only context must not be dev mode")
}
if !apiOnly.AllowsThreadsAPI {
t.Fatal("API-only context should keep Threads API")
}
}
func TestMemberContextForCrawlerOnlyStillWorks(t *testing.T) {
base := MemberContextForAPIOnly(BuildMemberContext(
"t", "u", "acc",
ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
true, true, ResearchSettings{}, false, 10,
))
crawler := MemberContextForCrawlerOnly(base)
if !crawler.AllowsCrawler || crawler.SearchSourceMode != SearchSourceCrawler {
t.Fatal("test patrol should force crawler-only routing")
}
}

View File

@ -49,13 +49,13 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if ShouldTryCrawlerFirst(m) {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil && len(posts) > 0 {
return posts, DiscoverCrawler, nil
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
if m.SearchSourceMode == SearchSourceCrawler {
if err != nil {
return nil, DiscoverCrawler, err
}
return posts, DiscoverCrawler, nil
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
}
@ -73,7 +73,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if m.CrawlerFallbackAllowed() {
cPosts, cErr := runCrawlerDiscover(ctx, req)
if cErr == nil && len(cPosts) > 0 {
return cPosts, DiscoverCrawler, nil
return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil
}
}
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
@ -89,7 +89,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if m.CrawlerFallbackAllowed() {
posts, err := runCrawlerDiscover(ctx, req)
if err == nil {
return posts, DiscoverCrawler, nil
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
}
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key跟隨此登入帳號", m.WebSearchProviderLabel())
@ -102,8 +102,15 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if err != nil {
return nil, DiscoverCrawler, err
}
return posts, DiscoverCrawler, nil
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
}
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
}
func maybeEnrichDiscoverPosts(ctx context.Context, m MemberContext, posts []DiscoverPost) []DiscoverPost {
if !m.ApiConnected {
return posts
}
return EnrichDiscoverPostsMediaIDs(ctx, m.ThreadsAPIAccessToken, posts)
}

View File

@ -0,0 +1,69 @@
package placement
import (
"context"
"testing"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
type stubWebSearch struct {
enabled bool
posts []DiscoverPost
}
func (s stubWebSearch) Search(ctx context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) {
_ = ctx
_ = opts
results := make([]websearch.SearchResult, 0, len(s.posts))
for _, post := range s.posts {
results = append(results, websearch.SearchResult{
Title: post.Text,
URL: post.Permalink,
Snippet: "",
})
}
return websearch.SearchResponse{Status: "success", Results: results, Provider: websearch.ProviderBrave}, nil
}
func (s stubWebSearch) Enabled() bool { return s.enabled }
func (s stubWebSearch) Provider() websearch.Provider { return websearch.ProviderBrave }
func TestDiscoverForQueryMergesWebSearchWhenBraveEnabled(t *testing.T) {
input := DualTrackInput{
Member: MemberContext{
AllowsBrave: true,
AllowsThreadsAPI: false,
SearchSourceMode: SearchSourceBrave,
WebSearchProvider: string(websearch.ProviderBrave),
BraveAPIKey: "test-key",
},
WebSearch: stubWebSearch{
enabled: true,
posts: []DiscoverPost{{
Text: "最近洗衣精有推薦嗎?皮膚會癢",
Permalink: "https://www.threads.net/@user1/post/abc123",
}},
},
PatrolContext: PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精"},
}, nil),
}
tq := TagQuery{
Tag: "洗衣精 推薦",
Query: `site:threads.net 洗衣精 推薦`,
Dimension: QueryRelevance,
}
posts, channel, err := discoverForQuery(context.Background(), input, tq, 5)
if err != nil {
t.Fatalf("discoverForQuery error: %v", err)
}
if len(posts) == 0 {
t.Fatal("expected web search posts to be merged")
}
if channel != DiscoverBrave {
t.Fatalf("expected brave channel, got %s", channel)
}
}

View File

@ -24,6 +24,7 @@ type ScanCandidate struct {
Text string
SearchTag string
QueryDimension QueryDimension
RecencyDays int
GraphNodeID string
ProductFitScore int
Source DiscoverChannel
@ -50,6 +51,7 @@ type DualTrackInput struct {
Nodes []libkg.Node
PatrolKeywords []string
Exclusions []string
PatrolContext PostScanContext
Member MemberContext
WebSearch websearch.Client
Crawler CrawlerSearchFn
@ -104,17 +106,6 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
RecencyDays: IdealMaxPostAgeDays,
})
}
q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays)
if q30 != "" && q30 != q7 {
out = append(out, TagQuery{
Tag: tag,
Query: q30,
Dimension: QueryRecency,
GraphNodeID: node.ID,
ProductFitScore: fit,
RecencyDays: MaxPostAgeDays,
})
}
}
}
return out
@ -122,7 +113,12 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords, input.Member.WebSearchProviderEnum())
queries := ResolveTagQueries(
input.Nodes,
input.PatrolKeywords,
input.Member.WebSearchProviderEnum(),
PatrolTagInputFromScanContext(input.PatrolContext),
)
if len(queries) == 0 {
if len(input.PatrolKeywords) > 0 {
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句")
@ -145,15 +141,31 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
runQuery := func(tq TagQuery, limit int) error {
posts, channel, err := discoverForQuery(ctx, input, tq, limit)
if err != nil {
return err
if onProgress != nil {
onProgress(fmt.Sprintf("略過「%s」%s", tq.Tag, err.Error()), -1)
}
return nil
}
if len(posts) == 0 {
return nil
}
for _, post := range posts {
if MatchesExclusion(post.Text, input.Exclusions) {
continue
}
if !PassesPlacementFilter(post.Text) {
if LooksLikeCasualChat(post.Text) {
continue
}
postedAt := strings.TrimSpace(post.PostedAt)
if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) {
continue
}
bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext)
semanticScore := LocalIntentScore(post.Text, input.PatrolContext)
effectiveFit := bodyFit
if tq.ProductFitScore > 0 && bodyFit > 0 {
effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3
}
key := post.Permalink
if key == "" {
continue
@ -170,6 +182,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
extID = parsed.ExternalID
}
}
semP := &semanticScore
merged[key] = &ScanCandidate{
Permalink: post.Permalink,
ExternalID: extID,
@ -179,17 +192,19 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
Text: post.Text,
SearchTag: tq.Tag,
QueryDimension: tq.Dimension,
RecencyDays: recencyDaysForCandidate(tq),
GraphNodeID: tq.GraphNodeID,
ProductFitScore: tq.ProductFitScore,
ProductFitScore: effectiveFit,
Source: channel,
HasRelevance: tq.Dimension == QueryRelevance,
HasRecency: tq.Dimension == QueryRecency,
Priority: priority,
LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount,
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil),
SolvedByProduct: tq.ProductFitScore >= 55,
PostedAt: strings.TrimSpace(post.PostedAt),
SemanticScore: semanticScore,
PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
PostedAt: postedAt,
}
order = append(order, key)
continue
@ -199,14 +214,27 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
if tq.Dimension == QueryRecency {
existing.HasRecency = true
existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
}
if tq.ProductFitScore > existing.ProductFitScore {
existing.ProductFitScore = tq.ProductFitScore
existing.SolvedByProduct = tq.ProductFitScore >= 55
if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
if effectiveFit > existing.ProductFitScore {
existing.ProductFitScore = effectiveFit
}
if semanticScore > existing.SemanticScore {
existing.SemanticScore = semanticScore
}
existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext)
semP := existing.SemanticScore
semPtr := &semP
if semP <= 0 {
semPtr = nil
}
existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil)
}
if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
existing.PostedAt = strings.TrimSpace(post.PostedAt)
}
existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
}
return nil
}
@ -217,15 +245,12 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
pct := 10 + ((i + 1) * 75 / max(total, 1))
onProgress(fmt.Sprintf("雙軌海巡 %d/%d%s", i+1, total, tq.Tag), pct)
}
limit := relevanceLimitPerTag
if tq.Dimension == QueryRecency {
limit = recencyLimitPerTag
}
limit := perTagDiscoverLimit(total, tq.Dimension)
if err := runQuery(tq, limit); err != nil {
return nil, err
}
if input.OnCheckpoint != nil {
snapshot := snapshotMergedCandidates(merged, order, false)
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
if err := input.OnCheckpoint(snapshot); err != nil {
return nil, err
}
@ -237,7 +262,48 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
}
out := snapshotMergedCandidates(merged, order, true)
cascadeExtra := 0
for _, st := range buildTagCascadeStates(queries) {
for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag {
if cascadeExtra >= MaxRecencyCascadeQueries {
break
}
days := nextRecencyCascadeWindow(st.RanWindows)
if days == 0 {
break
}
tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum())
if !ok {
st.RanWindows[days] = true
continue
}
if onProgress != nil {
onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1)
}
limit := perTagDiscoverLimit(total, QueryRecency)
if err := runQuery(tq, limit); err != nil {
return nil, err
}
st.RanWindows[days] = true
cascadeExtra++
if input.OnCheckpoint != nil {
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
if err := input.OnCheckpoint(snapshot); err != nil {
return nil, err
}
}
if input.Member.AllowsCrawler && input.Member.BrowserConnected {
if err := politeDiscoverPause(ctx); err != nil {
return nil, err
}
}
}
if cascadeExtra >= MaxRecencyCascadeQueries {
break
}
}
out := snapshotMergedCandidates(merged, order, true, input.PatrolContext)
if onProgress != nil {
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
}
@ -245,32 +311,71 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
}
func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
apiKeyword := tq.Tag
if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" {
apiKeyword = shaped
}
req := DiscoverRequest{
Query: tq.Query,
Keyword: tq.Tag,
Keyword: apiKeyword,
Recency: tq.Dimension == QueryRecency,
Limit: limit,
Member: input.Member,
Crawler: input.Crawler,
}
posts, channel, err := Discover(ctx, req)
if err == nil && len(posts) > 0 {
return posts, channel, nil
merged := map[string]DiscoverPost{}
channel := DiscoverChannel("")
posts, primaryChannel, err := Discover(ctx, req)
for _, post := range posts {
key := strings.TrimSpace(post.Permalink)
if key == "" {
continue
}
if existing, ok := merged[key]; ok {
post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
}
merged[key] = post
}
if input.WebSearch == nil || !input.WebSearch.Enabled() {
if primaryChannel != "" {
channel = primaryChannel
}
webEnabled := input.WebSearch != nil && input.WebSearch.Enabled()
if webEnabled && input.Member.AllowsBrave {
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
if werr != nil && len(merged) == 0 && err != nil {
return nil, "", err
}
for _, post := range webPosts {
key := strings.TrimSpace(post.Permalink)
if key == "" {
continue
}
merged[key] = post
}
if len(merged) > 0 && channel == "" {
channel = input.Member.WebSearchDiscoverChannel()
}
} else if len(merged) == 0 {
if err != nil {
return nil, "", err
}
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
}
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
if werr != nil {
if err != nil {
return nil, "", err
if !webEnabled {
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
}
return nil, "", werr
}
return webPosts, input.Member.WebSearchDiscoverChannel(), nil
out := make([]DiscoverPost, 0, len(merged))
for _, post := range merged {
out = append(out, post)
}
if len(out) > limit {
out = out[:limit]
}
if channel == "" && len(out) > 0 {
channel = DiscoverThreadsAPI
}
return out, channel, nil
}
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
@ -307,20 +412,50 @@ func discoverViaWebSearch(ctx context.Context, client websearch.Client, member M
return out, nil
}
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate {
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate {
out := make([]ScanCandidate, 0, len(order))
for _, key := range order {
item := merged[key]
finalizeScanCandidate(item)
if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" {
finalizeScanCandidate(item, ctx)
if applyFinalFilter && !passesFinalStrictFilter(item) {
continue
}
out = append(out, *item)
}
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
for _, key := range order {
item := merged[key]
finalizeScanCandidate(item, ctx)
if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) {
continue
}
out = append(out, *item)
}
}
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
for _, key := range order {
item := merged[key]
finalizeScanCandidate(item, ctx)
if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) {
continue
}
out = append(out, *item)
}
}
return out
}
func finalizeScanCandidate(item *ScanCandidate) {
func passesFinalStrictFilter(item *ScanCandidate) bool {
if item == nil {
return false
}
if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" {
return false
}
return item.SolvedByProduct
}
func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) {
if item == nil {
return
}
@ -345,7 +480,7 @@ func finalizeScanCandidate(item *ScanCandidate) {
qualP = &v
}
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
item.SolvedByProduct = item.ProductFitScore >= 55
item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx)
}
func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
@ -413,6 +548,55 @@ func max(a, b int) int {
return b
}
func recencyDaysForCandidate(tq TagQuery) int {
if tq.Dimension != QueryRecency {
return 0
}
if tq.RecencyDays > 0 {
return tq.RecencyDays
}
return IdealMaxPostAgeDays
}
func mergeRecencyDays(existing, incoming int) int {
if incoming <= 0 {
return existing
}
if existing <= 0 {
return incoming
}
if incoming < existing {
return incoming
}
return existing
}
func previousRecencyWindow(days int) int {
prev := IdealMaxPostAgeDays
for _, window := range RecencyCascadeDays() {
if window == days {
return prev
}
prev = window
}
return IdealMaxPostAgeDays
}
func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int {
limit := relevanceLimitPerTag
if dimension == QueryRecency {
limit = recencyLimitPerTag
}
switch {
case totalQueries > 18:
return max(7, limit-3)
case totalQueries > 14:
return max(8, limit-2)
default:
return limit
}
}
func politeDiscoverPause(ctx context.Context) error {
wait := 2*time.Second + jitterDuration(2*time.Second)
timer := time.NewTimer(wait)

View File

@ -0,0 +1,50 @@
package placement
import (
"context"
"strings"
libthreads "haixun-backend/internal/library/threadsapi"
)
// PreferReplyExternalID keeps a numeric Threads media id when merging scan rows.
func PreferReplyExternalID(existing, incoming string) string {
a := strings.TrimSpace(existing)
b := strings.TrimSpace(incoming)
if libthreads.IsNumericMediaID(a) {
return a
}
if libthreads.IsNumericMediaID(b) {
return b
}
if b != "" {
return b
}
return a
}
// EnrichDiscoverPostsMediaIDs resolves shortcode-only crawler rows via Threads API when possible.
func EnrichDiscoverPostsMediaIDs(ctx context.Context, accessToken string, posts []DiscoverPost) []DiscoverPost {
token := strings.TrimSpace(accessToken)
if token == "" || len(posts) == 0 {
return posts
}
client := libthreads.NewClient(token)
out := make([]DiscoverPost, len(posts))
copy(out, posts)
for i := range out {
if libthreads.IsNumericMediaID(out[i].ExternalID) {
continue
}
id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
ExternalID: out[i].ExternalID,
Permalink: out[i].Permalink,
HintText: out[i].Text,
})
if err != nil || !libthreads.IsNumericMediaID(id) {
continue
}
out[i].ExternalID = id
}
return out
}

View File

@ -0,0 +1,14 @@
package placement
import "testing"
func TestPreferReplyExternalIDKeepsNumeric(t *testing.T) {
got := PreferReplyExternalID("18123456789012345", "AbCdEf123")
if got != "18123456789012345" {
t.Fatalf("got %q", got)
}
got = PreferReplyExternalID("AbCdEf123", "18123456789012345")
if got != "18123456789012345" {
t.Fatalf("got %q", got)
}
}

View File

@ -5,6 +5,7 @@ import "regexp"
var (
placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`)
casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
)
@ -35,3 +36,11 @@ func PassesPlacementFilter(text string) bool {
}
return HasPlacementIntent(text)
}
// HasPainPointDemand requires the post to read like someone asking for help with a solvable problem.
func HasPainPointDemand(text string) bool {
if !PassesPlacementFilter(text) {
return false
}
return painProblemRe.MatchString(text) || LooksLikeRecommendationPost(text)
}

View File

@ -0,0 +1,57 @@
package placement
import (
"context"
"strings"
libthreads "haixun-backend/internal/library/threadsapi"
)
type ReplyMediaResolveInput struct {
AccessToken string
StorageState string
ExternalID string
Permalink string
HintText string
}
// ResolveReplyMediaWithFallback resolves a Threads reply target id via API and permalink scraping.
func ResolveReplyMediaWithFallback(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
return resolveReplyMedia(ctx, in, false)
}
// ResolveReplyMediaForPublish prefers fast permalink scraping, then API lookup.
func ResolveReplyMediaForPublish(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
return resolveReplyMedia(ctx, in, true)
}
func resolveReplyMedia(ctx context.Context, in ReplyMediaResolveInput, publishFastPath bool) (string, error) {
if libthreads.IsNumericMediaID(in.ExternalID) {
return strings.TrimSpace(in.ExternalID), nil
}
permalink := strings.TrimSpace(in.Permalink)
if publishFastPath && permalink != "" {
if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
return scraped, nil
}
}
client := libthreads.NewClient(in.AccessToken)
id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
ExternalID: in.ExternalID,
Permalink: in.Permalink,
HintText: in.HintText,
})
if err == nil && libthreads.IsNumericMediaID(id) {
return id, nil
}
if !publishFastPath && permalink != "" {
if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
return scraped, nil
}
}
return "", err
}

View File

@ -23,8 +23,14 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
if len(match) < 3 {
return ParsedThreadsPost{}, false
}
text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — "))
if len([]rune(text)) < 8 {
text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — "))
if text == "" {
text = strings.TrimSpace(title)
}
if text == "" {
text = strings.TrimSpace(snippet)
}
if len([]rune(text)) < 6 {
return ParsedThreadsPost{}, false
}
return ParsedThreadsPost{
@ -35,6 +41,16 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
}, true
}
func filterNonEmpty(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item) != "" {
out = append(out, item)
}
}
return out
}
func normalizeThreadsPermalink(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {

View File

@ -1,24 +1,31 @@
package placement
import (
"sort"
"strings"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
const defaultPatrolProductFit = 78
const (
defaultPatrolProductFitNoGraph = 42
maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
)
// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only.
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery {
// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
keywords = libkg.NormalizePatrolKeywordList(keywords)
if len(keywords) == 0 {
return nil
}
if len(keywords) > libkg.MaxScanPatrolKeywords {
keywords = keywords[:libkg.MaxScanPatrolKeywords]
}
out := make([]TagQuery, 0, len(keywords)*3)
out := make([]TagQuery, 0, len(keywords)*2)
for _, tag := range keywords {
fit := productFitForPatrolTag(tag, nodes)
fit := productFitForPatrolTag(tag, nodes, patrolInput)
if q := BuildRelevanceQuery(provider, tag); q != "" {
out = append(out, TagQuery{
Tag: tag,
@ -36,25 +43,15 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider web
RecencyDays: IdealMaxPostAgeDays,
})
}
if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" {
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 {
out = append(out, TagQuery{
Tag: tag,
Query: q30,
Dimension: QueryRecency,
ProductFitScore: fit,
RecencyDays: MaxPostAgeDays,
})
}
}
}
return out
}
func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
tagKey := patrolTagMatchKey(tag)
best := 0
bestNodeID := ""
profile := libkg.BuildIntentProfile(patrolInput)
intentFit := libkg.ScoreIntentSimilarity(tag, profile)
for _, node := range nodes {
score := node.ProductFitScore
if score <= 0 {
@ -72,14 +69,25 @@ func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
}
if score > best {
best = score
bestNodeID = node.ID
}
}
if best > 0 {
return best
return maxPatrolFit(best, intentFit)
}
_ = bestNodeID
return defaultPatrolProductFit
if intentFit > 0 {
return intentFit
}
return defaultPatrolProductFitNoGraph
}
func maxPatrolFit(values ...int) int {
best := 0
for _, v := range values {
if v > best {
best = v
}
}
return best
}
func patrolTagMatchKey(tag string) string {
@ -93,10 +101,66 @@ func patrolTagMatchKey(tag string) string {
return strings.TrimSpace(tag)
}
// ResolveTagQueries prefers explicit patrol keywords over graph node selection.
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery {
if len(patrolKeywords) > 0 {
return CollectPatrolTagQueries(patrolKeywords, nodes, provider)
// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
keywords := patrolKeywords
if len(keywords) == 0 {
keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords)
}
return CollectTagQueries(nodes, provider)
queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput)
if len(queries) == 0 {
queries = CollectTagQueries(nodes, provider)
}
return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput)
}
func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery {
profile := libkg.BuildIntentProfile(patrolInput)
if max <= 0 || len(queries) <= max {
return queries
}
type ranked struct {
query TagQuery
key string
}
rankedQueries := make([]ranked, 0, len(queries))
seen := map[string]struct{}{}
for _, item := range queries {
key := strings.TrimSpace(item.Query)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
rankedQueries = append(rankedQueries, ranked{query: item, key: key})
}
sort.SliceStable(rankedQueries, func(i, j int) bool {
left := rankedQueries[i].query.ProductFitScore
right := rankedQueries[j].query.ProductFitScore
leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile)
rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile)
leftRank := left*2 + leftIntent*3
rightRank := right*2 + rightIntent*3
if leftRank == rightRank {
if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension {
return rankedQueries[i].key < rankedQueries[j].key
}
if rankedQueries[i].query.Dimension == QueryRelevance {
return true
}
return false
}
return leftRank > rightRank
})
out := make([]TagQuery, 0, max)
for _, item := range rankedQueries {
out = append(out, item.query)
if len(out) >= max {
break
}
}
return out
}

View File

@ -8,15 +8,15 @@ import (
)
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave)
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) < 2 {
t.Fatalf("expected relevance + recency queries, got %d", len(queries))
}
if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance {
t.Fatalf("unexpected first query: %+v", queries[0])
}
if queries[0].ProductFitScore != defaultPatrolProductFit {
t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore)
if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph {
t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFitNoGraph, queries[0].ProductFitScore)
}
}
@ -31,7 +31,7 @@ func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) {
},
},
}
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave)
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) == 0 || queries[0].ProductFitScore != 92 {
t.Fatalf("expected graph fit 92, got %+v", queries)
}
@ -41,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
nodes := []libkg.Node{
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
}
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave)
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
t.Fatalf("expected patrol keyword query, got %+v", queries)
}

View File

@ -0,0 +1,534 @@
package placement
import (
"strings"
"time"
"unicode"
libkg "haixun-backend/internal/library/knowledge"
)
const (
minPostBodyProductFit = 50
minPostBodyProductFitDiscover = 28
minPostProductFitPatrol = 58
minPostProductFitDiscover = 34
minPostProductFitRelevant = 62
minPostProductFitRelaxedFinal = 38
minPostProductFitIngestFinal = 28
minPostPillarAnchor = 1
patrolMaxPostAgeDays = IdealMaxPostAgeDays
relevanceMaxPostAgeDays = 14
)
// PostScanContext carries research map + product signals for post-level filtering.
type PostScanContext struct {
MatchTags []string
ProductName string
ProductFeatures string
AudienceSummary string
TargetAudience string
Questions []string
Pillars []string
Exclusions []string
}
func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext {
return PostScanContext{
MatchTags: append([]string{}, in.MatchTags...),
ProductName: strings.TrimSpace(in.ProductName),
ProductFeatures: strings.TrimSpace(in.ProductFeatures),
AudienceSummary: strings.TrimSpace(in.AudienceSummary),
TargetAudience: strings.TrimSpace(in.TargetAudience),
Questions: append([]string{}, in.Questions...),
Pillars: append([]string{}, in.Pillars...),
Exclusions: ExpandExclusionTokens(exclusions),
}
}
func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return 0
}
tagScore := ScoreProductForTag(searchTag, ctx.MatchTags)
if tagScore == 0 && ctx.ProductName != "" {
tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName})
}
postScore := ScoreProductForTag(text, ctx.MatchTags)
if postScore == 0 && ctx.ProductName != "" {
postScore = scoreTextAgainstPhrase(text, ctx.ProductName)
}
if ctx.ProductFeatures != "" {
postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures))
}
for _, pillar := range ctx.Pillars {
postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2)
}
combined := postScore
if tagScore > 0 && postScore > 0 {
combined = (tagScore*2 + postScore*3) / 5
} else if postScore > 0 {
combined = postScore
} else if tagScore > 0 {
combined = tagScore / 2
}
if HasCategoryConflict(text, ctx) {
return minInt(combined, 25)
}
if HasTangentialTopicMismatch(text, ctx) {
return minInt(combined, 20)
}
intentScore := LocalIntentScore(text, ctx)
if intentScore > combined {
combined = (combined*2 + intentScore*3) / 5
}
return combined
}
// PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API.
// Stricter ranking happens at finalize.
func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if MatchesExclusion(text, ctx.Exclusions) {
return false
}
if HasCategoryConflict(text, ctx) {
return false
}
if HasTangentialTopicMismatch(text, ctx) {
return false
}
if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
return false
}
if !PassesPostAge(postedAt, dimension, recencyDays) {
return false
}
bodyFit := ScorePostBodyProductFit(text, ctx)
effectiveFit := bodyFit
if tagFit > 0 && bodyFit > 0 {
effectiveFit = (tagFit + bodyFit*2) / 3
} else if tagFit > 0 && HasPlacementIntent(text) {
effectiveFit = tagFit / 2
}
if bodyFit < minPostBodyProductFitDiscover {
if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) {
return false
}
}
return effectiveFit >= minPostProductFitDiscover ||
(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal)
}
func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if MatchesExclusion(text, ctx.Exclusions) {
return false
}
if HasCategoryConflict(text, ctx) {
return false
}
if HasTangentialTopicMismatch(text, ctx) {
return false
}
if !HasPainPointDemand(text) {
return false
}
if !PassesPostAge(postedAt, dimension, 0) {
return false
}
bodyFit := ScorePostBodyProductFit(text, ctx)
if bodyFit < minPostBodyProductFit {
return false
}
if !ProductSolvesPostPain(text, ctx) {
return false
}
effectiveFit := bodyFit
if tagFit > 0 && bodyFit > 0 {
effectiveFit = (tagFit + bodyFit*2) / 3
}
minFit := minPostProductFitPatrol
if dimension == QueryRelevance {
minFit = minPostProductFitRelevant
}
return effectiveFit >= minFit
}
// ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag).
func ScorePostBodyProductFit(text string, ctx PostScanContext) int {
return ScorePostProductFit(text, "", ctx)
}
// ProductSolvesPostPain checks pain in the post aligns with what the product can solve.
func ProductSolvesPostPain(text string, ctx PostScanContext) bool {
if HasCategoryConflict(text, ctx) {
return false
}
if HasTangentialTopicMismatch(text, ctx) {
return false
}
if matchesTopicAnchor(text, ctx) {
return true
}
bodyFit := ScorePostBodyProductFit(text, ctx)
if bodyFit < minPostBodyProductFit {
return false
}
for _, q := range ctx.Questions {
if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit {
return true
}
}
if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 {
return true
}
for _, tag := range ctx.MatchTags {
if scoreTextAgainstPhrase(text, tag) >= 55 {
return true
}
}
return LocalIntentScore(text, ctx) >= minPostBodyProductFit
}
// PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch.
func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool {
return effectiveFit >= minPostProductFitPatrol &&
HasPainPointDemand(text) &&
ProductSolvesPostPain(text, ctx) &&
!HasCategoryConflict(text, ctx) &&
!HasTangentialTopicMismatch(text, ctx)
}
// PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results.
func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if MatchesExclusion(text, ctx.Exclusions) {
return false
}
if HasCategoryConflict(text, ctx) {
return false
}
if HasTangentialTopicMismatch(text, ctx) {
return false
}
if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
return false
}
return effectiveFit >= minPostProductFitRelaxedFinal
}
// PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero.
func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if MatchesExclusion(text, ctx.Exclusions) {
return false
}
if HasCategoryConflict(text, ctx) {
return false
}
if !HasPlacementIntent(text) && !HasPainPointDemand(text) {
return false
}
return effectiveFit >= minPostProductFitIngestFinal
}
func matchesTopicAnchor(text string, ctx PostScanContext) bool {
text = strings.ToLower(text)
hits := 0
for _, pillar := range ctx.Pillars {
if tokenHits(text, pillar) > 0 {
hits++
}
}
for _, q := range ctx.Questions {
if tokenHits(text, q) >= 2 {
hits++
}
}
if hits >= minPostPillarAnchor {
return true
}
for _, tag := range ctx.MatchTags {
if tokenHits(text, tag) > 0 {
return true
}
}
if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 {
return true
}
for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} {
if tokenHits(text, phrase) >= 2 {
return true
}
}
return false
}
func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool {
postedAt = strings.TrimSpace(postedAt)
if postedAt == "" {
// Web Search snippets often lack dates; do not drop at ingest.
return true
}
maxDays := patrolMaxPostAgeDays
switch dimension {
case QueryRelevance:
maxDays = relevanceMaxPostAgeDays
case QueryRecency:
if recencyDays > 0 {
maxDays = recencyDays
}
}
return IsPostWithinMaxAge(postedAt, maxDays, time.Now())
}
func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool {
if maxDays <= 0 {
return true
}
ts, ok := ParsePostedAt(postedAt)
if !ok {
return true
}
if now.IsZero() {
now = time.Now()
}
cutoff := now.AddDate(0, 0, -maxDays)
return !ts.Before(cutoff)
}
func ParsePostedAt(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
"2006-01-02",
"2006/01/02",
}
for _, layout := range layouts {
if ts, err := time.Parse(layout, raw); err == nil {
return ts, true
}
}
return time.Time{}, false
}
func ExpandExclusionTokens(exclusions []string) []string {
seen := map[string]struct{}{}
out := []string{}
add := func(token string) {
token = strings.TrimSpace(strings.ToLower(token))
if len([]rune(token)) < 2 {
return
}
if _, ok := seen[token]; ok {
return
}
seen[token] = struct{}{}
out = append(out, token)
}
for _, rule := range exclusions {
rule = strings.TrimSpace(rule)
if rule == "" {
continue
}
add(rule)
for _, token := range tokenizePhrase(rule) {
add(token)
}
for _, synonym := range exclusionSynonyms(rule) {
add(synonym)
}
}
return out
}
func exclusionSynonyms(rule string) []string {
rule = strings.ToLower(rule)
out := []string{}
for _, group := range exclusionCategoryGroups {
if strings.Contains(rule, group.key) {
out = append(out, group.tokens...)
}
}
return out
}
var exclusionCategoryGroups = []struct {
key string
tokens []string
}{
{key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}},
{key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}},
{key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}},
{key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}},
{key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}},
{key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}},
}
var productCategoryGroups = []struct {
key string
tokens []string
}{
{key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}},
{key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}},
{key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}},
{key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}},
{key: "清潔", tokens: []string{"清潔", "打掃", "去污"}},
}
func productCategories(ctx PostScanContext) []string {
blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " "))
found := []string{}
for _, group := range productCategoryGroups {
for _, token := range group.tokens {
if strings.Contains(blob, token) {
found = append(found, group.key)
break
}
}
}
return found
}
func postCategories(text string) []string {
text = strings.ToLower(text)
found := []string{}
for _, group := range productCategoryGroups {
for _, token := range group.tokens {
if strings.Contains(text, token) {
found = append(found, group.key)
break
}
}
}
return found
}
var genericProductCategories = map[string]struct{}{
"清潔": {},
}
func HasCategoryConflict(text string, ctx PostScanContext) bool {
productCats := productCategories(ctx)
if len(productCats) == 0 {
return false
}
postCats := postCategories(text)
if len(postCats) == 0 {
return false
}
productSet := map[string]struct{}{}
for _, c := range productCats {
productSet[c] = struct{}{}
}
for _, c := range postCats {
if _, ok := genericProductCategories[c]; ok {
continue
}
if _, ok := productSet[c]; !ok {
return true
}
}
return false
}
func scoreTextAgainstPhrase(text, phrase string) int {
text = strings.ToLower(text)
phrase = strings.TrimSpace(strings.ToLower(phrase))
if text == "" || phrase == "" {
return 0
}
if strings.Contains(text, phrase) {
return 85
}
best := 0
for _, token := range tokenizePhrase(phrase) {
if len([]rune(token)) < 2 {
continue
}
if strings.Contains(text, token) {
best = maxInt(best, 55)
}
}
return best
}
func tokenHits(text, phrase string) int {
text = strings.ToLower(text)
hits := 0
for _, token := range tokenizePhrase(phrase) {
if len([]rune(token)) < 2 {
continue
}
if strings.Contains(text, token) {
hits++
}
}
return hits
}
func tokenizePhrase(phrase string) []string {
phrase = strings.ToLower(strings.TrimSpace(phrase))
if phrase == "" {
return nil
}
repl := strings.NewReplacer("", " ", "、", " ", "。", " ", "", " ", "/", " ", "", " ", "|", " ", "", " ", "", " ", "(", " ", ")", " ")
phrase = repl.Replace(phrase)
var tokens []string
for _, part := range strings.Fields(phrase) {
part = strings.Trim(part, `"'「」『』::`)
if part != "" {
tokens = append(tokens, part)
}
}
if len(tokens) == 0 {
return splitRunes(phrase)
}
return tokens
}
func splitRunes(s string) []string {
var out []string
var buf []rune
flush := func() {
if len(buf) >= 2 {
out = append(out, string(buf))
}
buf = buf[:0]
}
for _, r := range s {
if unicode.Is(unicode.Han, r) {
buf = append(buf, r)
continue
}
flush()
}
flush()
return out
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@ -0,0 +1,148 @@
package placement
import (
"testing"
"time"
libkg "haixun-backend/internal/library/knowledge"
)
func TestHasCategoryConflictLaundryVsDish(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精", "衣物清潔"},
}, nil)
text := "請問大家洗碗機清潔劑有推薦嗎?碗盤油污洗不掉"
if !HasCategoryConflict(text, ctx) {
t.Fatal("expected dishwasher post to conflict with laundry product")
}
if PassesPostScanFilters(text, "洗衣精 推薦", "", QueryRecency, 80, ctx) {
t.Fatal("expected conflicting post to be filtered out")
}
}
func TestPassesPostScanFiltersAcceptsMatchingLaundry(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精", "衣物"},
Pillars: []string{"衣物清潔", "敏感皮膚洗衣"},
}, []string{"洗碗精等其他品類"})
text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
if !PassesPostScanFilters(text, "洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 70, ctx) {
t.Fatal("expected matching laundry post to pass")
}
}
func TestPassesPostAgeAllowsMissingDateOnRelevance(t *testing.T) {
if !PassesPostAge("", QueryRelevance, 0) {
t.Fatal("expected missing posted_at to pass age gate at ingest")
}
}
func TestPassesDiscoverFiltersAllowsWebSnippetWithoutDate(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精", "衣物"},
}, nil)
text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
if !PassesDiscoverFilters(text, "洗衣精 推薦", "", QueryRelevance, 70, 0, ctx) {
t.Fatal("expected web snippet without date to pass discover filters")
}
}
func TestPassesPostAgeRejectsOldRelevancePost(t *testing.T) {
old := time.Now().AddDate(0, 0, -20).Format("2006-01-02")
if PassesPostAge(old, QueryRelevance, 0) {
t.Fatal("expected old relevance post to fail age gate")
}
}
func TestHasPainPointDemandRejectsCasualChat(t *testing.T) {
if HasPainPointDemand("今天天氣真好,晒照給大家看") {
t.Fatal("expected casual chat without demand to fail")
}
}
func TestHasPainPointDemandAcceptsHelpSeeking(t *testing.T) {
text := "最近皮膚一直癢,沐浴乳有推薦嗎?"
if !HasPainPointDemand(text) {
t.Fatal("expected help-seeking post with pain signal to pass")
}
}
func TestPostSolvedByProductRejectsCategoryMismatch(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精"},
}, nil)
text := "洗碗精有推薦嗎?碗盤油污洗不掉"
if PostSolvedByProduct(text, 80, ctx) {
t.Fatal("expected dish post not to be solved by laundry product")
}
}
func TestHasTangentialTopicMismatchRejectsWashingMachine(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏、適合敏感肌與化療族群",
MatchTags: []string{"無香", "抗敏", "洗衣精"},
Pillars: []string{"化療後衣物清潔", "無香洗衣"},
}, nil)
text := "請問大家有推薦的洗衣機嗎?想買洗脫烘"
if !HasTangentialTopicMismatch(text, ctx) {
t.Fatal("expected washing machine post to be tangential mismatch for hypoallergenic detergent")
}
if PassesPostScanFilters(text, "無香 洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 80, ctx) {
t.Fatal("expected washing machine post to be filtered out")
}
}
func TestHasTangentialTopicMismatchAcceptsDetergentPain(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏",
MatchTags: []string{"無香", "洗衣精"},
}, nil)
text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
if HasTangentialTopicMismatch(text, ctx) {
t.Fatal("expected detergent pain post to pass tangential check")
}
}
func TestHasTangentialTopicMismatchAcceptsMachineWithDetergentNeed(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "抗敏無香洗衣精",
ProductFeatures: "完全無香、抗敏",
MatchTags: []string{"無香", "洗衣精"},
}, nil)
text := "買了洗衣機,但想找不會讓皮膚癢的無香洗衣精"
if HasTangentialTopicMismatch(text, ctx) {
t.Fatal("expected combined machine + detergent pain post to pass")
}
}
func TestPostSolvedByProductAcceptsAlignedDemand(t *testing.T) {
ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
ProductName: "天然洗衣精",
MatchTags: []string{"洗衣精", "衣物"},
Pillars: []string{"衣物清潔"},
}, nil)
text := "衣服污漬洗不掉,洗衣精有推薦嗎?"
if !PostSolvedByProduct(text, 70, ctx) {
t.Fatal("expected aligned laundry demand to be solved by product")
}
}
func TestExpandExclusionTokensIncludesDishSynonyms(t *testing.T) {
tokens := ExpandExclusionTokens([]string{"洗碗精等其他品類"})
found := false
for _, token := range tokens {
if token == "洗碗機" || token == "洗碗精" {
found = true
break
}
}
if !found {
t.Fatalf("expected dish synonyms, got %v", tokens)
}
}

View File

@ -60,7 +60,8 @@ func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag st
if len(brand.Products) > 0 {
picked := RecommendProduct(brand.Products, searchTag, productID)
if picked != nil {
return BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
merged := BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
return ApplyPlacementURL(merged, picked.PlacementURL)
}
}
if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" {

View File

@ -95,6 +95,20 @@ func FormatProductContextForPrompt(raw string) string {
return strings.Join(lines, "\n")
}
func ApplyPlacementURL(productContext, placementURL string) string {
placementURL = strings.TrimSpace(placementURL)
if placementURL == "" {
return productContext
}
fields := ParseProductContext(productContext)
if fields.CtaType == CtaLink && strings.TrimSpace(fields.CtaUrl) != "" {
return productContext
}
fields.CtaType = CtaLink
fields.CtaUrl = placementURL
return SerializeProductContext(fields)
}
func ProductBriefFromContext(raw string) string {
formatted := FormatProductContextForPrompt(raw)
if formatted != "" {

View File

@ -0,0 +1,25 @@
package placement
import (
"strings"
"testing"
)
func TestApplyPlacementURL(t *testing.T) {
raw := `{"brand":"ecostore","product":"沐浴露","features":"無香抗敏"}`
got := ApplyPlacementURL(raw, "https://shop.example.com/body-wash")
formatted := ProductBriefFromContext(got)
if !strings.Contains(formatted, "留言 CTA 連結") || !strings.Contains(formatted, "https://shop.example.com/body-wash") {
t.Fatalf("formatted brief = %q", formatted)
}
existing := SerializeProductContext(ProductContextFields{
Brand: "ecostore",
Product: "沐浴露",
CtaType: CtaLink,
CtaUrl: "https://existing.example.com",
})
if changed := ApplyPlacementURL(existing, "https://new.example.com"); changed != existing {
t.Fatalf("should not override existing cta url")
}
}

View File

@ -2,6 +2,8 @@ package placement
import (
"strings"
brandentity "haixun-backend/internal/model/brand/domain/entity"
)
func ScoreProductForTag(searchTag string, matchTags []string) int {
@ -40,6 +42,71 @@ func ScoreProductForTag(searchTag string, matchTags []string) int {
return best
}
// ScoreProductForPost ranks a SKU against the search tag and post body.
func ScoreProductForPost(productLabel string, matchTags []string, searchTag, postText string) int {
tagScore := ScoreProductForTag(searchTag, matchTags)
if tagScore == 0 && strings.TrimSpace(productLabel) != "" {
tagScore = ScoreProductForTag(searchTag, []string{productLabel})
}
postScore := ScoreProductForTag(postText, matchTags)
if postScore == 0 && strings.TrimSpace(productLabel) != "" {
postScore = ScoreProductForTag(postText, []string{productLabel})
}
for _, token := range categoryTokens(productLabel, matchTags) {
if strings.Contains(strings.ToLower(postText), token) {
postScore = maxInt(postScore, 78)
}
if strings.Contains(strings.ToLower(searchTag), token) {
tagScore = maxInt(tagScore, 82)
}
}
switch {
case tagScore > 0 && postScore > 0:
return (tagScore*2 + postScore*3) / 5
case postScore > 0:
return postScore
default:
return tagScore
}
}
// RecommendProductForPost picks the best SKU for a single post.
func RecommendProductForPost(products []brandentity.Product, searchTag, postText, defaultProductID string) *brandentity.Product {
if len(products) == 0 {
return nil
}
bestScore := 0
var best *brandentity.Product
for i := range products {
score := ScoreProductForPost(products[i].Label, products[i].MatchTags, searchTag, postText)
if score > bestScore {
bestScore = score
best = &products[i]
}
}
if best != nil && bestScore >= 55 {
return best
}
return RecommendProduct(products, searchTag, defaultProductID)
}
func categoryTokens(productLabel string, matchTags []string) []string {
blob := strings.ToLower(strings.TrimSpace(productLabel + " " + strings.Join(matchTags, " ")))
tokens := []string{
"洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
"沐浴乳", "沐浴露",
"洗髮精", "洗髮乳",
"洗碗精", "洗碗劑",
}
out := make([]string, 0, 4)
for _, token := range tokens {
if strings.Contains(blob, token) {
out = append(out, token)
}
}
return out
}
func maxInt(a, b int) int {
if a > b {
return a

View File

@ -0,0 +1,39 @@
package placement
import (
"testing"
brandentity "haixun-backend/internal/model/brand/domain/entity"
)
func TestRecommendProductForPostPicksLaundryOverDefaultBodyWash(t *testing.T) {
products := []brandentity.Product{
{ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
{ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
}
post := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
got := RecommendProductForPost(products, "洗衣精 推薦", post, "body")
if got == nil || got.ID != "laundry" {
id := ""
if got != nil {
id = got.ID
}
t.Fatalf("expected laundry product, got %q", id)
}
}
func TestRecommendProductForPostPicksBodyWashForShowerPost(t *testing.T) {
products := []brandentity.Product{
{ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
{ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
}
post := "化療後皮膚很乾,沐浴乳有推薦嗎?想要無香味"
got := RecommendProductForPost(products, "沐浴乳 推薦", post, "laundry")
if got == nil || got.ID != "body" {
id := ""
if got != nil {
id = got.ID
}
t.Fatalf("expected body wash product, got %q", id)
}
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"time"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch"
)
@ -25,26 +26,37 @@ type TagQuery struct {
}
func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag))
if full == "" {
return ""
}
return fmt.Sprintf("Threads 貼文 繁體中文 %s", full)
}
core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
if core == "" {
core = strings.TrimSpace(tag)
}
if core == "" {
return ""
}
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
}
return `site:threads.net "` + tag + `"`
// Unquoted site search matches more real Threads posts than exact long phrases.
return `site:threads.net ` + core + ` 推薦`
}
func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
tag = strings.TrimSpace(tag)
if tag == "" {
core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
if core == "" {
core = strings.TrimSpace(tag)
}
if core == "" {
return ""
}
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag)
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core)
}
after := FormatAfterDate(maxAgeDays, timeNow())
return `site:threads.net "` + tag + `" 請問 after:` + after
return `site:threads.net "` + core + `" 請問 after:` + after
}
func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string {

View File

@ -0,0 +1,32 @@
package placement
import (
"strings"
"testing"
"haixun-backend/internal/library/websearch"
)
func TestBuildRelevanceQueryUsesBroadSiteSearch(t *testing.T) {
q := BuildRelevanceQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦")
if !strings.HasPrefix(q, "site:threads.net ") {
t.Fatalf("unexpected query %q", q)
}
if strings.Contains(q, `"`) {
t.Fatalf("expected unquoted broad query, got %q", q)
}
if !strings.Contains(q, "推薦") {
t.Fatalf("expected intent marker in query, got %q", q)
}
}
func TestBuildRecencyQueryUsesShortCore(t *testing.T) {
q := BuildRecencyQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦 請問", 7)
if !strings.Contains(q, "請問 after:") {
t.Fatalf("expected recency date filter, got %q", q)
}
quoted := strings.Trim(q, `site:threads.net `)
if strings.Contains(quoted, "推薦") {
t.Fatalf("expected short quoted core without 推薦, got %q", q)
}
}

View File

@ -6,8 +6,18 @@ const (
MaxPostAgeDays = 30
IdealMaxPostAgeDays = 7
WebSearchMaxAgeDays = 14
// MinRecencyCandidatesPerTag triggers wider recency windows when a keyword finds fewer posts.
MinRecencyCandidatesPerTag = 1
// MaxRecencyCascadeQueries caps extra Search API calls per patrol run.
MaxRecencyCascadeQueries = 10
)
// RecencyCascadeDays is the patrol recency ladder: nearest window first, then shift older.
func RecencyCascadeDays() []int {
return []int{IdealMaxPostAgeDays, WebSearchMaxAgeDays, MaxPostAgeDays}
}
func FormatAfterDate(maxAgeDays int, now time.Time) string {
if now.IsZero() {
now = time.Now()

View File

@ -0,0 +1,89 @@
package placement
import (
"haixun-backend/internal/library/websearch"
)
type tagCascadeState struct {
Tag string
GraphNodeID string
ProductFitScore int
RanWindows map[int]bool
}
func buildTagCascadeStates(queries []TagQuery) []*tagCascadeState {
states := map[string]*tagCascadeState{}
for _, q := range queries {
key := patrolTagMatchKey(q.Tag)
if key == "" {
continue
}
st, ok := states[key]
if !ok {
st = &tagCascadeState{
Tag: q.Tag,
RanWindows: map[int]bool{},
}
states[key] = st
}
if q.GraphNodeID != "" {
st.GraphNodeID = q.GraphNodeID
}
if q.ProductFitScore > st.ProductFitScore {
st.ProductFitScore = q.ProductFitScore
}
if q.Dimension == QueryRecency && q.RecencyDays > 0 {
st.RanWindows[q.RecencyDays] = true
}
}
out := make([]*tagCascadeState, 0, len(states))
for _, st := range states {
out = append(out, st)
}
return out
}
func countCandidatesForTag(merged map[string]*ScanCandidate, tag string) int {
key := patrolTagMatchKey(tag)
if key == "" {
return 0
}
count := 0
for _, item := range merged {
if item == nil {
continue
}
if patrolTagMatchKey(item.SearchTag) == key {
count++
}
}
return count
}
func nextRecencyCascadeWindow(ran map[int]bool) int {
for _, days := range RecencyCascadeDays() {
if ran[days] {
continue
}
return days
}
return 0
}
func buildRecencyCascadeQuery(st *tagCascadeState, days int, provider websearch.Provider) (TagQuery, bool) {
if st == nil || days <= 0 {
return TagQuery{}, false
}
query := BuildRecencyQuery(provider, st.Tag, days)
if query == "" {
return TagQuery{}, false
}
return TagQuery{
Tag: st.Tag,
Query: query,
Dimension: QueryRecency,
GraphNodeID: st.GraphNodeID,
ProductFitScore: st.ProductFitScore,
RecencyDays: days,
}, true
}

View File

@ -0,0 +1,40 @@
package placement
import (
"testing"
"haixun-backend/internal/library/websearch"
)
func TestNextRecencyCascadeWindowSkipsRanWindows(t *testing.T) {
ran := map[int]bool{7: true}
if got := nextRecencyCascadeWindow(ran); got != 14 {
t.Fatalf("expected 14, got %d", got)
}
ran[14] = true
if got := nextRecencyCascadeWindow(ran); got != 30 {
t.Fatalf("expected 30, got %d", got)
}
ran[30] = true
if got := nextRecencyCascadeWindow(ran); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
}
func TestPassesPostAgeUsesRecencyWindow(t *testing.T) {
old := timeNow().AddDate(0, 0, -10).Format("2006-01-02")
if PassesPostAge(old, QueryRecency, 7) {
t.Fatal("expected 10-day-old post to fail 7-day recency window")
}
if !PassesPostAge(old, QueryRecency, 14) {
t.Fatal("expected 10-day-old post to pass 14-day recency window")
}
}
func TestBuildRecencyCascadeQuery(t *testing.T) {
st := &tagCascadeState{Tag: "洗衣精 推薦", ProductFitScore: 55}
tq, ok := buildRecencyCascadeQuery(st, 14, websearch.ProviderBrave)
if !ok || tq.RecencyDays != 14 || tq.Dimension != QueryRecency {
t.Fatalf("unexpected cascade query: %+v ok=%v", tq, ok)
}
}

View File

@ -103,7 +103,7 @@ func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) st
3. contentGoal 要寫入產品詳情的完整產品名稱近期發文留言置入時機
4. questions 至少 8 pillars 至少 6 exclusions 至少 8 exclusions 只寫貼文類型**不要寫時間近期幾天內**
5. questions 人會發文的求助句patrolKeywords 人會打進 Threads 搜尋框的短句兩者不可混在一起
6. patrolKeywords 至少 6 最多 8 每組 616 questions 提煉搜尋短句必須可直接拿去搜尋`)
6. patrolKeywords 至少 8 最多 10 每組 616 questions 提煉搜尋短句必須可直接拿去搜尋`)
return b.String()
}
@ -149,7 +149,7 @@ func BuildResearchMapSystemPrompt() string {
- 觸及即排除的**貼文類型內容方向**要寫清楚為什麼不能置入純晒照業配無求助跑題癌別錯品類已滿意他牌非病友視角等
- **禁止寫時間相關條件**不要碰不是篩發文時間用的不可出現過舊貼文非近期發文3 天前一週以上發文太久置入時間窗口只寫在 contentGoal
## patrolKeywords68 每組 614
## patrolKeywords810 每組 614
- 這不是分類標籤而是**真人會貼進 Threads 搜尋框的短句**
- 每組 24 個詞空格分隔必須同時保留情境困擾產品品類用途
- 優先格式困擾 品類族群 品類 推薦症狀 品類 請問例如化療 沐浴乳 推薦無香 洗衣精 請問

View File

@ -0,0 +1,105 @@
package placement
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type resolveMediaInput struct {
Permalink string `json:"permalink"`
StorageState string `json:"storage_state,omitempty"`
}
type resolveMediaOutput struct {
MediaID string `json:"mediaId"`
}
// RunResolveMediaFromPermalink opens a Threads post page via Playwright and extracts numeric media id.
func RunResolveMediaFromPermalink(ctx context.Context, permalink, storageState string) (string, error) {
permalink = strings.TrimSpace(permalink)
if permalink == "" {
return "", fmt.Errorf("permalink is required")
}
repoRoot, cliPath, err := resolveMediaCLI()
if err != nil {
return "", err
}
payload, err := json.Marshal(resolveMediaInput{
Permalink: permalink,
StorageState: strings.TrimSpace(storageState),
})
if err != nil {
return "", err
}
runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath)
cmd.Dir = repoRoot
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("permalink media resolve failed: %s", msg)
}
var out resolveMediaOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
return "", fmt.Errorf("permalink media resolve output parse failed: %w", err)
}
mediaID := strings.TrimSpace(out.MediaID)
if mediaID == "" {
return "", fmt.Errorf("permalink media resolve returned empty media id")
}
return mediaID, nil
}
func resolveMediaCLI() (repoRoot, cliPath string, err error) {
if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" {
cli := filepath.Join(root, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
if fileExists(cli) {
return root, cli, nil
}
cli = filepath.Join(root, "worker", "threads-resolve-media-cli.ts")
if fileExists(cli) {
return root, cli, nil
}
}
cwd, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("resolve media cli: %w", err)
}
dir := cwd
for i := 0; i < 6; i++ {
cli := filepath.Join(dir, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
if fileExists(cli) {
return dir, cli, nil
}
cli = filepath.Join(dir, "worker", "threads-resolve-media-cli.ts")
if fileExists(cli) {
return dir, cli, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", "", fmt.Errorf("找不到 threads-resolve-media-cli.ts請設定 HAIXUN_REPO_ROOT")
}

View File

@ -0,0 +1,18 @@
package placement
import "strings"
func ResolveReplyTargetID(externalID, permalink string) string {
if id := strings.TrimSpace(externalID); id != "" {
return id
}
permalink = normalizeThreadsPermalink(permalink)
if permalink == "" {
return ""
}
match := threadsPostURLRe.FindStringSubmatch(permalink)
if len(match) < 3 {
return ""
}
return strings.TrimSpace(match[2])
}

View File

@ -0,0 +1,17 @@
package placement
import "testing"
func TestResolveReplyTargetIDFromPermalink(t *testing.T) {
got := ResolveReplyTargetID("", "https://www.threads.net/@alice/post/AbCdEf123")
if got != "AbCdEf123" {
t.Fatalf("got %q", got)
}
}
func TestResolveReplyTargetIDPrefersExternalID(t *testing.T) {
got := ResolveReplyTargetID("media-123", "https://www.threads.net/@alice/post/AbCdEf123")
if got != "media-123" {
t.Fatalf("got %q", got)
}
}

View File

@ -56,7 +56,7 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool {
// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
func MemberNeedsWebSearchKey(ctx MemberContext) bool {
if !ctx.AllowsBrave || ctx.DevMode {
if !ctx.AllowsBrave {
return false
}
switch ctx.SearchSourceMode {

View File

@ -20,9 +20,9 @@ func TestMemberNeedsBraveKey(t *testing.T) {
if !MemberNeedsBraveKey(braveOnly) {
t.Fatal("brave-only should require brave key")
}
devCrawler := MemberContext{AllowsBrave: true, DevMode: true, SearchSourceMode: SearchSourceCrawler}
if MemberNeedsBraveKey(devCrawler) {
t.Fatal("dev crawler should not require brave key")
testPatrol := MemberContextForCrawlerOnly(MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave})
if MemberNeedsBraveKey(testPatrol) {
t.Fatal("test patrol crawler should not require brave key")
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
libkg "haixun-backend/internal/library/knowledge"
libthreads "haixun-backend/internal/library/threadsapi"
)
@ -24,14 +25,19 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
limit = 12
}
// Strip site: prefix for API keyword search.
query := strings.TrimSpace(req.Query)
query = strings.TrimPrefix(query, "site:threads.net ")
query = strings.Trim(query, `"`)
if idx := strings.Index(query, " after:"); idx > 0 {
query = strings.TrimSpace(query[:idx])
query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword))
if query == "" {
query = strings.TrimSpace(req.Query)
query = strings.TrimPrefix(query, "site:threads.net ")
query = strings.Trim(query, `"`)
if idx := strings.Index(query, " after:"); idx > 0 {
query = strings.TrimSpace(query[:idx])
}
query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`))
}
if query == "" {
return nil, fmt.Errorf("threads api keyword is empty")
}
query = strings.Trim(query, `"`)
items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
Query: query,
@ -48,14 +54,18 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
if text == "" {
continue
}
externalID := strings.TrimSpace(item.ID)
if !libthreads.IsNumericMediaID(externalID) {
continue
}
permalink := strings.TrimSpace(item.Permalink)
if permalink == "" && item.Username != "" && item.ID != "" {
permalink = "https://www.threads.net/@" + item.Username + "/post/" + item.ID
if permalink == "" && item.Username != "" {
permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID
}
out = append(out, DiscoverPost{
Text: text,
Permalink: permalink,
ExternalID: strings.TrimSpace(item.ID),
ExternalID: externalID,
Author: strings.TrimSpace(item.Username),
PostedAt: strings.TrimSpace(item.Timestamp),
LikeCount: item.LikeCount,

View File

@ -0,0 +1,42 @@
package placement
import (
"strings"
libkg "haixun-backend/internal/library/knowledge"
)
// HasTangentialTopicMismatch uses the shared intent model: broad category overlap
// without product/research-map intent alignment (generalizes beyond hardcoded lists).
func HasTangentialTopicMismatch(text string, ctx PostScanContext) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if matchesTopicAnchor(text, ctx) {
return false
}
profile := libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx))
return libkg.IsTangentialToIntent(text, profile)
}
func PatrolTagInputFromScanContext(ctx PostScanContext) libkg.PatrolTagInput {
return intentProfileInputFromPostScan(ctx)
}
func intentProfileInputFromPostScan(ctx PostScanContext) libkg.PatrolTagInput {
return libkg.PatrolTagInput{
ProductName: ctx.ProductName,
ProductFeatures: ctx.ProductFeatures,
AudienceSummary: ctx.AudienceSummary,
TargetAudience: ctx.TargetAudience,
MatchTags: append([]string{}, ctx.MatchTags...),
Questions: append([]string{}, ctx.Questions...),
Pillars: append([]string{}, ctx.Pillars...),
}
}
// LocalIntentScore is the offline semantic scorer (no embedding API).
func LocalIntentScore(text string, ctx PostScanContext) int {
return libkg.ScoreIntentSimilarity(text, libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx)))
}

View File

@ -13,9 +13,10 @@
4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji01 個)。
6. 誠實:不承諾療效、不捏造誇大數據。
7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀
7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
10. 排版要像真人用手機留言:每則 text 用 24 個短段落段落之間一定要換行JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。

View File

@ -8,5 +8,6 @@
目標貼文作者:@{{author_name}}
目標貼文:
{{target_text}}
{{regenerate_line}}
請產生 {{count}} 則留言草稿每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入relevance 0-1與 reason繁體中文一句話

Some files were not shown because too many files have changed in this diff Show More