add things
This commit is contained in:
parent
fb28b5a7e5
commit
ce7d01c263
|
|
@ -14,6 +14,11 @@ test:
|
|||
go test ./...
|
||||
|
||||
run:
|
||||
mkdir -p bin
|
||||
go build -o bin/gateway gateway.go
|
||||
./bin/gateway -f etc/gateway.yaml
|
||||
|
||||
run-dev:
|
||||
go run gateway.go -f etc/gateway.yaml
|
||||
|
||||
web-dev:
|
||||
|
|
|
|||
|
|
@ -5,27 +5,27 @@ Timeout: 120000
|
|||
|
||||
# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。
|
||||
Mongo:
|
||||
URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin
|
||||
Database: haixun
|
||||
URI: ${HAIXUN_MONGO_URI}
|
||||
Database: ${HAIXUN_MONGO_DB}
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: 127.0.0.1:6379
|
||||
Password: change-me-redis-pass
|
||||
Addr: ${HAIXUN_REDIS_ADDR}
|
||||
Password: ${HAIXUN_REDIS_PASSWORD}
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
AccessSecret: haixun-dev-access-secret-change-me
|
||||
RefreshSecret: haixun-dev-refresh-secret-change-me
|
||||
AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
|
||||
RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
DevHeaderFallback: false
|
||||
|
||||
Secrets:
|
||||
EncryptionKey: ""
|
||||
EncryptionKey: ${HAIXUN_SECRETS_KEY}
|
||||
|
||||
InternalWorker:
|
||||
Secret: haixun-dev-worker-secret
|
||||
Secret: ${HAIXUN_WORKER_SECRET}
|
||||
|
||||
JobWorker:
|
||||
Enabled: true
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ ThreadsOAuth:
|
|||
AppID: "${THREADS_APP_ID}"
|
||||
AppSecret: "${THREADS_APP_SECRET}"
|
||||
RedirectURI: "${THREADS_REDIRECT_URI}"
|
||||
# OAuth 完成後瀏覽器導回(Dev Console 預設 http://127.0.0.1:5173)
|
||||
# OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net )
|
||||
SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
|
||||
|
||||
# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ func main() {
|
|||
defer sc.Close(context.Background())
|
||||
|
||||
handler.RegisterHandlers(server, sc)
|
||||
handler.RegisterExtraHandlers(server, sc)
|
||||
|
||||
fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
|
||||
server.Start()
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type (
|
|||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
ProductContext string `json:"product_context"`
|
||||
PlacementURL string `json:"placement_url,omitempty"`
|
||||
MatchTags []string `json:"match_tags,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
|
|
@ -65,12 +66,14 @@ type (
|
|||
CreateBrandProductReq {
|
||||
Label string `json:"label" validate:"required"`
|
||||
ProductContext string `json:"product_context" validate:"required"`
|
||||
PlacementURL string `json:"placement_url,optional"`
|
||||
MatchTags []string `json:"match_tags,optional"`
|
||||
}
|
||||
|
||||
UpdateBrandProductReq {
|
||||
Label *string `json:"label,optional"`
|
||||
ProductContext *string `json:"product_context,optional"`
|
||||
PlacementURL string `json:"placement_url,optional"`
|
||||
MatchTags []string `json:"match_tags,optional"`
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +186,7 @@ type (
|
|||
NodeIDs []string `json:"node_ids,optional"`
|
||||
DualTrack bool `json:"dual_track,optional"`
|
||||
PatrolMode bool `json:"patrol_mode,optional"`
|
||||
TestPatrol bool `json:"test_patrol,optional"`
|
||||
PatrolKeywords []string `json:"patrol_keywords,optional"`
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +223,7 @@ type (
|
|||
PublishedPermalink string `json:"published_permalink,omitempty"`
|
||||
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
|
||||
PostedAt string `json:"posted_at,omitempty"`
|
||||
RecencyDays int `json:"recency_days,omitempty"`
|
||||
Replies []ScanReplyData `json:"replies,omitempty"`
|
||||
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
|
|
@ -235,6 +240,7 @@ type (
|
|||
Count int `json:"count,optional"`
|
||||
VoicePersonaID string `json:"voice_persona_id,optional"`
|
||||
ProductID string `json:"product_id,optional"`
|
||||
Regenerate bool `json:"regenerate,optional"`
|
||||
}
|
||||
|
||||
OutreachDraftItemData {
|
||||
|
|
@ -258,6 +264,11 @@ type (
|
|||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
UpdateOutreachDraftReq {
|
||||
DraftIndex int `json:"draft_index"`
|
||||
Text string `json:"text" validate:"required"`
|
||||
}
|
||||
|
||||
PublishOutreachDraftData {
|
||||
ScanPostID string `json:"scan_post_id"`
|
||||
ReplyID string `json:"reply_id"`
|
||||
|
|
@ -355,6 +366,12 @@ type (
|
|||
PublishOutreachDraftReq
|
||||
}
|
||||
|
||||
UpdateOutreachDraftHandlerReq {
|
||||
BrandPath
|
||||
DraftID string `path:"draftId" validate:"required"`
|
||||
UpdateOutreachDraftReq
|
||||
}
|
||||
|
||||
PatchScanPostOutreachHandlerReq {
|
||||
BrandPath
|
||||
PostID string `path:"postId"`
|
||||
|
|
@ -428,6 +445,9 @@ service gateway {
|
|||
@handler publishOutreachDraft
|
||||
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
|
||||
|
||||
@handler updateOutreachDraft
|
||||
patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
|
||||
|
||||
@handler patchScanPostOutreach
|
||||
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ type (
|
|||
BraveSearchLang string `json:"brave_search_lang"`
|
||||
ExaUserLocation string `json:"exa_user_location"`
|
||||
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
|
||||
DevModeEnabled bool `json:"dev_mode_enabled"`
|
||||
}
|
||||
|
||||
UpdateMemberPlacementSettingsReq {
|
||||
|
|
@ -49,6 +50,7 @@ type (
|
|||
BraveSearchLang *string `json:"brave_search_lang,optional"`
|
||||
ExaUserLocation *string `json:"exa_user_location,optional"`
|
||||
ExpandStrategy *string `json:"expand_strategy,optional"`
|
||||
DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
|
||||
}
|
||||
|
||||
MemberCapabilitiesData {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ type (
|
|||
PersonaID string `json:"persona_id"`
|
||||
CopyMissionID string `json:"copy_mission_id,omitempty"`
|
||||
ScanPostID string `json:"scan_post_id,omitempty"`
|
||||
FormulaID string `json:"formula_id,omitempty"`
|
||||
DraftType string `json:"draft_type"`
|
||||
SortOrder int `json:"sort_order,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
|
@ -229,6 +230,65 @@ type (
|
|||
DraftID string `json:"draft_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
ListPersonaContentInboxReq {
|
||||
Page int64 `form:"page,optional"`
|
||||
PageSize int64 `form:"pageSize,optional"`
|
||||
Status string `form:"status,optional"`
|
||||
RangeStart int64 `form:"rangeStart,optional"`
|
||||
RangeEnd int64 `form:"rangeEnd,optional"`
|
||||
}
|
||||
|
||||
ListPersonaContentInboxHandlerReq {
|
||||
PersonaPath
|
||||
ListPersonaContentInboxReq
|
||||
}
|
||||
|
||||
ContentInboxItemData {
|
||||
CopyDraftData
|
||||
ScheduledAt int64 `json:"scheduled_at,omitempty"`
|
||||
Lifecycle string `json:"lifecycle"`
|
||||
GroupDate int64 `json:"group_date"`
|
||||
FormulaLabel string `json:"formula_label,omitempty"`
|
||||
}
|
||||
|
||||
ListPersonaContentInboxData {
|
||||
Pagination PaginationData `json:"pagination"`
|
||||
List []ContentInboxItemData `json:"list"`
|
||||
}
|
||||
|
||||
SchedulePersonaCopyDraftReq {
|
||||
AccountID string `json:"account_id" validate:"required"`
|
||||
ScheduledAt int64 `json:"scheduled_at,optional"`
|
||||
}
|
||||
|
||||
SchedulePersonaCopyDraftHandlerReq {
|
||||
CopyDraftPath
|
||||
SchedulePersonaCopyDraftReq
|
||||
}
|
||||
|
||||
ContentFormulaPath {
|
||||
ID string `path:"id" validate:"required"`
|
||||
FormulaID string `path:"formulaId" validate:"required"`
|
||||
}
|
||||
|
||||
GenerateFromContentFormulaReq {
|
||||
AccountID string `json:"account_id" validate:"required"`
|
||||
Topic string `json:"topic" validate:"required"`
|
||||
Brief string `json:"brief,optional"`
|
||||
UseWebSearch bool `json:"use_web_search,optional"`
|
||||
DraftCount int `json:"draft_count,optional"`
|
||||
}
|
||||
|
||||
GenerateFromContentFormulaHandlerReq {
|
||||
ContentFormulaPath
|
||||
GenerateFromContentFormulaReq
|
||||
}
|
||||
|
||||
GenerateFromContentFormulaData {
|
||||
List []CopyDraftData `json:"list"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -266,6 +326,9 @@ service gateway {
|
|||
@handler listPersonaCopyDrafts
|
||||
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
|
||||
|
||||
@handler listPersonaContentInbox
|
||||
get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
|
||||
|
||||
@handler generatePersonaCopyDraft
|
||||
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
|
||||
|
||||
|
|
@ -278,6 +341,12 @@ service gateway {
|
|||
@handler schedulePersonaDrafts
|
||||
post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
|
||||
|
||||
@handler schedulePersonaCopyDraft
|
||||
post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData)
|
||||
|
||||
@handler generateFromContentFormula
|
||||
post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
|
||||
|
||||
@handler deletePersonaCopyDraft
|
||||
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ type (
|
|||
PublishOutreachDraftReq
|
||||
}
|
||||
|
||||
UpdatePlacementTopicOutreachDraftHandlerReq {
|
||||
PlacementTopicPath
|
||||
DraftID string `path:"draftId" validate:"required"`
|
||||
UpdateOutreachDraftReq
|
||||
}
|
||||
|
||||
PatchPlacementTopicScanPostOutreachHandlerReq {
|
||||
PlacementTopicPath
|
||||
PostID string `path:"postId"`
|
||||
|
|
@ -116,6 +122,25 @@ type (
|
|||
PlacementTopicPath
|
||||
UpsertBrandScanScheduleReq
|
||||
}
|
||||
|
||||
StartPlacementTopicOutreachDraftJobReq {
|
||||
ScanPostID string `json:"scan_post_id,optional"`
|
||||
ScanPostIDs []string `json:"scan_post_ids,optional"`
|
||||
Count int `json:"count,optional"`
|
||||
VoicePersonaID string `json:"voice_persona_id,optional"`
|
||||
ProductID string `json:"product_id,optional"`
|
||||
Regenerate bool `json:"regenerate,optional"`
|
||||
}
|
||||
|
||||
StartPlacementTopicOutreachDraftJobHandlerReq {
|
||||
PlacementTopicPath
|
||||
StartPlacementTopicOutreachDraftJobReq
|
||||
}
|
||||
|
||||
StartPlacementTopicOutreachDraftJobsData {
|
||||
Jobs []StartBrandScanJobData `json:"jobs"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -159,9 +184,15 @@ service gateway {
|
|||
@handler generatePlacementTopicOutreachDrafts
|
||||
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
|
||||
|
||||
@handler startPlacementTopicOutreachDraftJob
|
||||
post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
|
||||
|
||||
@handler publishPlacementTopicOutreachDraft
|
||||
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
|
||||
|
||||
@handler updatePlacementTopicOutreachDraft
|
||||
patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
|
||||
|
||||
@handler patchPlacementTopicScanPostOutreach
|
||||
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,9 @@ type (
|
|||
ReplyID string `json:"reply_id,optional"`
|
||||
ReplyToID string `json:"reply_to_id,optional"`
|
||||
Text string `json:"text,optional"`
|
||||
Permalink string `json:"permalink,optional"`
|
||||
HintText string `json:"hint_text,optional"`
|
||||
SkipPrime bool `json:"skip_prime,optional"`
|
||||
LocationQuery string `json:"location_query,optional"`
|
||||
Limit int `json:"limit,optional"`
|
||||
Hide bool `json:"hide,optional"`
|
||||
|
|
@ -257,6 +260,11 @@ type (
|
|||
Text string `json:"text,optional"`
|
||||
Permalink string `json:"permalink,optional"`
|
||||
Timestamp string `json:"timestamp,optional"`
|
||||
MediaType string `json:"media_type,optional"`
|
||||
MediaURL string `json:"media_url,optional"`
|
||||
ThumbnailURL string `json:"thumbnail_url,optional"`
|
||||
TopicTag string `json:"topic_tag,optional"`
|
||||
Shortcode string `json:"shortcode,optional"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
RepostCount int `json:"repost_count,optional"`
|
||||
|
|
@ -547,6 +555,145 @@ type (
|
|||
TotalReplies7d int `json:"total_replies_7d"`
|
||||
}
|
||||
|
||||
GenerateOwnPostReplyDraftReq {
|
||||
MediaID string `json:"media_id"`
|
||||
PersonaID string `json:"persona_id,optional"`
|
||||
ReplyToID string `json:"reply_to_id,optional"`
|
||||
PostText string `json:"post_text,optional"`
|
||||
ReplyText string `json:"reply_text,optional"`
|
||||
ThreadPostText string `json:"thread_post_text,optional"`
|
||||
ParentReplyText string `json:"parent_reply_text,optional"`
|
||||
}
|
||||
|
||||
GenerateOwnPostReplyDraftHandlerReq {
|
||||
ThreadsAccountPath
|
||||
GenerateOwnPostReplyDraftReq
|
||||
}
|
||||
|
||||
GenerateOwnPostReplyDraftData {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
MentionInboxItemData {
|
||||
MediaID string `json:"media_id"`
|
||||
AuthorUsername string `json:"author_username,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Permalink string `json:"permalink,omitempty"`
|
||||
Shortcode string `json:"shortcode,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
IsReply bool `json:"is_reply"`
|
||||
IsQuotePost bool `json:"is_quote_post"`
|
||||
HasReplies bool `json:"has_replies"`
|
||||
RootPostID string `json:"root_post_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
ThreadPostText string `json:"thread_post_text,omitempty"`
|
||||
ParentReplyText string `json:"parent_reply_text,omitempty"`
|
||||
ReplyReady bool `json:"reply_ready"`
|
||||
ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
|
||||
SyncedAt int64 `json:"synced_at"`
|
||||
}
|
||||
|
||||
SyncMentionInboxReq {
|
||||
Limit int `json:"limit,optional"`
|
||||
MaxPages int `json:"max_pages,optional"`
|
||||
}
|
||||
|
||||
SyncMentionInboxHandlerReq {
|
||||
ThreadsAccountPath
|
||||
SyncMentionInboxReq
|
||||
}
|
||||
|
||||
SyncMentionInboxData {
|
||||
Synced int `json:"synced"`
|
||||
Ready int `json:"ready"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
ListMentionInboxQuery {
|
||||
Page int `form:"page,optional"`
|
||||
PageSize int `form:"pageSize,optional"`
|
||||
}
|
||||
|
||||
ListMentionInboxHandlerReq {
|
||||
ThreadsAccountPath
|
||||
ListMentionInboxQuery
|
||||
}
|
||||
|
||||
ListMentionInboxData {
|
||||
List []MentionInboxItemData `json:"list"`
|
||||
Pagination PaginationData `json:"pagination"`
|
||||
ReadyTotal int64 `json:"ready_total"`
|
||||
NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
|
||||
}
|
||||
|
||||
PublishMentionReplyReq {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
PublishMentionReplyPath {
|
||||
ID string `path:"id" validate:"required"`
|
||||
MediaID string `path:"media_id" validate:"required"`
|
||||
}
|
||||
|
||||
PublishMentionReplyHandlerReq {
|
||||
PublishMentionReplyPath
|
||||
PublishMentionReplyReq
|
||||
}
|
||||
|
||||
PublishMentionReplyData {
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
Permalink string `json:"permalink,omitempty"`
|
||||
}
|
||||
|
||||
GenerateOwnPostFormulaReq {
|
||||
MediaID string `json:"media_id"`
|
||||
PersonaID string `json:"persona_id,optional"`
|
||||
PostText string `json:"post_text,optional"`
|
||||
TopicTag string `json:"topic_tag,optional"`
|
||||
MediaType string `json:"media_type,optional"`
|
||||
Timestamp string `json:"timestamp,optional"`
|
||||
LikeCount int `json:"like_count,optional"`
|
||||
ReplyCount int `json:"reply_count,optional"`
|
||||
Views int `json:"views,optional"`
|
||||
RepostCount int `json:"repost_count,optional"`
|
||||
QuoteCount int `json:"quote_count,optional"`
|
||||
Shares int `json:"shares,optional"`
|
||||
Force bool `json:"force,optional"`
|
||||
}
|
||||
|
||||
GenerateOwnPostFormulaHandlerReq {
|
||||
ThreadsAccountPath
|
||||
GenerateOwnPostFormulaReq
|
||||
}
|
||||
|
||||
OwnPostFormulaReviewData {
|
||||
MediaID string `json:"media_id"`
|
||||
ReviewedAt int64 `json:"reviewed_at"`
|
||||
FromCache bool `json:"from_cache,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Wins []string `json:"wins"`
|
||||
Improvements []string `json:"improvements"`
|
||||
Formula string `json:"formula"`
|
||||
PostTemplate string `json:"post_template"`
|
||||
HookPattern string `json:"hook_pattern"`
|
||||
Structure string `json:"structure"`
|
||||
ReplicationTips []string `json:"replication_tips"`
|
||||
Avoid []string `json:"avoid"`
|
||||
}
|
||||
|
||||
GenerateOwnPostFormulaData {
|
||||
OwnPostFormulaReviewData
|
||||
}
|
||||
|
||||
ListOwnPostFormulasHandlerReq {
|
||||
ThreadsAccountPath
|
||||
}
|
||||
|
||||
ListOwnPostFormulasData {
|
||||
List []OwnPostFormulaReviewData `json:"list"`
|
||||
}
|
||||
|
||||
ThreadsDiagnosticsData {
|
||||
AccountID string `json:"account_id"`
|
||||
CheckedAt int64 `json:"checked_at"`
|
||||
|
|
@ -556,6 +703,149 @@ type (
|
|||
Items []ThreadsAPISmokeTestItem `json:"items"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
ContentFormulaSourceMetricsData {
|
||||
LikeCount int `json:"like_count,omitempty"`
|
||||
ReplyCount int `json:"reply_count,omitempty"`
|
||||
Views int `json:"views,omitempty"`
|
||||
RepostCount int `json:"repost_count,omitempty"`
|
||||
QuoteCount int `json:"quote_count,omitempty"`
|
||||
Shares int `json:"shares,omitempty"`
|
||||
}
|
||||
|
||||
ContentFormulaData {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Label string `json:"label"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceRef string `json:"source_ref,omitempty"`
|
||||
SourcePostText string `json:"source_post_text,omitempty"`
|
||||
SourceAuthor string `json:"source_author,omitempty"`
|
||||
SourcePermalink string `json:"source_permalink,omitempty"`
|
||||
SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Wins []string `json:"wins,omitempty"`
|
||||
Improvements []string `json:"improvements,omitempty"`
|
||||
Formula string `json:"formula"`
|
||||
PostTemplate string `json:"post_template"`
|
||||
HookPattern string `json:"hook_pattern"`
|
||||
Structure string `json:"structure"`
|
||||
ReplicationTips []string `json:"replication_tips,omitempty"`
|
||||
Avoid []string `json:"avoid,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
ListContentFormulasReq {
|
||||
Page int64 `form:"page,optional"`
|
||||
PageSize int64 `form:"pageSize,optional"`
|
||||
SourceType string `form:"source_type,optional"`
|
||||
Tag string `form:"tag,optional"`
|
||||
}
|
||||
|
||||
ListContentFormulasHandlerReq {
|
||||
ThreadsAccountPath
|
||||
ListContentFormulasReq
|
||||
}
|
||||
|
||||
ListContentFormulasData {
|
||||
Pagination PaginationData `json:"pagination"`
|
||||
List []ContentFormulaData `json:"list"`
|
||||
}
|
||||
|
||||
ContentFormulaItemPath {
|
||||
ID string `path:"id" validate:"required"`
|
||||
FormulaID string `path:"formulaId" validate:"required"`
|
||||
}
|
||||
|
||||
GetContentFormulaHandlerReq {
|
||||
ContentFormulaItemPath
|
||||
}
|
||||
|
||||
AnalyzeContentFormulaReq {
|
||||
SourceType string `json:"source_type" validate:"required"`
|
||||
PersonaID string `json:"persona_id,optional"`
|
||||
PostText string `json:"post_text,optional"`
|
||||
MediaID string `json:"media_id,optional"`
|
||||
ScanPostID string `json:"scan_post_id,optional"`
|
||||
Keyword string `json:"keyword,optional"`
|
||||
SaveLabel string `json:"save_label,optional"`
|
||||
AuthorName string `json:"author_name,optional"`
|
||||
Permalink string `json:"permalink,optional"`
|
||||
LikeCount int `json:"like_count,optional"`
|
||||
ReplyCount int `json:"reply_count,optional"`
|
||||
Views int `json:"views,optional"`
|
||||
RepostCount int `json:"repost_count,optional"`
|
||||
QuoteCount int `json:"quote_count,optional"`
|
||||
Shares int `json:"shares,optional"`
|
||||
ForceRefresh bool `json:"force_refresh,optional"`
|
||||
}
|
||||
|
||||
AnalyzeContentFormulaHandlerReq {
|
||||
ThreadsAccountPath
|
||||
AnalyzeContentFormulaReq
|
||||
}
|
||||
|
||||
AnalyzeContentFormulaData {
|
||||
Formula ContentFormulaData `json:"formula"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
SearchContentFormulaPostsReq {
|
||||
Keyword string `json:"keyword" validate:"required"`
|
||||
SearchType string `json:"search_type,optional"`
|
||||
Limit int `json:"limit,optional"`
|
||||
}
|
||||
|
||||
SearchContentFormulaPostsHandlerReq {
|
||||
ThreadsAccountPath
|
||||
SearchContentFormulaPostsReq
|
||||
}
|
||||
|
||||
ContentFormulaSearchPostData {
|
||||
Text string `json:"text"`
|
||||
Author string `json:"author"`
|
||||
Permalink string `json:"permalink,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
EngagementScore int `json:"engagement_score"`
|
||||
}
|
||||
|
||||
SearchContentFormulaPostsData {
|
||||
List []ContentFormulaSearchPostData `json:"list"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
PatchContentFormulaReq {
|
||||
Label *string `json:"label,optional"`
|
||||
Formula *string `json:"formula,optional"`
|
||||
PostTemplate *string `json:"post_template,optional"`
|
||||
HookPattern *string `json:"hook_pattern,optional"`
|
||||
Structure *string `json:"structure,optional"`
|
||||
Tags []string `json:"tags,optional"`
|
||||
}
|
||||
|
||||
PatchContentFormulaHandlerReq {
|
||||
ContentFormulaItemPath
|
||||
PatchContentFormulaReq
|
||||
}
|
||||
|
||||
DeleteContentFormulaData {
|
||||
FormulaID string `json:"formula_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
ImportOwnPostFormulaReq {
|
||||
MediaID string `json:"media_id" validate:"required"`
|
||||
Label string `json:"label,optional"`
|
||||
}
|
||||
|
||||
ImportOwnPostFormulaHandlerReq {
|
||||
ThreadsAccountPath
|
||||
ImportOwnPostFormulaReq
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -680,8 +970,47 @@ service gateway {
|
|||
@handler getThreadsDiagnostics
|
||||
get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData)
|
||||
|
||||
@handler generateOwnPostReplyDraft
|
||||
post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData)
|
||||
|
||||
@handler syncMentionInbox
|
||||
post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData)
|
||||
|
||||
@handler listMentionInbox
|
||||
get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData)
|
||||
|
||||
@handler publishMentionReply
|
||||
post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData)
|
||||
|
||||
@handler generateOwnPostFormula
|
||||
post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData)
|
||||
|
||||
@handler listOwnPostFormulas
|
||||
get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData)
|
||||
|
||||
@handler getPublishDashboardSummary
|
||||
get /publish-dashboard-summary returns (PublishDashboardSummaryData)
|
||||
|
||||
@handler listContentFormulas
|
||||
get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData)
|
||||
|
||||
@handler getContentFormula
|
||||
get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData)
|
||||
|
||||
@handler analyzeContentFormula
|
||||
post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData)
|
||||
|
||||
@handler searchContentFormulaPosts
|
||||
post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData)
|
||||
|
||||
@handler patchContentFormula
|
||||
patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData)
|
||||
|
||||
@handler deleteContentFormula
|
||||
delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData)
|
||||
|
||||
@handler importOwnPostToContentFormula
|
||||
post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData)
|
||||
}
|
||||
|
||||
@server(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
@ -162,6 +162,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/knowledge-graph/nodes",
|
||||
Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:id/outreach-drafts/:draftId",
|
||||
Handler: brand.UpdateOutreachDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/outreach-drafts/generate",
|
||||
|
|
@ -341,6 +346,65 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
rest.WithPrefix("/api/v1/personas"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.WorkerSecret},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/analyze-style8d",
|
||||
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/cancel-ack",
|
||||
Handler: job.AckWorkerJobCancelHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/cancel-check",
|
||||
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/complete",
|
||||
Handler: job.CompleteWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/fail",
|
||||
Handler: job.FailWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/heartbeat",
|
||||
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/progress",
|
||||
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/claim",
|
||||
Handler: job.ClaimWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/workers/personas/:id/style-profile",
|
||||
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/threads-accounts/:id/session",
|
||||
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/internal"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -425,65 +489,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
rest.WithPrefix("/api/v1"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.WorkerSecret},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/analyze-style8d",
|
||||
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/cancel-ack",
|
||||
Handler: job.AckWorkerJobCancelHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/cancel-check",
|
||||
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/complete",
|
||||
Handler: job.CompleteWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/fail",
|
||||
Handler: job.FailWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/heartbeat",
|
||||
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/:id/progress",
|
||||
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/jobs/claim",
|
||||
Handler: job.ClaimWorkerJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/workers/personas/:id/style-profile",
|
||||
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/workers/threads-accounts/:id/session",
|
||||
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/internal"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -577,6 +582,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id",
|
||||
Handler: persona.DeletePersonaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/content-formulas/:formulaId/generate",
|
||||
Handler: persona.GenerateFromContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/content-inbox",
|
||||
Handler: persona.ListPersonaContentInboxHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/copy-drafts",
|
||||
|
|
@ -597,6 +612,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/copy-drafts/:draftId/publish",
|
||||
Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/copy-drafts/:draftId/schedule",
|
||||
Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/copy-drafts/generate",
|
||||
|
|
@ -696,6 +716,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/knowledge-graph/nodes",
|
||||
Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/outreach-draft-jobs",
|
||||
Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:id/outreach-drafts/:draftId",
|
||||
Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/outreach-drafts/generate",
|
||||
|
|
@ -849,11 +879,76 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/connection",
|
||||
Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/content-formulas",
|
||||
Handler: threads_account.ListContentFormulasHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/content-formulas/:formulaId",
|
||||
Handler: threads_account.GetContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:id/content-formulas/:formulaId",
|
||||
Handler: threads_account.PatchContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id/content-formulas/:formulaId",
|
||||
Handler: threads_account.DeleteContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/content-formulas/analyze",
|
||||
Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/content-formulas/import-own-post",
|
||||
Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/content-formulas/search-posts",
|
||||
Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/diagnostics",
|
||||
Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/mention-inbox",
|
||||
Handler: threads_account.ListMentionInboxHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/mention-inbox/:media_id/reply",
|
||||
Handler: threads_account.PublishMentionReplyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/mention-inbox/sync",
|
||||
Handler: threads_account.SyncMentionInboxHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/own-post-formula",
|
||||
Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/own-post-formulas",
|
||||
Handler: threads_account.ListOwnPostFormulasHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/own-post-reply-draft",
|
||||
Handler: threads_account.GenerateOwnPostReplyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/post-performance",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
// Package embedding provides an optional OpenAI embeddings client.
|
||||
// It is intentionally not wired into patrol/graph flows; relevance uses
|
||||
// offline lexical intent scoring in knowledge/semantic_fit.go instead.
|
||||
package embedding
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -80,6 +80,32 @@ func TotalTagCandidates(nodes []Node) int {
|
|||
return count
|
||||
}
|
||||
|
||||
// PreserveNodeSelectionByLabel reapplies user scan selections after graph regen changes node IDs.
|
||||
func PreserveNodeSelectionByLabel(nodes []Node, previous []Node) {
|
||||
if len(nodes) == 0 || len(previous) == 0 {
|
||||
return
|
||||
}
|
||||
selected := map[string]struct{}{}
|
||||
for _, node := range previous {
|
||||
if !node.SelectedForScan {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(strings.TrimSpace(node.Label))
|
||||
if label != "" {
|
||||
selected[label] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range nodes {
|
||||
label := strings.ToLower(strings.TrimSpace(nodes[i].Label))
|
||||
if _, ok := selected[label]; ok {
|
||||
nodes[i].SelectedForScan = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NodeByID(nodes []Node, id string) (Node, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
for _, node := range nodes {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ type PatrolTagInput struct {
|
|||
ProductName string
|
||||
ProductFeatures string
|
||||
AudienceSummary string
|
||||
TargetAudience string
|
||||
MatchTags []string
|
||||
Questions []string
|
||||
Pillars []string
|
||||
|
|
@ -65,6 +66,7 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
|
|||
ProductName: productName,
|
||||
ProductFeatures: features,
|
||||
AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
|
||||
TargetAudience: strings.TrimSpace(brand.TargetAudience),
|
||||
MatchTags: matchTagsFromBrand(brand),
|
||||
Questions: append([]string{}, brand.ResearchMap.Questions...),
|
||||
Pillars: append([]string{}, brand.ResearchMap.Pillars...),
|
||||
|
|
@ -72,6 +74,23 @@ func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief strin
|
|||
}
|
||||
}
|
||||
|
||||
// OverlayResearchMap copies topic-level research map fields onto patrol input.
|
||||
func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput {
|
||||
if strings.TrimSpace(audienceSummary) != "" {
|
||||
in.AudienceSummary = strings.TrimSpace(audienceSummary)
|
||||
}
|
||||
if len(questions) > 0 {
|
||||
in.Questions = append([]string{}, questions...)
|
||||
}
|
||||
if len(pillars) > 0 {
|
||||
in.Pillars = append([]string{}, pillars...)
|
||||
}
|
||||
if len(patrolKeywords) > 0 {
|
||||
in.PatrolKeywords = append([]string{}, patrolKeywords...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func productLabelFromBrand(brand *branddomain.BrandSummary) string {
|
||||
if brand == nil {
|
||||
return ""
|
||||
|
|
@ -134,9 +153,33 @@ func (in PatrolTagInput) HasProductContext() bool {
|
|||
len(in.MatchTags) > 0
|
||||
}
|
||||
|
||||
var patrolCoreValueTokens = []string{
|
||||
"敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚",
|
||||
"化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友",
|
||||
}
|
||||
|
||||
func primaryPatrolValueAnchor(in PatrolTagInput) string {
|
||||
blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " +
|
||||
strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " +
|
||||
in.AudienceSummary + " " + in.TargetAudience))
|
||||
priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"}
|
||||
for _, token := range priority {
|
||||
if strings.Contains(blob, token) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
for _, token := range patrolCoreValueTokens {
|
||||
if strings.Contains(blob, token) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func productCategoryHint(productName, features string) string {
|
||||
combined := productName + " " + features
|
||||
for _, hint := range []string{
|
||||
"洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
|
||||
"沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
|
||||
"防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -162,6 +162,48 @@ func splitPatrolChunks(text string) []string {
|
|||
return []string{head, tail}
|
||||
}
|
||||
|
||||
// AnchorPatrolTagToProduct injects product category tokens when a tag lacks product context.
|
||||
func AnchorPatrolTagToProduct(tag string, in PatrolTagInput) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
hint := productCategoryHint(in.ProductName, in.ProductFeatures)
|
||||
if hint == "" {
|
||||
for _, matchTag := range in.MatchTags {
|
||||
if h := productCategoryHint(matchTag, ""); h != "" {
|
||||
hint = h
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hint == "" {
|
||||
return tag
|
||||
}
|
||||
lowerTag := strings.ToLower(tag)
|
||||
if strings.Contains(lowerTag, strings.ToLower(hint)) {
|
||||
return tag
|
||||
}
|
||||
for _, matchTag := range in.MatchTags {
|
||||
matchTag = strings.TrimSpace(strings.ToLower(matchTag))
|
||||
if len([]rune(matchTag)) >= 2 && strings.Contains(lowerTag, matchTag) {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
anchor := hint
|
||||
if valueAnchor := primaryPatrolValueAnchor(in); valueAnchor != "" {
|
||||
anchor = strings.TrimSpace(valueAnchor + " " + hint)
|
||||
}
|
||||
anchored := strings.TrimSpace(anchor + " " + tag)
|
||||
if utf8.RuneCountInString(anchored) > maxPatrolTagRunes {
|
||||
anchored = truncateRunes(anchor+" "+compressPatrolKeywords(tag), maxPatrolTagRunes)
|
||||
}
|
||||
if utf8.RuneCountInString(anchored) < minPatrolTagRunes {
|
||||
return tag
|
||||
}
|
||||
return anchored
|
||||
}
|
||||
|
||||
func ensurePatrolIntent(phrase, intent string) string {
|
||||
phrase = strings.TrimSpace(phrase)
|
||||
if phrase == "" {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package knowledge
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
|
||||
got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳")
|
||||
|
|
@ -9,6 +12,35 @@ func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAnchorPatrolTagToProductInjectsCategory(t *testing.T) {
|
||||
in := PatrolTagInput{
|
||||
ProductName: "天然洗衣精",
|
||||
MatchTags: []string{"洗衣精", "衣物清潔"},
|
||||
}
|
||||
got := AnchorPatrolTagToProduct("敏感肌 推薦", in)
|
||||
if got == "敏感肌 推薦" {
|
||||
t.Fatal("expected product category to be injected into generic tag")
|
||||
}
|
||||
if !strings.Contains(got, "洗衣") {
|
||||
t.Fatalf("expected laundry anchor in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnchorPatrolTagToProductPrefersValueAnchor(t *testing.T) {
|
||||
in := PatrolTagInput{
|
||||
ProductName: "抗敏無香洗衣精",
|
||||
ProductFeatures: "完全無香、抗敏、適合化療族群",
|
||||
MatchTags: []string{"無香", "洗衣精"},
|
||||
}
|
||||
got := AnchorPatrolTagToProduct("皮膚癢 推薦", in)
|
||||
if !strings.Contains(got, "無香") {
|
||||
t.Fatalf("expected value anchor in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "洗衣精") {
|
||||
t.Fatalf("expected product form in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) {
|
||||
got := PatrolTagFromQuestion("敏感肌保養")
|
||||
if got == "敏感肌保養" {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,27 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const MaxTopPatrolTags = 8
|
||||
const MaxTopPatrolTags = 10
|
||||
const MaxScanPatrolKeywords = 10
|
||||
|
||||
// SelectedPatrolNodes returns nodes flagged for scan.
|
||||
func SelectedPatrolNodes(nodes []Node) []Node {
|
||||
out := make([]Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if node.SelectedForScan {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NodesForPatrolKeywordDerivation prefers user-selected graph nodes when present.
|
||||
func NodesForPatrolKeywordDerivation(nodes []Node) []Node {
|
||||
if selected := SelectedPatrolNodes(nodes); len(selected) > 0 {
|
||||
return selected
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
type PatrolTagCandidate struct {
|
||||
Tag string
|
||||
|
|
@ -24,19 +44,20 @@ func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate
|
|||
addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
|
||||
}
|
||||
for i, q := range in.Questions {
|
||||
addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
|
||||
addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
|
||||
}
|
||||
for i, pillar := range in.Pillars {
|
||||
addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
|
||||
addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
|
||||
}
|
||||
for i, matchTag := range in.MatchTags {
|
||||
addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
}
|
||||
if in.ProductName != "" {
|
||||
addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if !IsPainNode(node) && node.Layer > 1 {
|
||||
nodesToUse := NodesForPatrolKeywordDerivation(nodes)
|
||||
for _, node := range nodesToUse {
|
||||
if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 {
|
||||
continue
|
||||
}
|
||||
nodeScore := scoreNode(node)
|
||||
|
|
@ -216,7 +237,7 @@ func scoreNode(node Node) int {
|
|||
base += 8
|
||||
}
|
||||
if node.ProductFitScore > 0 {
|
||||
base += node.ProductFitScore / 6
|
||||
base += node.ProductFitScore / 5
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,14 @@ func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) {
|
|||
{Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance},
|
||||
{Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance},
|
||||
{Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance},
|
||||
{Tag: "標靶 治療 沐浴", Score: 58, Reason: "周邊情境", Intent: patrolIntentRelevance},
|
||||
{Tag: "無香 沐浴乳 請問", Score: 56, Reason: "受眾提問", Intent: patrolIntentRelevance},
|
||||
}, MaxTopPatrolTags)
|
||||
if len(tags) != MaxTopPatrolTags {
|
||||
t.Fatalf("expected %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
|
||||
if len(tags) > MaxTopPatrolTags {
|
||||
t.Fatalf("expected at most %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
|
||||
}
|
||||
if len(tags) < 8 {
|
||||
t.Fatalf("expected at least 8 deduped tags, got %d: %v", len(tags), tags)
|
||||
}
|
||||
if tags[0] != "化療 沐浴乳" {
|
||||
t.Fatalf("expected highest score first, got %q", tags[0])
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package knowledge
|
||||
|
||||
// ResolveScanPatrolKeywords picks keywords for placement-scan in UI/API order:
|
||||
// explicit request > saved research map > auto-ranked from map + graph nodes.
|
||||
// ResolveScanPatrolKeywords picks the most relevant search phrases for the Search API.
|
||||
// Research map and graph nodes are inputs; UI display order is not preserved.
|
||||
func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string {
|
||||
if keywords := NormalizePatrolKeywordList(explicit); len(keywords) > 0 {
|
||||
return keywords
|
||||
}
|
||||
if keywords := NormalizePatrolKeywordList(saved); len(keywords) > 0 {
|
||||
return keywords
|
||||
}
|
||||
return NormalizePatrolKeywordList(CollectPatrolTagsFromGraph(input, nodes))
|
||||
nodes = NodesForPatrolKeywordDerivation(nodes)
|
||||
return SelectBestSearchKeywords(explicit, saved, input, nodes, MaxScanPatrolKeywords)
|
||||
}
|
||||
|
||||
func capPatrolKeywordList(keywords []string, limit int) []string {
|
||||
keywords = NormalizePatrolKeywordList(keywords)
|
||||
if limit <= 0 || len(keywords) <= limit {
|
||||
return keywords
|
||||
}
|
||||
return keywords[:limit]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,25 @@ package knowledge
|
|||
|
||||
import "testing"
|
||||
|
||||
func TestResolveScanPatrolKeywordsPrefersExplicit(t *testing.T) {
|
||||
func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) {
|
||||
got := ResolveScanPatrolKeywords(
|
||||
[]string{"手動 關鍵字"},
|
||||
[]string{"已儲存"},
|
||||
PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
|
||||
nil,
|
||||
)
|
||||
if len(got) != 1 || got[0] != "手動 關鍵字" {
|
||||
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit keyword", got)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected ranked keywords")
|
||||
}
|
||||
found := false
|
||||
for _, kw := range got {
|
||||
if kw == "手動 關鍵字" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit hint included", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -22,3 +32,29 @@ func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) {
|
|||
t.Fatal("expected research map questions to produce patrol keywords without graph nodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveScanPatrolKeywordsPrefersSelectedNodes(t *testing.T) {
|
||||
nodes := []Node{
|
||||
{ID: "pain", Label: "化療後 皮膚乾", SelectedForScan: true, ProductFitScore: 88, NodeKind: "pain", DerivedTags: DerivedTags{Relevance: []string{"化療後 皮膚乾 沐浴乳"}}},
|
||||
{ID: "skip", Label: "無關節點", SelectedForScan: false, ProductFitScore: 90, DerivedTags: DerivedTags{Relevance: []string{"不該出現"}}},
|
||||
}
|
||||
got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{}, nodes)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("expected selected node to produce patrol keywords")
|
||||
}
|
||||
found := false
|
||||
for _, kw := range got {
|
||||
if kw == "化療後 皮膚乾 沐浴乳" || kw == "化療後 皮膚乾" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected selected node keyword, got %#v", got)
|
||||
}
|
||||
for _, kw := range got {
|
||||
if kw == "不該出現" {
|
||||
t.Fatalf("unselected node leaked into keywords: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -55,23 +55,23 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
|
|||
matches := isCoreNode(node)
|
||||
|
||||
for i, q := range in.Questions {
|
||||
if matches || nodeMatchesResearchText(node, q) {
|
||||
addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
|
||||
addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
|
||||
if matches || nodeMatchesResearchText(node, q, in) {
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
|
||||
}
|
||||
}
|
||||
for i, pillar := range in.Pillars {
|
||||
if matches || nodeMatchesResearchText(node, pillar) {
|
||||
addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
|
||||
if matches || nodeMatchesResearchText(node, pillar, in) {
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
|
||||
}
|
||||
}
|
||||
for i, matchTag := range in.MatchTags {
|
||||
if matches || nodeMatchesResearchText(node, matchTag) {
|
||||
addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
if matches || nodeMatchesResearchText(node, matchTag, in) {
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
}
|
||||
}
|
||||
if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) {
|
||||
addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) {
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 && IsPainNode(node) {
|
||||
|
|
@ -79,16 +79,16 @@ func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
|
|||
if i >= 2 {
|
||||
break
|
||||
}
|
||||
addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 && IsPainNode(node) {
|
||||
addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||||
for i, matchTag := range in.MatchTags {
|
||||
if i >= 1 {
|
||||
break
|
||||
}
|
||||
addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ func isCoreNode(node Node) bool {
|
|||
return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core")
|
||||
}
|
||||
|
||||
func nodeMatchesResearchText(node Node, text string) bool {
|
||||
func nodeMatchesResearchText(node Node, text string, in PatrolTagInput) bool {
|
||||
label := strings.TrimSpace(node.Label)
|
||||
text = strings.TrimSpace(text)
|
||||
if label == "" || text == "" {
|
||||
|
|
@ -116,7 +116,12 @@ func nodeMatchesResearchText(node Node, text string) bool {
|
|||
if label == text || strings.Contains(text, label) || strings.Contains(label, text) {
|
||||
return true
|
||||
}
|
||||
return sharesMeaningfulSubstring(label, text)
|
||||
if sharesMeaningfulSubstring(label, text) {
|
||||
return true
|
||||
}
|
||||
profile := BuildIntentProfile(in)
|
||||
joined := strings.TrimSpace(NodeIntentText(node) + " " + text)
|
||||
return ScoreIntentSimilarity(joined, profile) >= 40
|
||||
}
|
||||
|
||||
func deriveFallbackPatrolTags(node Node) DerivedTags {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -24,14 +24,16 @@ type GenerateResult struct {
|
|||
}
|
||||
|
||||
type GenerateInput struct {
|
||||
Persona string
|
||||
TopicLabel string
|
||||
AudienceBrief string
|
||||
ProductBrief string
|
||||
PlacementReason string
|
||||
TargetText string
|
||||
AuthorName string
|
||||
Count int
|
||||
Persona string
|
||||
TopicLabel string
|
||||
AudienceBrief string
|
||||
ProductBrief string
|
||||
PlacementReason string
|
||||
TargetText string
|
||||
AuthorName string
|
||||
Count int
|
||||
Regenerate bool
|
||||
PreviousDraftsHint string
|
||||
}
|
||||
|
||||
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
||||
|
|
@ -65,6 +67,13 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
|
|||
if author == "" {
|
||||
author = "匿名"
|
||||
}
|
||||
regenerateLine := ""
|
||||
if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" {
|
||||
regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿:換開頭、換切角、換句式與情境細節,不要複製舊文案。"
|
||||
if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" {
|
||||
regenerateLine += "\n" + hint
|
||||
}
|
||||
}
|
||||
return libprompt.OutreachPlacementUser(map[string]string{
|
||||
"persona_block": personaBlock,
|
||||
"topic_label": topic,
|
||||
|
|
@ -73,6 +82,7 @@ func BuildUserPrompt(in GenerateInput) (string, error) {
|
|||
"placement_reason_line": reasonLine,
|
||||
"author_name": author,
|
||||
"target_text": strings.TrimSpace(in.TargetText),
|
||||
"regenerate_line": regenerateLine,
|
||||
"count": fmt.Sprintf("%d", count),
|
||||
})
|
||||
}
|
||||
|
|
@ -90,7 +100,7 @@ func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
|||
return GenerateResult{}, fmt.Errorf("outreach drafts missing")
|
||||
}
|
||||
for i := range out.Drafts {
|
||||
out.Drafts[i].Text = trimDraftText(out.Drafts[i].Text)
|
||||
out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text)
|
||||
if out.Drafts[i].Text == "" {
|
||||
return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) + "…"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -46,8 +46,5 @@ func publishCapabilityHint(member MemberContext) string {
|
|||
if strings.TrimSpace(member.ActiveAccountID) == "" {
|
||||
return "請先建立並選定經營帳號"
|
||||
}
|
||||
if member.DevMode && !member.AllowsThreadsAPI {
|
||||
return "開發模式請在連線設定啟用 API 發文"
|
||||
}
|
||||
return "請先完成 Threads API 連線後再發布貼文"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,27 @@ func BuildMemberContext(
|
|||
}
|
||||
}
|
||||
|
||||
// MemberContextForAPIOnly forces Threads / web-search discover and disables Playwright routing.
|
||||
// Used for all flows except explicit test-patrol jobs.
|
||||
func MemberContextForAPIOnly(base MemberContext) MemberContext {
|
||||
base.DevMode = false
|
||||
base.SearchSourceMode = WithoutCrawler(base.SearchSourceMode)
|
||||
base.AllowsCrawler = false
|
||||
base.AllowsThreadsAPI = ModeAllowsThreadsAPI(base.SearchSourceMode)
|
||||
base.AllowsBrave = ModeAllowsBrave(base.SearchSourceMode)
|
||||
return base
|
||||
}
|
||||
|
||||
// MemberContextForCrawlerOnly forces Playwright-only discover for test patrol jobs.
|
||||
func MemberContextForCrawlerOnly(base MemberContext) MemberContext {
|
||||
base.DevMode = true
|
||||
base.SearchSourceMode = SearchSourceCrawler
|
||||
base.AllowsCrawler = true
|
||||
base.AllowsBrave = false
|
||||
base.AllowsThreadsAPI = false
|
||||
return base
|
||||
}
|
||||
|
||||
func (c MemberContext) PayloadFields() map[string]any {
|
||||
return map[string]any{
|
||||
"tenant_id": c.TenantID,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -49,13 +49,13 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
|
|||
if ShouldTryCrawlerFirst(m) {
|
||||
posts, err := runCrawlerDiscover(ctx, req)
|
||||
if err == nil && len(posts) > 0 {
|
||||
return posts, DiscoverCrawler, nil
|
||||
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
|
||||
}
|
||||
if m.SearchSourceMode == SearchSourceCrawler {
|
||||
if err != nil {
|
||||
return nil, DiscoverCrawler, err
|
||||
}
|
||||
return posts, DiscoverCrawler, nil
|
||||
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
|
|||
if m.CrawlerFallbackAllowed() {
|
||||
cPosts, cErr := runCrawlerDiscover(ctx, req)
|
||||
if cErr == nil && len(cPosts) > 0 {
|
||||
return cPosts, DiscoverCrawler, nil
|
||||
return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil
|
||||
}
|
||||
}
|
||||
if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
|
||||
|
|
@ -89,7 +89,7 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
|
|||
if m.CrawlerFallbackAllowed() {
|
||||
posts, err := runCrawlerDiscover(ctx, req)
|
||||
if err == nil {
|
||||
return posts, DiscoverCrawler, nil
|
||||
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel())
|
||||
|
|
@ -102,8 +102,15 @@ func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, Discove
|
|||
if err != nil {
|
||||
return nil, DiscoverCrawler, err
|
||||
}
|
||||
return posts, DiscoverCrawler, nil
|
||||
return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
|
||||
}
|
||||
|
||||
func maybeEnrichDiscoverPosts(ctx context.Context, m MemberContext, posts []DiscoverPost) []DiscoverPost {
|
||||
if !m.ApiConnected {
|
||||
return posts
|
||||
}
|
||||
return EnrichDiscoverPostsMediaIDs(ctx, m.ThreadsAPIAccessToken, posts)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ type ScanCandidate struct {
|
|||
Text string
|
||||
SearchTag string
|
||||
QueryDimension QueryDimension
|
||||
RecencyDays int
|
||||
GraphNodeID string
|
||||
ProductFitScore int
|
||||
Source DiscoverChannel
|
||||
|
|
@ -50,6 +51,7 @@ type DualTrackInput struct {
|
|||
Nodes []libkg.Node
|
||||
PatrolKeywords []string
|
||||
Exclusions []string
|
||||
PatrolContext PostScanContext
|
||||
Member MemberContext
|
||||
WebSearch websearch.Client
|
||||
Crawler CrawlerSearchFn
|
||||
|
|
@ -104,17 +106,6 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
|
|||
RecencyDays: IdealMaxPostAgeDays,
|
||||
})
|
||||
}
|
||||
q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays)
|
||||
if q30 != "" && q30 != q7 {
|
||||
out = append(out, TagQuery{
|
||||
Tag: tag,
|
||||
Query: q30,
|
||||
Dimension: QueryRecency,
|
||||
GraphNodeID: node.ID,
|
||||
ProductFitScore: fit,
|
||||
RecencyDays: MaxPostAgeDays,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
@ -122,7 +113,12 @@ func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQue
|
|||
|
||||
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
||||
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
|
||||
queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords, input.Member.WebSearchProviderEnum())
|
||||
queries := ResolveTagQueries(
|
||||
input.Nodes,
|
||||
input.PatrolKeywords,
|
||||
input.Member.WebSearchProviderEnum(),
|
||||
PatrolTagInputFromScanContext(input.PatrolContext),
|
||||
)
|
||||
if len(queries) == 0 {
|
||||
if len(input.PatrolKeywords) > 0 {
|
||||
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
||||
|
|
@ -145,15 +141,31 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
runQuery := func(tq TagQuery, limit int) error {
|
||||
posts, channel, err := discoverForQuery(ctx, input, tq, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
if onProgress != nil {
|
||||
onProgress(fmt.Sprintf("略過「%s」:%s", tq.Tag, err.Error()), -1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, post := range posts {
|
||||
if MatchesExclusion(post.Text, input.Exclusions) {
|
||||
continue
|
||||
}
|
||||
if !PassesPlacementFilter(post.Text) {
|
||||
if LooksLikeCasualChat(post.Text) {
|
||||
continue
|
||||
}
|
||||
postedAt := strings.TrimSpace(post.PostedAt)
|
||||
if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) {
|
||||
continue
|
||||
}
|
||||
bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext)
|
||||
semanticScore := LocalIntentScore(post.Text, input.PatrolContext)
|
||||
effectiveFit := bodyFit
|
||||
if tq.ProductFitScore > 0 && bodyFit > 0 {
|
||||
effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3
|
||||
}
|
||||
key := post.Permalink
|
||||
if key == "" {
|
||||
continue
|
||||
|
|
@ -170,6 +182,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
extID = parsed.ExternalID
|
||||
}
|
||||
}
|
||||
semP := &semanticScore
|
||||
merged[key] = &ScanCandidate{
|
||||
Permalink: post.Permalink,
|
||||
ExternalID: extID,
|
||||
|
|
@ -179,17 +192,19 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
Text: post.Text,
|
||||
SearchTag: tq.Tag,
|
||||
QueryDimension: tq.Dimension,
|
||||
RecencyDays: recencyDaysForCandidate(tq),
|
||||
GraphNodeID: tq.GraphNodeID,
|
||||
ProductFitScore: tq.ProductFitScore,
|
||||
ProductFitScore: effectiveFit,
|
||||
Source: channel,
|
||||
HasRelevance: tq.Dimension == QueryRelevance,
|
||||
HasRecency: tq.Dimension == QueryRecency,
|
||||
Priority: priority,
|
||||
LikeCount: post.LikeCount,
|
||||
ReplyCount: post.ReplyCount,
|
||||
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency, nil, nil, nil),
|
||||
SolvedByProduct: tq.ProductFitScore >= 55,
|
||||
PostedAt: strings.TrimSpace(post.PostedAt),
|
||||
SemanticScore: semanticScore,
|
||||
PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
|
||||
SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
|
||||
PostedAt: postedAt,
|
||||
}
|
||||
order = append(order, key)
|
||||
continue
|
||||
|
|
@ -199,14 +214,27 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
}
|
||||
if tq.Dimension == QueryRecency {
|
||||
existing.HasRecency = true
|
||||
existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
|
||||
}
|
||||
if tq.ProductFitScore > existing.ProductFitScore {
|
||||
existing.ProductFitScore = tq.ProductFitScore
|
||||
existing.SolvedByProduct = tq.ProductFitScore >= 55
|
||||
if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
|
||||
if effectiveFit > existing.ProductFitScore {
|
||||
existing.ProductFitScore = effectiveFit
|
||||
}
|
||||
if semanticScore > existing.SemanticScore {
|
||||
existing.SemanticScore = semanticScore
|
||||
}
|
||||
existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext)
|
||||
semP := existing.SemanticScore
|
||||
semPtr := &semP
|
||||
if semP <= 0 {
|
||||
semPtr = nil
|
||||
}
|
||||
existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil)
|
||||
}
|
||||
if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
|
||||
existing.PostedAt = strings.TrimSpace(post.PostedAt)
|
||||
}
|
||||
existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -217,15 +245,12 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
pct := 10 + ((i + 1) * 75 / max(total, 1))
|
||||
onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
|
||||
}
|
||||
limit := relevanceLimitPerTag
|
||||
if tq.Dimension == QueryRecency {
|
||||
limit = recencyLimitPerTag
|
||||
}
|
||||
limit := perTagDiscoverLimit(total, tq.Dimension)
|
||||
if err := runQuery(tq, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.OnCheckpoint != nil {
|
||||
snapshot := snapshotMergedCandidates(merged, order, false)
|
||||
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
|
||||
if err := input.OnCheckpoint(snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -237,7 +262,48 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
}
|
||||
}
|
||||
|
||||
out := snapshotMergedCandidates(merged, order, true)
|
||||
cascadeExtra := 0
|
||||
for _, st := range buildTagCascadeStates(queries) {
|
||||
for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag {
|
||||
if cascadeExtra >= MaxRecencyCascadeQueries {
|
||||
break
|
||||
}
|
||||
days := nextRecencyCascadeWindow(st.RanWindows)
|
||||
if days == 0 {
|
||||
break
|
||||
}
|
||||
tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum())
|
||||
if !ok {
|
||||
st.RanWindows[days] = true
|
||||
continue
|
||||
}
|
||||
if onProgress != nil {
|
||||
onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1)
|
||||
}
|
||||
limit := perTagDiscoverLimit(total, QueryRecency)
|
||||
if err := runQuery(tq, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.RanWindows[days] = true
|
||||
cascadeExtra++
|
||||
if input.OnCheckpoint != nil {
|
||||
snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
|
||||
if err := input.OnCheckpoint(snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Member.AllowsCrawler && input.Member.BrowserConnected {
|
||||
if err := politeDiscoverPause(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if cascadeExtra >= MaxRecencyCascadeQueries {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := snapshotMergedCandidates(merged, order, true, input.PatrolContext)
|
||||
if onProgress != nil {
|
||||
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
|
||||
}
|
||||
|
|
@ -245,32 +311,71 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
}
|
||||
|
||||
func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
|
||||
apiKeyword := tq.Tag
|
||||
if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" {
|
||||
apiKeyword = shaped
|
||||
}
|
||||
req := DiscoverRequest{
|
||||
Query: tq.Query,
|
||||
Keyword: tq.Tag,
|
||||
Keyword: apiKeyword,
|
||||
Recency: tq.Dimension == QueryRecency,
|
||||
Limit: limit,
|
||||
Member: input.Member,
|
||||
Crawler: input.Crawler,
|
||||
}
|
||||
posts, channel, err := Discover(ctx, req)
|
||||
if err == nil && len(posts) > 0 {
|
||||
return posts, channel, nil
|
||||
merged := map[string]DiscoverPost{}
|
||||
channel := DiscoverChannel("")
|
||||
posts, primaryChannel, err := Discover(ctx, req)
|
||||
for _, post := range posts {
|
||||
key := strings.TrimSpace(post.Permalink)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if existing, ok := merged[key]; ok {
|
||||
post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
|
||||
}
|
||||
merged[key] = post
|
||||
}
|
||||
if input.WebSearch == nil || !input.WebSearch.Enabled() {
|
||||
if primaryChannel != "" {
|
||||
channel = primaryChannel
|
||||
}
|
||||
|
||||
webEnabled := input.WebSearch != nil && input.WebSearch.Enabled()
|
||||
if webEnabled && input.Member.AllowsBrave {
|
||||
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
|
||||
if werr != nil && len(merged) == 0 && err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, post := range webPosts {
|
||||
key := strings.TrimSpace(post.Permalink)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
merged[key] = post
|
||||
}
|
||||
if len(merged) > 0 && channel == "" {
|
||||
channel = input.Member.WebSearchDiscoverChannel()
|
||||
}
|
||||
} else if len(merged) == 0 {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
|
||||
}
|
||||
webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
|
||||
if werr != nil {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
if !webEnabled {
|
||||
return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
|
||||
}
|
||||
return nil, "", werr
|
||||
}
|
||||
return webPosts, input.Member.WebSearchDiscoverChannel(), nil
|
||||
|
||||
out := make([]DiscoverPost, 0, len(merged))
|
||||
for _, post := range merged {
|
||||
out = append(out, post)
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
if channel == "" && len(out) > 0 {
|
||||
channel = DiscoverThreadsAPI
|
||||
}
|
||||
return out, channel, nil
|
||||
}
|
||||
|
||||
func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
|
||||
|
|
@ -307,20 +412,50 @@ func discoverViaWebSearch(ctx context.Context, client websearch.Client, member M
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate {
|
||||
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate {
|
||||
out := make([]ScanCandidate, 0, len(order))
|
||||
for _, key := range order {
|
||||
item := merged[key]
|
||||
finalizeScanCandidate(item)
|
||||
if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" {
|
||||
finalizeScanCandidate(item, ctx)
|
||||
if applyFinalFilter && !passesFinalStrictFilter(item) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *item)
|
||||
}
|
||||
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
|
||||
for _, key := range order {
|
||||
item := merged[key]
|
||||
finalizeScanCandidate(item, ctx)
|
||||
if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *item)
|
||||
}
|
||||
}
|
||||
if applyFinalFilter && len(out) == 0 && len(order) > 0 {
|
||||
for _, key := range order {
|
||||
item := merged[key]
|
||||
finalizeScanCandidate(item, ctx)
|
||||
if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func finalizeScanCandidate(item *ScanCandidate) {
|
||||
func passesFinalStrictFilter(item *ScanCandidate) bool {
|
||||
if item == nil {
|
||||
return false
|
||||
}
|
||||
if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" {
|
||||
return false
|
||||
}
|
||||
return item.SolvedByProduct
|
||||
}
|
||||
|
||||
func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -345,7 +480,7 @@ func finalizeScanCandidate(item *ScanCandidate) {
|
|||
qualP = &v
|
||||
}
|
||||
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
|
||||
item.SolvedByProduct = item.ProductFitScore >= 55
|
||||
item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx)
|
||||
}
|
||||
|
||||
func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
|
||||
|
|
@ -413,6 +548,55 @@ func max(a, b int) int {
|
|||
return b
|
||||
}
|
||||
|
||||
func recencyDaysForCandidate(tq TagQuery) int {
|
||||
if tq.Dimension != QueryRecency {
|
||||
return 0
|
||||
}
|
||||
if tq.RecencyDays > 0 {
|
||||
return tq.RecencyDays
|
||||
}
|
||||
return IdealMaxPostAgeDays
|
||||
}
|
||||
|
||||
func mergeRecencyDays(existing, incoming int) int {
|
||||
if incoming <= 0 {
|
||||
return existing
|
||||
}
|
||||
if existing <= 0 {
|
||||
return incoming
|
||||
}
|
||||
if incoming < existing {
|
||||
return incoming
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func previousRecencyWindow(days int) int {
|
||||
prev := IdealMaxPostAgeDays
|
||||
for _, window := range RecencyCascadeDays() {
|
||||
if window == days {
|
||||
return prev
|
||||
}
|
||||
prev = window
|
||||
}
|
||||
return IdealMaxPostAgeDays
|
||||
}
|
||||
|
||||
func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int {
|
||||
limit := relevanceLimitPerTag
|
||||
if dimension == QueryRecency {
|
||||
limit = recencyLimitPerTag
|
||||
}
|
||||
switch {
|
||||
case totalQueries > 18:
|
||||
return max(7, limit-3)
|
||||
case totalQueries > 14:
|
||||
return max(8, limit-2)
|
||||
default:
|
||||
return limit
|
||||
}
|
||||
}
|
||||
|
||||
func politeDiscoverPause(ctx context.Context) error {
|
||||
wait := 2*time.Second + jitterDuration(2*time.Second)
|
||||
timer := time.NewTimer(wait)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import "regexp"
|
|||
var (
|
||||
placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
|
||||
placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
|
||||
painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`)
|
||||
casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
|
||||
)
|
||||
|
||||
|
|
@ -35,3 +36,11 @@ func PassesPlacementFilter(text string) bool {
|
|||
}
|
||||
return HasPlacementIntent(text)
|
||||
}
|
||||
|
||||
// HasPainPointDemand requires the post to read like someone asking for help with a solvable problem.
|
||||
func HasPainPointDemand(text string) bool {
|
||||
if !PassesPlacementFilter(text) {
|
||||
return false
|
||||
}
|
||||
return painProblemRe.MatchString(text) || LooksLikeRecommendationPost(text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,8 +23,14 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
|
|||
if len(match) < 3 {
|
||||
return ParsedThreadsPost{}, false
|
||||
}
|
||||
text := strings.TrimSpace(strings.Join([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}, " — "))
|
||||
if len([]rune(text)) < 8 {
|
||||
text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — "))
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(title)
|
||||
}
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(snippet)
|
||||
}
|
||||
if len([]rune(text)) < 6 {
|
||||
return ParsedThreadsPost{}, false
|
||||
}
|
||||
return ParsedThreadsPost{
|
||||
|
|
@ -35,6 +41,16 @@ func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPos
|
|||
}, true
|
||||
}
|
||||
|
||||
func filterNonEmpty(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeThreadsPermalink(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,31 @@
|
|||
package placement
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
libkg "haixun-backend/internal/library/knowledge"
|
||||
"haixun-backend/internal/library/websearch"
|
||||
)
|
||||
|
||||
const defaultPatrolProductFit = 78
|
||||
const (
|
||||
defaultPatrolProductFitNoGraph = 42
|
||||
maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
|
||||
)
|
||||
|
||||
// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only.
|
||||
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider) []TagQuery {
|
||||
// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
|
||||
func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
|
||||
keywords = libkg.NormalizePatrolKeywordList(keywords)
|
||||
if len(keywords) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(keywords) > libkg.MaxScanPatrolKeywords {
|
||||
keywords = keywords[:libkg.MaxScanPatrolKeywords]
|
||||
}
|
||||
|
||||
out := make([]TagQuery, 0, len(keywords)*3)
|
||||
out := make([]TagQuery, 0, len(keywords)*2)
|
||||
for _, tag := range keywords {
|
||||
fit := productFitForPatrolTag(tag, nodes)
|
||||
fit := productFitForPatrolTag(tag, nodes, patrolInput)
|
||||
if q := BuildRelevanceQuery(provider, tag); q != "" {
|
||||
out = append(out, TagQuery{
|
||||
Tag: tag,
|
||||
|
|
@ -36,25 +43,15 @@ func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider web
|
|||
RecencyDays: IdealMaxPostAgeDays,
|
||||
})
|
||||
}
|
||||
if q30 := BuildRecencyQuery(provider, tag, MaxPostAgeDays); q30 != "" {
|
||||
if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q30 != q7 {
|
||||
out = append(out, TagQuery{
|
||||
Tag: tag,
|
||||
Query: q30,
|
||||
Dimension: QueryRecency,
|
||||
ProductFitScore: fit,
|
||||
RecencyDays: MaxPostAgeDays,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
|
||||
func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
|
||||
tagKey := patrolTagMatchKey(tag)
|
||||
best := 0
|
||||
bestNodeID := ""
|
||||
profile := libkg.BuildIntentProfile(patrolInput)
|
||||
intentFit := libkg.ScoreIntentSimilarity(tag, profile)
|
||||
for _, node := range nodes {
|
||||
score := node.ProductFitScore
|
||||
if score <= 0 {
|
||||
|
|
@ -72,14 +69,25 @@ func productFitForPatrolTag(tag string, nodes []libkg.Node) int {
|
|||
}
|
||||
if score > best {
|
||||
best = score
|
||||
bestNodeID = node.ID
|
||||
}
|
||||
}
|
||||
if best > 0 {
|
||||
return best
|
||||
return maxPatrolFit(best, intentFit)
|
||||
}
|
||||
_ = bestNodeID
|
||||
return defaultPatrolProductFit
|
||||
if intentFit > 0 {
|
||||
return intentFit
|
||||
}
|
||||
return defaultPatrolProductFitNoGraph
|
||||
}
|
||||
|
||||
func maxPatrolFit(values ...int) int {
|
||||
best := 0
|
||||
for _, v := range values {
|
||||
if v > best {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func patrolTagMatchKey(tag string) string {
|
||||
|
|
@ -93,10 +101,66 @@ func patrolTagMatchKey(tag string) string {
|
|||
return strings.TrimSpace(tag)
|
||||
}
|
||||
|
||||
// ResolveTagQueries prefers explicit patrol keywords over graph node selection.
|
||||
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider) []TagQuery {
|
||||
if len(patrolKeywords) > 0 {
|
||||
return CollectPatrolTagQueries(patrolKeywords, nodes, provider)
|
||||
// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
|
||||
func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
|
||||
nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
|
||||
keywords := patrolKeywords
|
||||
if len(keywords) == 0 {
|
||||
keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords)
|
||||
}
|
||||
return CollectTagQueries(nodes, provider)
|
||||
queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput)
|
||||
if len(queries) == 0 {
|
||||
queries = CollectTagQueries(nodes, provider)
|
||||
}
|
||||
return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput)
|
||||
}
|
||||
|
||||
func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery {
|
||||
profile := libkg.BuildIntentProfile(patrolInput)
|
||||
if max <= 0 || len(queries) <= max {
|
||||
return queries
|
||||
}
|
||||
type ranked struct {
|
||||
query TagQuery
|
||||
key string
|
||||
}
|
||||
rankedQueries := make([]ranked, 0, len(queries))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range queries {
|
||||
key := strings.TrimSpace(item.Query)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
rankedQueries = append(rankedQueries, ranked{query: item, key: key})
|
||||
}
|
||||
sort.SliceStable(rankedQueries, func(i, j int) bool {
|
||||
left := rankedQueries[i].query.ProductFitScore
|
||||
right := rankedQueries[j].query.ProductFitScore
|
||||
leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile)
|
||||
rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile)
|
||||
leftRank := left*2 + leftIntent*3
|
||||
rightRank := right*2 + rightIntent*3
|
||||
if leftRank == rightRank {
|
||||
if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension {
|
||||
return rankedQueries[i].key < rankedQueries[j].key
|
||||
}
|
||||
if rankedQueries[i].query.Dimension == QueryRelevance {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return leftRank > rightRank
|
||||
})
|
||||
out := make([]TagQuery, 0, max)
|
||||
for _, item := range rankedQueries {
|
||||
out = append(out, item.query)
|
||||
if len(out) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import (
|
|||
)
|
||||
|
||||
func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
|
||||
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave)
|
||||
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{})
|
||||
if len(queries) < 2 {
|
||||
t.Fatalf("expected relevance + recency queries, got %d", len(queries))
|
||||
}
|
||||
if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance {
|
||||
t.Fatalf("unexpected first query: %+v", queries[0])
|
||||
}
|
||||
if queries[0].ProductFitScore != defaultPatrolProductFit {
|
||||
t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore)
|
||||
if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph {
|
||||
t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFitNoGraph, queries[0].ProductFitScore)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave)
|
||||
queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave, libkg.PatrolTagInput{})
|
||||
if len(queries) == 0 || queries[0].ProductFitScore != 92 {
|
||||
t.Fatalf("expected graph fit 92, got %+v", queries)
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
|
|||
nodes := []libkg.Node{
|
||||
{ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
|
||||
}
|
||||
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave)
|
||||
queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave, libkg.PatrolTagInput{})
|
||||
if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
|
||||
t.Fatalf("expected patrol keyword query, got %+v", queries)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +60,8 @@ func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag st
|
|||
if len(brand.Products) > 0 {
|
||||
picked := RecommendProduct(brand.Products, searchTag, productID)
|
||||
if picked != nil {
|
||||
return BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
|
||||
merged := BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
|
||||
return ApplyPlacementURL(merged, picked.PlacementURL)
|
||||
}
|
||||
}
|
||||
if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,20 @@ func FormatProductContextForPrompt(raw string) string {
|
|||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func ApplyPlacementURL(productContext, placementURL string) string {
|
||||
placementURL = strings.TrimSpace(placementURL)
|
||||
if placementURL == "" {
|
||||
return productContext
|
||||
}
|
||||
fields := ParseProductContext(productContext)
|
||||
if fields.CtaType == CtaLink && strings.TrimSpace(fields.CtaUrl) != "" {
|
||||
return productContext
|
||||
}
|
||||
fields.CtaType = CtaLink
|
||||
fields.CtaUrl = placementURL
|
||||
return SerializeProductContext(fields)
|
||||
}
|
||||
|
||||
func ProductBriefFromContext(raw string) string {
|
||||
formatted := FormatProductContextForPrompt(raw)
|
||||
if formatted != "" {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ package placement
|
|||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
brandentity "haixun-backend/internal/model/brand/domain/entity"
|
||||
)
|
||||
|
||||
func ScoreProductForTag(searchTag string, matchTags []string) int {
|
||||
|
|
@ -40,6 +42,71 @@ func ScoreProductForTag(searchTag string, matchTags []string) int {
|
|||
return best
|
||||
}
|
||||
|
||||
// ScoreProductForPost ranks a SKU against the search tag and post body.
|
||||
func ScoreProductForPost(productLabel string, matchTags []string, searchTag, postText string) int {
|
||||
tagScore := ScoreProductForTag(searchTag, matchTags)
|
||||
if tagScore == 0 && strings.TrimSpace(productLabel) != "" {
|
||||
tagScore = ScoreProductForTag(searchTag, []string{productLabel})
|
||||
}
|
||||
postScore := ScoreProductForTag(postText, matchTags)
|
||||
if postScore == 0 && strings.TrimSpace(productLabel) != "" {
|
||||
postScore = ScoreProductForTag(postText, []string{productLabel})
|
||||
}
|
||||
for _, token := range categoryTokens(productLabel, matchTags) {
|
||||
if strings.Contains(strings.ToLower(postText), token) {
|
||||
postScore = maxInt(postScore, 78)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(searchTag), token) {
|
||||
tagScore = maxInt(tagScore, 82)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case tagScore > 0 && postScore > 0:
|
||||
return (tagScore*2 + postScore*3) / 5
|
||||
case postScore > 0:
|
||||
return postScore
|
||||
default:
|
||||
return tagScore
|
||||
}
|
||||
}
|
||||
|
||||
// RecommendProductForPost picks the best SKU for a single post.
|
||||
func RecommendProductForPost(products []brandentity.Product, searchTag, postText, defaultProductID string) *brandentity.Product {
|
||||
if len(products) == 0 {
|
||||
return nil
|
||||
}
|
||||
bestScore := 0
|
||||
var best *brandentity.Product
|
||||
for i := range products {
|
||||
score := ScoreProductForPost(products[i].Label, products[i].MatchTags, searchTag, postText)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = &products[i]
|
||||
}
|
||||
}
|
||||
if best != nil && bestScore >= 55 {
|
||||
return best
|
||||
}
|
||||
return RecommendProduct(products, searchTag, defaultProductID)
|
||||
}
|
||||
|
||||
func categoryTokens(productLabel string, matchTags []string) []string {
|
||||
blob := strings.ToLower(strings.TrimSpace(productLabel + " " + strings.Join(matchTags, " ")))
|
||||
tokens := []string{
|
||||
"洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
|
||||
"沐浴乳", "沐浴露",
|
||||
"洗髮精", "洗髮乳",
|
||||
"洗碗精", "洗碗劑",
|
||||
}
|
||||
out := make([]string, 0, 4)
|
||||
for _, token := range tokens {
|
||||
if strings.Contains(blob, token) {
|
||||
out = append(out, token)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
libkg "haixun-backend/internal/library/knowledge"
|
||||
"haixun-backend/internal/library/websearch"
|
||||
)
|
||||
|
||||
|
|
@ -25,26 +26,37 @@ type TagQuery struct {
|
|||
}
|
||||
|
||||
func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
|
||||
full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag))
|
||||
if full == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Threads 貼文 繁體中文 %s", full)
|
||||
}
|
||||
core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(tag)
|
||||
}
|
||||
if core == "" {
|
||||
return ""
|
||||
}
|
||||
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
|
||||
return fmt.Sprintf("Threads 貼文 繁體中文 %s", tag)
|
||||
}
|
||||
return `site:threads.net "` + tag + `"`
|
||||
// Unquoted site search matches more real Threads posts than exact long phrases.
|
||||
return `site:threads.net ` + core + ` 推薦`
|
||||
}
|
||||
|
||||
func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(tag)
|
||||
}
|
||||
if core == "" {
|
||||
return ""
|
||||
}
|
||||
if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
|
||||
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s", tag)
|
||||
return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core)
|
||||
}
|
||||
after := FormatAfterDate(maxAgeDays, timeNow())
|
||||
return `site:threads.net "` + tag + `" 請問 after:` + after
|
||||
return `site:threads.net "` + core + `" 請問 after:` + after
|
||||
}
|
||||
|
||||
func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,18 @@ const (
|
|||
MaxPostAgeDays = 30
|
||||
IdealMaxPostAgeDays = 7
|
||||
WebSearchMaxAgeDays = 14
|
||||
|
||||
// MinRecencyCandidatesPerTag triggers wider recency windows when a keyword finds fewer posts.
|
||||
MinRecencyCandidatesPerTag = 1
|
||||
// MaxRecencyCascadeQueries caps extra Search API calls per patrol run.
|
||||
MaxRecencyCascadeQueries = 10
|
||||
)
|
||||
|
||||
// RecencyCascadeDays is the patrol recency ladder: nearest window first, then shift older.
|
||||
func RecencyCascadeDays() []int {
|
||||
return []int{IdealMaxPostAgeDays, WebSearchMaxAgeDays, MaxPostAgeDays}
|
||||
}
|
||||
|
||||
func FormatAfterDate(maxAgeDays int, now time.Time) string {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) st
|
|||
3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機
|
||||
4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**)
|
||||
5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起
|
||||
6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
|
||||
6. patrolKeywords 至少 8 組、最多 10 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ func BuildResearchMapSystemPrompt() string {
|
|||
- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等)
|
||||
- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal
|
||||
|
||||
## patrolKeywords(6~8 組,每組 6~14 字)
|
||||
## patrolKeywords(8~10 組,每組 6~14 字)
|
||||
- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句**
|
||||
- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」
|
||||
- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool {
|
|||
|
||||
// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
|
||||
func MemberNeedsWebSearchKey(ctx MemberContext) bool {
|
||||
if !ctx.AllowsBrave || ctx.DevMode {
|
||||
if !ctx.AllowsBrave {
|
||||
return false
|
||||
}
|
||||
switch ctx.SearchSourceMode {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ func TestMemberNeedsBraveKey(t *testing.T) {
|
|||
if !MemberNeedsBraveKey(braveOnly) {
|
||||
t.Fatal("brave-only should require brave key")
|
||||
}
|
||||
devCrawler := MemberContext{AllowsBrave: true, DevMode: true, SearchSourceMode: SearchSourceCrawler}
|
||||
if MemberNeedsBraveKey(devCrawler) {
|
||||
t.Fatal("dev crawler should not require brave key")
|
||||
testPatrol := MemberContextForCrawlerOnly(MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave})
|
||||
if MemberNeedsBraveKey(testPatrol) {
|
||||
t.Fatal("test patrol crawler should not require brave key")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
libkg "haixun-backend/internal/library/knowledge"
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
)
|
||||
|
||||
|
|
@ -24,14 +25,19 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
|
|||
limit = 12
|
||||
}
|
||||
|
||||
// Strip site: prefix for API keyword search.
|
||||
query := strings.TrimSpace(req.Query)
|
||||
query = strings.TrimPrefix(query, "site:threads.net ")
|
||||
query = strings.Trim(query, `"`)
|
||||
if idx := strings.Index(query, " after:"); idx > 0 {
|
||||
query = strings.TrimSpace(query[:idx])
|
||||
query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword))
|
||||
if query == "" {
|
||||
query = strings.TrimSpace(req.Query)
|
||||
query = strings.TrimPrefix(query, "site:threads.net ")
|
||||
query = strings.Trim(query, `"`)
|
||||
if idx := strings.Index(query, " after:"); idx > 0 {
|
||||
query = strings.TrimSpace(query[:idx])
|
||||
}
|
||||
query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`))
|
||||
}
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("threads api keyword is empty")
|
||||
}
|
||||
query = strings.Trim(query, `"`)
|
||||
|
||||
items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
|
||||
Query: query,
|
||||
|
|
@ -48,14 +54,18 @@ func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]Dis
|
|||
if text == "" {
|
||||
continue
|
||||
}
|
||||
externalID := strings.TrimSpace(item.ID)
|
||||
if !libthreads.IsNumericMediaID(externalID) {
|
||||
continue
|
||||
}
|
||||
permalink := strings.TrimSpace(item.Permalink)
|
||||
if permalink == "" && item.Username != "" && item.ID != "" {
|
||||
permalink = "https://www.threads.net/@" + item.Username + "/post/" + item.ID
|
||||
if permalink == "" && item.Username != "" {
|
||||
permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID
|
||||
}
|
||||
out = append(out, DiscoverPost{
|
||||
Text: text,
|
||||
Permalink: permalink,
|
||||
ExternalID: strings.TrimSpace(item.ID),
|
||||
ExternalID: externalID,
|
||||
Author: strings.TrimSpace(item.Username),
|
||||
PostedAt: strings.TrimSpace(item.Timestamp),
|
||||
LikeCount: item.LikeCount,
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -13,9 +13,10 @@
|
|||
4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
|
||||
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。
|
||||
6. 誠實:不承諾療效、不捏造誇大數據。
|
||||
7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。
|
||||
7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻。
|
||||
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
|
||||
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
|
||||
10. 排版要像真人用手機留言:每則 text 用 2~4 個短段落,段落之間一定要換行(JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。
|
||||
|
||||
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@
|
|||
目標貼文作者:@{{author_name}}
|
||||
目標貼文:
|
||||
{{target_text}}
|
||||
{{regenerate_line}}
|
||||
|
||||
請產生 {{count}} 則留言草稿,每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue