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 ./... go test ./...
run: 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 go run gateway.go -f etc/gateway.yaml
web-dev: web-dev:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -115,6 +115,7 @@ type (
PersonaID string `json:"persona_id"` PersonaID string `json:"persona_id"`
CopyMissionID string `json:"copy_mission_id,omitempty"` CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"` ScanPostID string `json:"scan_post_id,omitempty"`
FormulaID string `json:"formula_id,omitempty"`
DraftType string `json:"draft_type"` DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"` SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"` Text string `json:"text"`
@ -229,6 +230,65 @@ type (
DraftID string `json:"draft_id"` DraftID string `json:"draft_id"`
Message string `json:"message"` 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( @server(
@ -266,6 +326,9 @@ service gateway {
@handler listPersonaCopyDrafts @handler listPersonaCopyDrafts
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData) get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
@handler listPersonaContentInbox
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
@handler generatePersonaCopyDraft @handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData) post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@ -278,6 +341,12 @@ service gateway {
@handler schedulePersonaDrafts @handler schedulePersonaDrafts
post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData) 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 @handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData) delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)

View File

@ -83,6 +83,12 @@ type (
PublishOutreachDraftReq PublishOutreachDraftReq
} }
UpdatePlacementTopicOutreachDraftHandlerReq {
PlacementTopicPath
DraftID string `path:"draftId" validate:"required"`
UpdateOutreachDraftReq
}
PatchPlacementTopicScanPostOutreachHandlerReq { PatchPlacementTopicScanPostOutreachHandlerReq {
PlacementTopicPath PlacementTopicPath
PostID string `path:"postId"` PostID string `path:"postId"`
@ -116,6 +122,25 @@ type (
PlacementTopicPath PlacementTopicPath
UpsertBrandScanScheduleReq 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( @server(
@ -159,9 +184,15 @@ service gateway {
@handler generatePlacementTopicOutreachDrafts @handler generatePlacementTopicOutreachDrafts
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData) post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
@handler startPlacementTopicOutreachDraftJob
post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
@handler publishPlacementTopicOutreachDraft @handler publishPlacementTopicOutreachDraft
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData) post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler updatePlacementTopicOutreachDraft
patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
@handler patchPlacementTopicScanPostOutreach @handler patchPlacementTopicScanPostOutreach
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData) patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)

View File

@ -206,6 +206,9 @@ type (
ReplyID string `json:"reply_id,optional"` ReplyID string `json:"reply_id,optional"`
ReplyToID string `json:"reply_to_id,optional"` ReplyToID string `json:"reply_to_id,optional"`
Text string `json:"text,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"` LocationQuery string `json:"location_query,optional"`
Limit int `json:"limit,optional"` Limit int `json:"limit,optional"`
Hide bool `json:"hide,optional"` Hide bool `json:"hide,optional"`
@ -257,6 +260,11 @@ type (
Text string `json:"text,optional"` Text string `json:"text,optional"`
Permalink string `json:"permalink,optional"` Permalink string `json:"permalink,optional"`
Timestamp string `json:"timestamp,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"` LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"` ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count,optional"` RepostCount int `json:"repost_count,optional"`
@ -547,6 +555,145 @@ type (
TotalReplies7d int `json:"total_replies_7d"` 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 { ThreadsDiagnosticsData {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
CheckedAt int64 `json:"checked_at"` CheckedAt int64 `json:"checked_at"`
@ -556,6 +703,149 @@ type (
Items []ThreadsAPISmokeTestItem `json:"items"` Items []ThreadsAPISmokeTestItem `json:"items"`
Message string `json:"message"` 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( @server(
@ -680,8 +970,47 @@ service gateway {
@handler getThreadsDiagnostics @handler getThreadsDiagnostics
get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData) 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 @handler getPublishDashboardSummary
get /publish-dashboard-summary returns (PublishDashboardSummaryData) 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( @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", Path: "/:id/knowledge-graph/nodes",
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx), Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
}, },
{
Method: http.MethodPatch,
Path: "/:id/outreach-drafts/:draftId",
Handler: brand.UpdateOutreachDraftHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate", Path: "/:id/outreach-drafts/generate",
@ -341,6 +346,65 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/personas"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -425,65 +489,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -577,6 +582,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id", Path: "/:id",
Handler: persona.DeletePersonaHandler(serverCtx), 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, Method: http.MethodGet,
Path: "/:id/copy-drafts", Path: "/:id/copy-drafts",
@ -597,6 +612,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/:draftId/publish", Path: "/:id/copy-drafts/:draftId/publish",
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx), Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/:id/copy-drafts/:draftId/schedule",
Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/:id/copy-drafts/generate", Path: "/:id/copy-drafts/generate",
@ -696,6 +716,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/knowledge-graph/nodes", Path: "/:id/knowledge-graph/nodes",
Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx), 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, Method: http.MethodPost,
Path: "/:id/outreach-drafts/generate", Path: "/:id/outreach-drafts/generate",
@ -849,11 +879,76 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/connection", Path: "/:id/connection",
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx), 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, Method: http.MethodGet,
Path: "/:id/diagnostics", Path: "/:id/diagnostics",
Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx), 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, Method: http.MethodGet,
Path: "/:id/post-performance", 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 package embedding
import ( 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 各 2~5 點字串陣列。`)
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 文案寫手。依「爆款公式」與「人設」為指定主題寫一篇新貼文。
規則:
- 用台灣繁體中文,符合人設語氣。
- 套用公式結構,但內容必須圍繞使用者主題,不可抄襲範例貼文。
- 只回傳 JSON:text, 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 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) { func NodeByID(nodes []Node, id string) (Node, bool) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
for _, node := range nodes { 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 ProductName string
ProductFeatures string ProductFeatures string
AudienceSummary string AudienceSummary string
TargetAudience string
MatchTags []string MatchTags []string
Questions []string Questions []string
Pillars []string Pillars []string
@ -65,6 +66,7 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
ProductName: productName, ProductName: productName,
ProductFeatures: features, ProductFeatures: features,
AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary), AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
TargetAudience: strings.TrimSpace(brand.TargetAudience),
MatchTags: matchTagsFromBrand(brand), MatchTags: matchTagsFromBrand(brand),
Questions: append([]string{}, brand.ResearchMap.Questions...), Questions: append([]string{}, brand.ResearchMap.Questions...),
Pillars: append([]string{}, brand.ResearchMap.Pillars...), 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 { func productLabelFromBrand(brand *branddomain.BrandSummary) string {
if brand == nil { if brand == nil {
return "" return ""
@ -134,9 +153,33 @@ func (in PatrolTagInput) HasProductContext() bool {
len(in.MatchTags) > 0 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 { func productCategoryHint(productName, features string) string {
combined := productName + " " + features combined := productName + " " + features
for _, hint := range []string{ for _, hint := range []string{
"洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華", "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
"防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔", "防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
} { } {

View File

@ -162,6 +162,48 @@ func splitPatrolChunks(text string) []string {
return []string{head, tail} 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 { func ensurePatrolIntent(phrase, intent string) string {
phrase = strings.TrimSpace(phrase) phrase = strings.TrimSpace(phrase)
if phrase == "" { if phrase == "" {

View File

@ -1,6 +1,9 @@
package knowledge package knowledge
import "testing" import (
"strings"
"testing"
)
func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) { func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳") 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) { func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) {
got := PatrolTagFromQuestion("敏感肌保養") got := PatrolTagFromQuestion("敏感肌保養")
if got == "敏感肌保養" { if got == "敏感肌保養" {

View File

@ -5,7 +5,27 @@ import (
"strings" "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 { type PatrolTagCandidate struct {
Tag string Tag string
@ -24,19 +44,20 @@ func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate
addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance) addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
} }
for i, q := range in.Questions { 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 { 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 { 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 != "" { 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 { nodesToUse := NodesForPatrolKeywordDerivation(nodes)
if !IsPainNode(node) && node.Layer > 1 { for _, node := range nodesToUse {
if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 {
continue continue
} }
nodeScore := scoreNode(node) nodeScore := scoreNode(node)
@ -216,7 +237,7 @@ func scoreNode(node Node) int {
base += 8 base += 8
} }
if node.ProductFitScore > 0 { if node.ProductFitScore > 0 {
base += node.ProductFitScore / 6 base += node.ProductFitScore / 5
} }
return base return base
} }

View File

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

View File

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

View File

@ -2,15 +2,25 @@ package knowledge
import "testing" import "testing"
func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) { func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) {
got := ResolveScanPatrolKeywords( got := ResolveScanPatrolKeywords(
[]string{"手動 關鍵字"}, []string{"手動 關鍵字"},
[]string{"已儲存"}, []string{"已儲存"},
PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}}, PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
nil, nil,
) )
if len(got) != 1 || got[0] != "手動 關鍵字" { if len(got) == 0 {
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got) 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") 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) matches := isCoreNode(node)
for i, q := range in.Questions { for i, q := range in.Questions {
if matches || nodeMatchesResearchText(node, q) { if matches || nodeMatchesResearchText(node, q, in) {
addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance) addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency) addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
} }
} }
for i, pillar := range in.Pillars { for i, pillar := range in.Pillars {
if matches || nodeMatchesResearchText(node, pillar) { if matches || nodeMatchesResearchText(node, pillar, in) {
addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance) addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
} }
} }
for i, matchTag := range in.MatchTags { for i, matchTag := range in.MatchTags {
if matches || nodeMatchesResearchText(node, matchTag) { if matches || nodeMatchesResearchText(node, matchTag, in) {
addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
} }
} }
if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) { if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) {
addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
} }
if len(candidates) == 0 && IsPainNode(node) { if len(candidates) == 0 && IsPainNode(node) {
@ -79,16 +79,16 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
if i >= 2 { if i >= 2 {
break 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) { 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 { for i, matchTag := range in.MatchTags {
if i >= 1 { if i >= 1 {
break 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") 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) label := strings.TrimSpace(node.Label)
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if label == "" || 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) { if label == text || strings.Contains(text, label) || strings.Contains(label, text) {
return true 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 { 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

@ -32,6 +32,8 @@ type GenerateInput struct {
TargetText string TargetText string
AuthorName string AuthorName string
Count int Count int
Regenerate bool
PreviousDraftsHint string
} }
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$") var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
@ -65,6 +67,13 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
if author == "" { if author == "" {
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{ return libprompt.OutreachPlacementUser(map[string]string{
"persona_block": personaBlock, "persona_block": personaBlock,
"topic_label": topic, "topic_label": topic,
@ -73,6 +82,7 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
"placement_reason_line": reasonLine, "placement_reason_line": reasonLine,
"author_name": author, "author_name": author,
"target_text": strings.TrimSpace(in.TargetText), "target_text": strings.TrimSpace(in.TargetText),
"regenerate_line": regenerateLine,
"count": fmt.Sprintf("%d", count), "count": fmt.Sprintf("%d", count),
}) })
} }
@ -90,7 +100,7 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
return GenerateResult{}, fmt.Errorf("outreach drafts missing") return GenerateResult{}, fmt.Errorf("outreach drafts missing")
} }
for i := range out.Drafts { 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 == "" { if out.Drafts[i].Text == "" {
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1) 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 各 2~5 點字串陣列。`)
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. 排版要像真人留言:2~4 個短段落,每段 1~2 句;段落之間必須換行(用實際換行,不要整段擠在一起)。
7. 可口語、可省略主詞、可用 …、可 0~1 個 emoji;總長度約 40~180 字,最多不超過 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 腔與客服腔;2~4 個短段落,段落間換行;約 40~180 字,最多 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) == "" { if strings.TrimSpace(member.ActiveAccountID) == "" {
return "請先建立並選定經營帳號" return "請先建立並選定經營帳號"
} }
if member.DevMode && !member.AllowsThreadsAPI {
return "開發模式請在連線設定啟用 API 發文"
}
return "請先完成 Threads 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 { func (c MemberContext) PayloadFields() map[string]any {
return map[string]any{ return map[string]any{
"tenant_id": c.TenantID, "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) { if ShouldTryCrawlerFirst(m) {
posts, err := runCrawlerDiscover(ctx, req) posts, err := runCrawlerDiscover(ctx, req)
if err == nil && len(posts) > 0 { if err == nil && len(posts) > 0 {
return posts, DiscoverCrawler, nil return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
} }
if m.SearchSourceMode == SearchSourceCrawler { if m.SearchSourceMode == SearchSourceCrawler {
if err != nil { if err != nil {
return nil, DiscoverCrawler, err 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() { if m.CrawlerFallbackAllowed() {
cPosts, cErr := runCrawlerDiscover(ctx, req) cPosts, cErr := runCrawlerDiscover(ctx, req)
if cErr == nil && len(cPosts) > 0 { if cErr == nil && len(cPosts) > 0 {
return cPosts, DiscoverCrawler, nil return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil
} }
} }
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() { if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
@ -89,7 +89,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
if m.CrawlerFallbackAllowed() { if m.CrawlerFallbackAllowed() {
posts, err := runCrawlerDiscover(ctx, req) posts, err := runCrawlerDiscover(ctx, req)
if err == nil { if err == nil {
return posts, DiscoverCrawler, nil return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
} }
} }
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel()) 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 { if err != nil {
return nil, DiscoverCrawler, err return nil, DiscoverCrawler, err
} }
return posts, DiscoverCrawler, nil return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
} }
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode) 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 Text string
SearchTag string SearchTag string
QueryDimension QueryDimension QueryDimension QueryDimension
RecencyDays int
GraphNodeID string GraphNodeID string
ProductFitScore int ProductFitScore int
Source DiscoverChannel Source DiscoverChannel
@ -50,6 +51,7 @@ type DualTrackInput struct {
Nodes []libkg.Node Nodes []libkg.Node
PatrolKeywords []string PatrolKeywords []string
Exclusions []string Exclusions []string
PatrolContext PostScanContext
Member MemberContext Member MemberContext
WebSearch websearch.Client WebSearch websearch.Client
Crawler CrawlerSearchFn Crawler CrawlerSearchFn
@ -104,17 +106,6 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
RecencyDays: IdealMaxPostAgeDays, 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 return out
@ -122,7 +113,12 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink. // RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) { 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(queries) == 0 {
if len(input.PatrolKeywords) > 0 { if len(input.PatrolKeywords) > 0 {
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句") return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
@ -145,15 +141,31 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
runQuery := func(tq TagQuery, limit int) error { runQuery := func(tq TagQuery, limit int) error {
posts, channel, err := discoverForQuery(ctx, input, tq, limit) posts, channel, err := discoverForQuery(ctx, input, tq, limit)
if err != nil { 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 { for _, post := range posts {
if MatchesExclusion(post.Text, input.Exclusions) { if MatchesExclusion(post.Text, input.Exclusions) {
continue continue
} }
if !PassesPlacementFilter(post.Text) { if LooksLikeCasualChat(post.Text) {
continue 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 key := post.Permalink
if key == "" { if key == "" {
continue continue
@ -170,6 +182,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
extID = parsed.ExternalID extID = parsed.ExternalID
} }
} }
semP := &semanticScore
merged[key] = &ScanCandidate{ merged[key] = &ScanCandidate{
Permalink: post.Permalink, Permalink: post.Permalink,
ExternalID: extID, ExternalID: extID,
@ -179,17 +192,19 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
Text: post.Text, Text: post.Text,
SearchTag: tq.Tag, SearchTag: tq.Tag,
QueryDimension: tq.Dimension, QueryDimension: tq.Dimension,
RecencyDays: recencyDaysForCandidate(tq),
GraphNodeID: tq.GraphNodeID, GraphNodeID: tq.GraphNodeID,
ProductFitScore: tq.ProductFitScore, ProductFitScore: effectiveFit,
Source: channel, Source: channel,
HasRelevance: tq.Dimension == QueryRelevance, HasRelevance: tq.Dimension == QueryRelevance,
HasRecency: tq.Dimension == QueryRecency, HasRecency: tq.Dimension == QueryRecency,
Priority: priority, Priority: priority,
LikeCount: post.LikeCount, LikeCount: post.LikeCount,
ReplyCount: post.ReplyCount, ReplyCount: post.ReplyCount,
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil), SemanticScore: semanticScore,
SolvedByProduct: tq.ProductFitScore >= 55, PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
PostedAt: strings.TrimSpace(post.PostedAt), SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
PostedAt: postedAt,
} }
order = append(order, key) order = append(order, key)
continue continue
@ -199,14 +214,27 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
} }
if tq.Dimension == QueryRecency { if tq.Dimension == QueryRecency {
existing.HasRecency = true existing.HasRecency = true
existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
} }
if tq.ProductFitScore > existing.ProductFitScore { if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
existing.ProductFitScore = tq.ProductFitScore if effectiveFit > existing.ProductFitScore {
existing.SolvedByProduct = tq.ProductFitScore >= 55 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) != "" { if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
existing.PostedAt = strings.TrimSpace(post.PostedAt) existing.PostedAt = strings.TrimSpace(post.PostedAt)
} }
existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
} }
return nil return nil
} }
@ -217,15 +245,12 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
pct := 10 + ((i + 1) * 75 / max(total, 1)) pct := 10 + ((i + 1) * 75 / max(total, 1))
onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct) onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
} }
limit := relevanceLimitPerTag limit := perTagDiscoverLimit(total, tq.Dimension)
if tq.Dimension == QueryRecency {
limit = recencyLimitPerTag
}
if err := runQuery(tq, limit); err != nil { if err := runQuery(tq, limit); err != nil {
return nil, err return nil, err
} }
if input.OnCheckpoint != nil { if input.OnCheckpoint != nil {
snapshot := snapshotMergedCandidates(merged, order, false) snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
if err := input.OnCheckpoint(snapshot); err != nil { if err := input.OnCheckpoint(snapshot); err != nil {
return nil, err 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 { if onProgress != nil {
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90) 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) { 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{ req := DiscoverRequest{
Query: tq.Query, Query: tq.Query,
Keyword: tq.Tag, Keyword: apiKeyword,
Recency: tq.Dimension == QueryRecency, Recency: tq.Dimension == QueryRecency,
Limit: limit, Limit: limit,
Member: input.Member, Member: input.Member,
Crawler: input.Crawler, Crawler: input.Crawler,
} }
posts, channel, err := Discover(ctx, req) merged := map[string]DiscoverPost{}
if err == nil && len(posts) > 0 { channel := DiscoverChannel("")
return posts, channel, nil posts, primaryChannel, err := Discover(ctx, req)
for _, post := range posts {
key := strings.TrimSpace(post.Permalink)
if key == "" {
continue
} }
if input.WebSearch == nil || !input.WebSearch.Enabled() { if existing, ok := merged[key]; ok {
post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
}
merged[key] = post
}
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 { if err != nil {
return nil, "", err return nil, "", err
} }
if !webEnabled {
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel()) 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
} }
return nil, "", werr
out := make([]DiscoverPost, 0, len(merged))
for _, post := range merged {
out = append(out, post)
} }
return webPosts, input.Member.WebSearchDiscoverChannel(), nil 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) { 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 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)) out := make([]ScanCandidate, 0, len(order))
for _, key := range order { for _, key := range order {
item := merged[key] item := merged[key]
finalizeScanCandidate(item) finalizeScanCandidate(item, ctx)
if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" { if applyFinalFilter && !passesFinalStrictFilter(item) {
continue continue
} }
out = append(out, *item) 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 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 { if item == nil {
return return
} }
@ -345,7 +480,7 @@ func finalizeScanCandidate(item *ScanCandidate) {
qualP = &v qualP = &v
} }
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP) 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 { 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 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 { func politeDiscoverPause(ctx context.Context) error {
wait := 2*time.Second + jitterDuration(2*time.Second) wait := 2*time.Second + jitterDuration(2*time.Second)
timer := time.NewTimer(wait) 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 ( var (
placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`) placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`) placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`)
casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`) casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
) )
@ -35,3 +36,11 @@ func PassesPlacementFilter(text string) bool {
} }
return HasPlacementIntent(text) 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 { if len(match) < 3 {
return ParsedThreadsPost{}, false return ParsedThreadsPost{}, false
} }
text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — ")) text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — "))
if len([]rune(text)) < 8 { if text == "" {
text = strings.TrimSpace(title)
}
if text == "" {
text = strings.TrimSpace(snippet)
}
if len([]rune(text)) < 6 {
return ParsedThreadsPost{}, false return ParsedThreadsPost{}, false
} }
return ParsedThreadsPost{ return ParsedThreadsPost{
@ -35,6 +41,16 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
}, true }, 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 { func normalizeThreadsPermalink(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {

View File

@ -1,24 +1,31 @@
package placement package placement
import ( import (
"sort"
"strings" "strings"
libkg "haixun-backend/internal/library/knowledge" libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch" "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. // CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery { func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
keywords = libkg.NormalizePatrolKeywordList(keywords) keywords = libkg.NormalizePatrolKeywordList(keywords)
if len(keywords) == 0 { if len(keywords) == 0 {
return nil 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 { for _, tag := range keywords {
fit := productFitForPatrolTag(tag, nodes) fit := productFitForPatrolTag(tag, nodes, patrolInput)
if q := BuildRelevanceQuery(provider, tag); q != "" { if q := BuildRelevanceQuery(provider, tag); q != "" {
out = append(out, TagQuery{ out = append(out, TagQuery{
Tag: tag, Tag: tag,
@ -36,25 +43,15 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider web
RecencyDays: IdealMaxPostAgeDays, 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 return out
} }
func productFitForPatrolTag(tag string, nodes []libkg.Node) int { func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
tagKey := patrolTagMatchKey(tag) tagKey := patrolTagMatchKey(tag)
best := 0 best := 0
bestNodeID := "" profile := libkg.BuildIntentProfile(patrolInput)
intentFit := libkg.ScoreIntentSimilarity(tag, profile)
for _, node := range nodes { for _, node := range nodes {
score := node.ProductFitScore score := node.ProductFitScore
if score <= 0 { if score <= 0 {
@ -72,14 +69,25 @@ func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
} }
if score > best { if score > best {
best = score best = score
bestNodeID = node.ID
} }
} }
if best > 0 { if best > 0 {
return best return maxPatrolFit(best, intentFit)
} }
_ = bestNodeID if intentFit > 0 {
return defaultPatrolProductFit 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 { func patrolTagMatchKey(tag string) string {
@ -93,10 +101,66 @@ func patrolTagMatchKey(tag string) string {
return strings.TrimSpace(tag) return strings.TrimSpace(tag)
} }
// ResolveTagQueries prefers explicit patrol keywords over graph node selection. // ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery { func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
if len(patrolKeywords) > 0 { nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
return CollectPatrolTagQueries(patrolKeywords, nodes, provider) 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) { func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave) queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{})
if len(queries) < 2 { if len(queries) < 2 {
t.Fatalf("expected relevance + recency queries, got %d", len(queries)) t.Fatalf("expected relevance + recency queries, got %d", len(queries))
} }
if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance { if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance {
t.Fatalf("unexpected first query: %+v", queries[0]) t.Fatalf("unexpected first query: %+v", queries[0])
} }
if queries[0].ProductFitScore != defaultPatrolProductFit { if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph {
t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore) 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 { if len(queries) == 0 || queries[0].ProductFitScore != 92 {
t.Fatalf("expected graph fit 92, got %+v", queries) t.Fatalf("expected graph fit 92, got %+v", queries)
} }
@ -41,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
nodes := []libkg.Node{ nodes := []libkg.Node{
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}}, {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 != "手動 關鍵字" { if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
t.Fatalf("expected patrol keyword query, got %+v", queries) 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 { if len(brand.Products) > 0 {
picked := RecommendProduct(brand.Products, searchTag, productID) picked := RecommendProduct(brand.Products, searchTag, productID)
if picked != nil { 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 != "" { if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" {

View File

@ -95,6 +95,20 @@ func FormatProductContextForPrompt(raw string) string {
return strings.Join(lines, "\n") 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 { func ProductBriefFromContext(raw string) string {
formatted := FormatProductContextForPrompt(raw) formatted := FormatProductContextForPrompt(raw)
if formatted != "" { 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 ( import (
"strings" "strings"
brandentity "haixun-backend/internal/model/brand/domain/entity"
) )
func ScoreProductForTag(searchTag string, matchTags []string) int { func ScoreProductForTag(searchTag string, matchTags []string) int {
@ -40,6 +42,71 @@ func ScoreProductForTag(searchTag string, matchTags []string) int {
return best 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 { func maxInt(a, b int) int {
if a > b { if a > b {
return a 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" "strings"
"time" "time"
libkg "haixun-backend/internal/library/knowledge"
"haixun-backend/internal/library/websearch" "haixun-backend/internal/library/websearch"
) )
@ -25,26 +26,37 @@ type TagQuery struct {
} }
func BuildRelevanceQuery(provider websearch.Provider, tag string) string { func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
tag = strings.TrimSpace(tag) if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
if tag == "" { full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag))
if full == "" {
return "" return ""
} }
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa { return fmt.Sprintf("Threads 貼文 繁體中文 %s", full)
return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
} }
return `site:threads.net "` + tag + `"` core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
if core == "" {
core = strings.TrimSpace(tag)
}
if core == "" {
return ""
}
// 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 { func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
tag = strings.TrimSpace(tag) core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
if tag == "" { if core == "" {
core = strings.TrimSpace(tag)
}
if core == "" {
return "" return ""
} }
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa { if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag) return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core)
} }
after := FormatAfterDate(maxAgeDays, timeNow()) 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 { 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 MaxPostAgeDays = 30
IdealMaxPostAgeDays = 7 IdealMaxPostAgeDays = 7
WebSearchMaxAgeDays = 14 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 { func FormatAfterDate(maxAgeDays int, now time.Time) string {
if now.IsZero() { if now.IsZero() {
now = time.Now() 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 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機 3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機
4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**) 4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**)
5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起 5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起
6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`) 6. patrolKeywords 至少 8 組、最多 10 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
return b.String() return b.String()
} }
@ -149,7 +149,7 @@ func BuildResearchMapSystemPrompt() string {
- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等) - 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等)
- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal - **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal
## patrolKeywords(6~8 組,每組 6~14 字) ## patrolKeywords(8~10 組,每組 6~14 字)
- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句** - 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句**
- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」 - 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」
- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」 - 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」

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. // MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
func MemberNeedsWebSearchKey(ctx MemberContext) bool { func MemberNeedsWebSearchKey(ctx MemberContext) bool {
if !ctx.AllowsBrave || ctx.DevMode { if !ctx.AllowsBrave {
return false return false
} }
switch ctx.SearchSourceMode { switch ctx.SearchSourceMode {

View File

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

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
libkg "haixun-backend/internal/library/knowledge"
libthreads "haixun-backend/internal/library/threadsapi" libthreads "haixun-backend/internal/library/threadsapi"
) )
@ -24,14 +25,19 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
limit = 12 limit = 12
} }
// Strip site: prefix for API keyword search. query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword))
query := strings.TrimSpace(req.Query) if query == "" {
query = strings.TrimSpace(req.Query)
query = strings.TrimPrefix(query, "site:threads.net ") query = strings.TrimPrefix(query, "site:threads.net ")
query = strings.Trim(query, `"`) query = strings.Trim(query, `"`)
if idx := strings.Index(query, " after:"); idx > 0 { if idx := strings.Index(query, " after:"); idx > 0 {
query = strings.TrimSpace(query[:idx]) query = strings.TrimSpace(query[:idx])
} }
query = strings.Trim(query, `"`) query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`))
}
if query == "" {
return nil, fmt.Errorf("threads api keyword is empty")
}
items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{ items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
Query: query, Query: query,
@ -48,14 +54,18 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
if text == "" { if text == "" {
continue continue
} }
externalID := strings.TrimSpace(item.ID)
if !libthreads.IsNumericMediaID(externalID) {
continue
}
permalink := strings.TrimSpace(item.Permalink) permalink := strings.TrimSpace(item.Permalink)
if permalink == "" && item.Username != "" && item.ID != "" { if permalink == "" && item.Username != "" {
permalink = "https://www.threads.net/@" + item.Username + "/post/" + item.ID permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID
} }
out = append(out, DiscoverPost{ out = append(out, DiscoverPost{
Text: text, Text: text,
Permalink: permalink, Permalink: permalink,
ExternalID: strings.TrimSpace(item.ID), ExternalID: externalID,
Author: strings.TrimSpace(item.Username), Author: strings.TrimSpace(item.Username),
PostedAt: strings.TrimSpace(item.Timestamp), PostedAt: strings.TrimSpace(item.Timestamp),
LikeCount: item.LikeCount, 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. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。 4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。 5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。
6. 誠實:不承諾療效、不捏造誇大數據。 6. 誠實:不承諾療效、不捏造誇大數據。
7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。 7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻。
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。 8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。 9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
10. 排版要像真人用手機留言:每則 text 用 2~4 個短段落,段落之間一定要換行(JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。 每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。

View File

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

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