diff --git a/backend/Makefile b/backend/Makefile index 56c15b3..5bed275 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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: diff --git a/backend/etc/gateway.worker.yaml b/backend/etc/gateway.worker.yaml index 05dfaba..7969184 100644 --- a/backend/etc/gateway.worker.yaml +++ b/backend/etc/gateway.worker.yaml @@ -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 diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml index d504842..55e9fc3 100644 --- a/backend/etc/gateway.yaml +++ b/backend/etc/gateway.yaml @@ -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 diff --git a/backend/gateway.go b/backend/gateway.go index 46ba7a7..130f7f7 100644 --- a/backend/gateway.go +++ b/backend/gateway.go @@ -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() diff --git a/backend/generate/api/brand.api b/backend/generate/api/brand.api index b4102de..d4658e1 100644 --- a/backend/generate/api/brand.api +++ b/backend/generate/api/brand.api @@ -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) diff --git a/backend/generate/api/member.api b/backend/generate/api/member.api index 5f2f546..4df8067 100644 --- a/backend/generate/api/member.api +++ b/backend/generate/api/member.api @@ -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 { diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index ca30be7..b268ca4 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -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) diff --git a/backend/generate/api/placement_topic.api b/backend/generate/api/placement_topic.api index a6d66a1..a0b12d9 100644 --- a/backend/generate/api/placement_topic.api +++ b/backend/generate/api/placement_topic.api @@ -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) diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index b0ebba3..9aba164 100644 --- a/backend/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -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( diff --git a/backend/internal/handler/brand/update_outreach_draft_handler.go b/backend/internal/handler/brand/update_outreach_draft_handler.go new file mode 100644 index 0000000..c9c8dc3 --- /dev/null +++ b/backend/internal/handler/brand/update_outreach_draft_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/persona/generate_from_content_formula_handler.go b/backend/internal/handler/persona/generate_from_content_formula_handler.go new file mode 100644 index 0000000..11e093f --- /dev/null +++ b/backend/internal/handler/persona/generate_from_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/persona/list_persona_content_inbox_handler.go b/backend/internal/handler/persona/list_persona_content_inbox_handler.go new file mode 100644 index 0000000..42f15c7 --- /dev/null +++ b/backend/internal/handler/persona/list_persona_content_inbox_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go new file mode 100644 index 0000000..0812ea0 --- /dev/null +++ b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go new file mode 100644 index 0000000..199c76e --- /dev/null +++ b/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go new file mode 100644 index 0000000..17c739b --- /dev/null +++ b/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/register_extra.go b/backend/internal/handler/register_extra.go new file mode 100644 index 0000000..b199e1b --- /dev/null +++ b/backend/internal/handler/register_extra.go @@ -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"), + ) +} \ No newline at end of file diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 7ee7e7c..943d0a0 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -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", diff --git a/backend/internal/handler/static/extension_download.go b/backend/internal/handler/static/extension_download.go new file mode 100644 index 0000000..53c4a6a --- /dev/null +++ b/backend/internal/handler/static/extension_download.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/analyze_content_formula_handler.go b/backend/internal/handler/threads_account/analyze_content_formula_handler.go new file mode 100644 index 0000000..7d859f5 --- /dev/null +++ b/backend/internal/handler/threads_account/analyze_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/delete_content_formula_handler.go b/backend/internal/handler/threads_account/delete_content_formula_handler.go new file mode 100644 index 0000000..8e6ea0e --- /dev/null +++ b/backend/internal/handler/threads_account/delete_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/generate_own_post_formula_handler.go b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go new file mode 100644 index 0000000..2055f56 --- /dev/null +++ b/backend/internal/handler/threads_account/generate_own_post_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go new file mode 100644 index 0000000..9c3baa9 --- /dev/null +++ b/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/get_content_formula_handler.go b/backend/internal/handler/threads_account/get_content_formula_handler.go new file mode 100644 index 0000000..41156b8 --- /dev/null +++ b/backend/internal/handler/threads_account/get_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go new file mode 100644 index 0000000..e3dffbd --- /dev/null +++ b/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/list_content_formulas_handler.go b/backend/internal/handler/threads_account/list_content_formulas_handler.go new file mode 100644 index 0000000..538443b --- /dev/null +++ b/backend/internal/handler/threads_account/list_content_formulas_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/list_mention_inbox_handler.go b/backend/internal/handler/threads_account/list_mention_inbox_handler.go new file mode 100644 index 0000000..181b83e --- /dev/null +++ b/backend/internal/handler/threads_account/list_mention_inbox_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/list_own_post_formulas_handler.go b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go new file mode 100644 index 0000000..8a7aa2e --- /dev/null +++ b/backend/internal/handler/threads_account/list_own_post_formulas_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/patch_content_formula_handler.go b/backend/internal/handler/threads_account/patch_content_formula_handler.go new file mode 100644 index 0000000..c786007 --- /dev/null +++ b/backend/internal/handler/threads_account/patch_content_formula_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/publish_mention_reply_handler.go b/backend/internal/handler/threads_account/publish_mention_reply_handler.go new file mode 100644 index 0000000..1aa4feb --- /dev/null +++ b/backend/internal/handler/threads_account/publish_mention_reply_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/search_content_formula_posts_handler.go b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go new file mode 100644 index 0000000..01b16b8 --- /dev/null +++ b/backend/internal/handler/threads_account/search_content_formula_posts_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/handler/threads_account/sync_mention_inbox_handler.go b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go new file mode 100644 index 0000000..3aa327a --- /dev/null +++ b/backend/internal/handler/threads_account/sync_mention_inbox_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/embedding/client.go b/backend/internal/library/embedding/client.go index 097d8df..2754e4e 100644 --- a/backend/internal/library/embedding/client.go +++ b/backend/internal/library/embedding/client.go @@ -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 ( diff --git a/backend/internal/library/formula/analyze_pasted.go b/backend/internal/library/formula/analyze_pasted.go new file mode 100644 index 0000000..799c731 --- /dev/null +++ b/backend/internal/library/formula/analyze_pasted.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/formula/generate_from_formula.go b/backend/internal/library/formula/generate_from_formula.go new file mode 100644 index 0000000..eee3a6e --- /dev/null +++ b/backend/internal/library/formula/generate_from_formula.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/graph.go b/backend/internal/library/knowledge/graph.go index bacb474..3a220e7 100644 --- a/backend/internal/library/knowledge/graph.go +++ b/backend/internal/library/knowledge/graph.go @@ -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 { diff --git a/backend/internal/library/knowledge/graph_rescore.go b/backend/internal/library/knowledge/graph_rescore.go new file mode 100644 index 0000000..3ba5310 --- /dev/null +++ b/backend/internal/library/knowledge/graph_rescore.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/graph_selection_test.go b/backend/internal/library/knowledge/graph_selection_test.go new file mode 100644 index 0000000..9b5332a --- /dev/null +++ b/backend/internal/library/knowledge/graph_selection_test.go @@ -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") + } +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/patrol_input.go b/backend/internal/library/knowledge/patrol_input.go index 5acafaf..5da4c30 100644 --- a/backend/internal/library/knowledge/patrol_input.go +++ b/backend/internal/library/knowledge/patrol_input.go @@ -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{ + "洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤", "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華", "防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔", } { diff --git a/backend/internal/library/knowledge/patrol_phrase.go b/backend/internal/library/knowledge/patrol_phrase.go index b4f7308..05c4927 100644 --- a/backend/internal/library/knowledge/patrol_phrase.go +++ b/backend/internal/library/knowledge/patrol_phrase.go @@ -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 == "" { diff --git a/backend/internal/library/knowledge/patrol_phrase_test.go b/backend/internal/library/knowledge/patrol_phrase_test.go index 9f942d2..e7bf3a1 100644 --- a/backend/internal/library/knowledge/patrol_phrase_test.go +++ b/backend/internal/library/knowledge/patrol_phrase_test.go @@ -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 == "敏感肌保養" { diff --git a/backend/internal/library/knowledge/patrol_rank.go b/backend/internal/library/knowledge/patrol_rank.go index 9a8dc1e..7937570 100644 --- a/backend/internal/library/knowledge/patrol_rank.go +++ b/backend/internal/library/knowledge/patrol_rank.go @@ -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 } diff --git a/backend/internal/library/knowledge/patrol_rank_test.go b/backend/internal/library/knowledge/patrol_rank_test.go index 11533be..adfa421 100644 --- a/backend/internal/library/knowledge/patrol_rank_test.go +++ b/backend/internal/library/knowledge/patrol_rank_test.go @@ -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]) diff --git a/backend/internal/library/knowledge/patrol_resolve.go b/backend/internal/library/knowledge/patrol_resolve.go index 61b11a3..bdac43d 100644 --- a/backend/internal/library/knowledge/patrol_resolve.go +++ b/backend/internal/library/knowledge/patrol_resolve.go @@ -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] } diff --git a/backend/internal/library/knowledge/patrol_resolve_test.go b/backend/internal/library/knowledge/patrol_resolve_test.go index 349b458..05af32f 100644 --- a/backend/internal/library/knowledge/patrol_resolve_test.go +++ b/backend/internal/library/knowledge/patrol_resolve_test.go @@ -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) + } + } +} diff --git a/backend/internal/library/knowledge/patrol_search.go b/backend/internal/library/knowledge/patrol_search.go new file mode 100644 index 0000000..42b9727 --- /dev/null +++ b/backend/internal/library/knowledge/patrol_search.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/patrol_search_test.go b/backend/internal/library/knowledge/patrol_search_test.go new file mode 100644 index 0000000..dcd0a1c --- /dev/null +++ b/backend/internal/library/knowledge/patrol_search_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/patrol_tags.go b/backend/internal/library/knowledge/patrol_tags.go index 08c2ea2..bee71c1 100644 --- a/backend/internal/library/knowledge/patrol_tags.go +++ b/backend/internal/library/knowledge/patrol_tags.go @@ -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 { diff --git a/backend/internal/library/knowledge/research_map_fit.go b/backend/internal/library/knowledge/research_map_fit.go new file mode 100644 index 0000000..417f57e --- /dev/null +++ b/backend/internal/library/knowledge/research_map_fit.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/search_keyword_shape.go b/backend/internal/library/knowledge/search_keyword_shape.go new file mode 100644 index 0000000..3ac3638 --- /dev/null +++ b/backend/internal/library/knowledge/search_keyword_shape.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/search_keyword_shape_test.go b/backend/internal/library/knowledge/search_keyword_shape_test.go new file mode 100644 index 0000000..cd72ef0 --- /dev/null +++ b/backend/internal/library/knowledge/search_keyword_shape_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/semantic_fit.go b/backend/internal/library/knowledge/semantic_fit.go new file mode 100644 index 0000000..5a913c0 --- /dev/null +++ b/backend/internal/library/knowledge/semantic_fit.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/semantic_fit_test.go b/backend/internal/library/knowledge/semantic_fit_test.go new file mode 100644 index 0000000..357e929 --- /dev/null +++ b/backend/internal/library/knowledge/semantic_fit_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/outreach/draft_context.go b/backend/internal/library/outreach/draft_context.go new file mode 100644 index 0000000..31bf201 --- /dev/null +++ b/backend/internal/library/outreach/draft_context.go @@ -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] +} \ No newline at end of file diff --git a/backend/internal/library/outreach/draft_format.go b/backend/internal/library/outreach/draft_format.go new file mode 100644 index 0000000..4eb5fbd --- /dev/null +++ b/backend/internal/library/outreach/draft_format.go @@ -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 + } +} + diff --git a/backend/internal/library/outreach/draft_format_test.go b/backend/internal/library/outreach/draft_format_test.go new file mode 100644 index 0000000..0978679 --- /dev/null +++ b/backend/internal/library/outreach/draft_format_test.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/outreach/generate.go b/backend/internal/library/outreach/generate.go index a47277a..6f3b2ae 100644 --- a/backend/internal/library/outreach/generate.go +++ b/backend/internal/library/outreach/generate.go @@ -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) } diff --git a/backend/internal/library/outreach/generate_draft.go b/backend/internal/library/outreach/generate_draft.go new file mode 100644 index 0000000..695db5e --- /dev/null +++ b/backend/internal/library/outreach/generate_draft.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/outreach/persona_voice.go b/backend/internal/library/outreach/persona_voice.go new file mode 100644 index 0000000..206a061 --- /dev/null +++ b/backend/internal/library/outreach/persona_voice.go @@ -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")) +} \ No newline at end of file diff --git a/backend/internal/library/outreach/persona_voice_test.go b/backend/internal/library/outreach/persona_voice_test.go new file mode 100644 index 0000000..00262a8 --- /dev/null +++ b/backend/internal/library/outreach/persona_voice_test.go @@ -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") + } +} \ No newline at end of file diff --git a/backend/internal/library/outreach/previous_drafts.go b/backend/internal/library/outreach/previous_drafts.go new file mode 100644 index 0000000..dfa484d --- /dev/null +++ b/backend/internal/library/outreach/previous_drafts.go @@ -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]) + "…" +} \ No newline at end of file diff --git a/backend/internal/library/outreach/previous_drafts_test.go b/backend/internal/library/outreach/previous_drafts_test.go new file mode 100644 index 0000000..c7655de --- /dev/null +++ b/backend/internal/library/outreach/previous_drafts_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/ownpost/post_formula.go b/backend/internal/library/ownpost/post_formula.go new file mode 100644 index 0000000..6f43001 --- /dev/null +++ b/backend/internal/library/ownpost/post_formula.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/ownpost/post_formula_test.go b/backend/internal/library/ownpost/post_formula_test.go new file mode 100644 index 0000000..334a05b --- /dev/null +++ b/backend/internal/library/ownpost/post_formula_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/ownpost/reply_draft.go b/backend/internal/library/ownpost/reply_draft.go new file mode 100644 index 0000000..d40e5c2 --- /dev/null +++ b/backend/internal/library/ownpost/reply_draft.go @@ -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") +} \ No newline at end of file diff --git a/backend/internal/library/ownpost/reply_draft_test.go b/backend/internal/library/ownpost/reply_draft_test.go new file mode 100644 index 0000000..63b2ba6 --- /dev/null +++ b/backend/internal/library/ownpost/reply_draft_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/capabilities.go b/backend/internal/library/placement/capabilities.go index 24fd855..424bdf8 100644 --- a/backend/internal/library/placement/capabilities.go +++ b/backend/internal/library/placement/capabilities.go @@ -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 連線後再發布貼文" } diff --git a/backend/internal/library/placement/context.go b/backend/internal/library/placement/context.go index 86e0ce6..3b128ae 100644 --- a/backend/internal/library/placement/context.go +++ b/backend/internal/library/placement/context.go @@ -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, diff --git a/backend/internal/library/placement/context_api_only_test.go b/backend/internal/library/placement/context_api_only_test.go new file mode 100644 index 0000000..2a315b2 --- /dev/null +++ b/backend/internal/library/placement/context_api_only_test.go @@ -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") + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/discover.go b/backend/internal/library/placement/discover.go index d317506..845d2cc 100644 --- a/backend/internal/library/placement/discover.go +++ b/backend/internal/library/placement/discover.go @@ -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) +} diff --git a/backend/internal/library/placement/discover_for_query_test.go b/backend/internal/library/placement/discover_for_query_test.go new file mode 100644 index 0000000..1e168b1 --- /dev/null +++ b/backend/internal/library/placement/discover_for_query_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/dual_track.go b/backend/internal/library/placement/dual_track.go index 4f37509..bedbaae 100644 --- a/backend/internal/library/placement/dual_track.go +++ b/backend/internal/library/placement/dual_track.go @@ -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) diff --git a/backend/internal/library/placement/external_id.go b/backend/internal/library/placement/external_id.go new file mode 100644 index 0000000..3f97170 --- /dev/null +++ b/backend/internal/library/placement/external_id.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/placement/external_id_test.go b/backend/internal/library/placement/external_id_test.go new file mode 100644 index 0000000..800de03 --- /dev/null +++ b/backend/internal/library/placement/external_id_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/filter.go b/backend/internal/library/placement/filter.go index 4d68aad..4fbdc31 100644 --- a/backend/internal/library/placement/filter.go +++ b/backend/internal/library/placement/filter.go @@ -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) +} diff --git a/backend/internal/library/placement/media_resolve.go b/backend/internal/library/placement/media_resolve.go new file mode 100644 index 0000000..c261c2d --- /dev/null +++ b/backend/internal/library/placement/media_resolve.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/placement/parse_threads.go b/backend/internal/library/placement/parse_threads.go index ee1ae31..740d867 100644 --- a/backend/internal/library/placement/parse_threads.go +++ b/backend/internal/library/placement/parse_threads.go @@ -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 == "" { diff --git a/backend/internal/library/placement/patrol_queries.go b/backend/internal/library/placement/patrol_queries.go index b1c1dd7..b0b3fe8 100644 --- a/backend/internal/library/placement/patrol_queries.go +++ b/backend/internal/library/placement/patrol_queries.go @@ -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 } diff --git a/backend/internal/library/placement/patrol_queries_test.go b/backend/internal/library/placement/patrol_queries_test.go index 5551df3..cc8dc10 100644 --- a/backend/internal/library/placement/patrol_queries_test.go +++ b/backend/internal/library/placement/patrol_queries_test.go @@ -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) } diff --git a/backend/internal/library/placement/post_relevance.go b/backend/internal/library/placement/post_relevance.go new file mode 100644 index 0000000..33bd6de --- /dev/null +++ b/backend/internal/library/placement/post_relevance.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/placement/post_relevance_test.go b/backend/internal/library/placement/post_relevance_test.go new file mode 100644 index 0000000..8b48cf0 --- /dev/null +++ b/backend/internal/library/placement/post_relevance_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/product_catalog.go b/backend/internal/library/placement/product_catalog.go index 29ed36f..32ef56b 100644 --- a/backend/internal/library/placement/product_catalog.go +++ b/backend/internal/library/placement/product_catalog.go @@ -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 != "" { diff --git a/backend/internal/library/placement/product_context.go b/backend/internal/library/placement/product_context.go index b7aa429..a8c6b2f 100644 --- a/backend/internal/library/placement/product_context.go +++ b/backend/internal/library/placement/product_context.go @@ -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 != "" { diff --git a/backend/internal/library/placement/product_context_placement_url_test.go b/backend/internal/library/placement/product_context_placement_url_test.go new file mode 100644 index 0000000..a00a159 --- /dev/null +++ b/backend/internal/library/placement/product_context_placement_url_test.go @@ -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") + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/product_match.go b/backend/internal/library/placement/product_match.go index 81d4203..75afa19 100644 --- a/backend/internal/library/placement/product_match.go +++ b/backend/internal/library/placement/product_match.go @@ -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 diff --git a/backend/internal/library/placement/product_match_test.go b/backend/internal/library/placement/product_match_test.go new file mode 100644 index 0000000..7a00c00 --- /dev/null +++ b/backend/internal/library/placement/product_match_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/query_build.go b/backend/internal/library/placement/query_build.go index cd0e5bc..b776973 100644 --- a/backend/internal/library/placement/query_build.go +++ b/backend/internal/library/placement/query_build.go @@ -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 { diff --git a/backend/internal/library/placement/query_build_test.go b/backend/internal/library/placement/query_build_test.go new file mode 100644 index 0000000..6927d52 --- /dev/null +++ b/backend/internal/library/placement/query_build_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/recency.go b/backend/internal/library/placement/recency.go index 0694bfa..fe273e9 100644 --- a/backend/internal/library/placement/recency.go +++ b/backend/internal/library/placement/recency.go @@ -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() diff --git a/backend/internal/library/placement/recency_cascade.go b/backend/internal/library/placement/recency_cascade.go new file mode 100644 index 0000000..96d8a4e --- /dev/null +++ b/backend/internal/library/placement/recency_cascade.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/library/placement/recency_cascade_test.go b/backend/internal/library/placement/recency_cascade_test.go new file mode 100644 index 0000000..5fcd5ef --- /dev/null +++ b/backend/internal/library/placement/recency_cascade_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/research_map.go b/backend/internal/library/placement/research_map.go index fbc258b..b584ab8 100644 --- a/backend/internal/library/placement/research_map.go +++ b/backend/internal/library/placement/research_map.go @@ -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 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」 - 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」 diff --git a/backend/internal/library/placement/resolve_media_exec.go b/backend/internal/library/placement/resolve_media_exec.go new file mode 100644 index 0000000..efed66c --- /dev/null +++ b/backend/internal/library/placement/resolve_media_exec.go @@ -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") +} \ No newline at end of file diff --git a/backend/internal/library/placement/resolve_reply_target.go b/backend/internal/library/placement/resolve_reply_target.go new file mode 100644 index 0000000..68191de --- /dev/null +++ b/backend/internal/library/placement/resolve_reply_target.go @@ -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]) +} \ No newline at end of file diff --git a/backend/internal/library/placement/resolve_reply_target_test.go b/backend/internal/library/placement/resolve_reply_target_test.go new file mode 100644 index 0000000..df8be48 --- /dev/null +++ b/backend/internal/library/placement/resolve_reply_target_test.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/library/placement/source_mode.go b/backend/internal/library/placement/source_mode.go index 038108d..2f5f2db 100644 --- a/backend/internal/library/placement/source_mode.go +++ b/backend/internal/library/placement/source_mode.go @@ -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 { diff --git a/backend/internal/library/placement/source_mode_test.go b/backend/internal/library/placement/source_mode_test.go index 1afd1d7..3b5b0ed 100644 --- a/backend/internal/library/placement/source_mode_test.go +++ b/backend/internal/library/placement/source_mode_test.go @@ -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") } } diff --git a/backend/internal/library/placement/threads_api.go b/backend/internal/library/placement/threads_api.go index 76e21ae..aff4eb6 100644 --- a/backend/internal/library/placement/threads_api.go +++ b/backend/internal/library/placement/threads_api.go @@ -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, diff --git a/backend/internal/library/placement/topic_fit.go b/backend/internal/library/placement/topic_fit.go new file mode 100644 index 0000000..48dd6eb --- /dev/null +++ b/backend/internal/library/placement/topic_fit.go @@ -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))) +} \ No newline at end of file diff --git a/backend/internal/library/prompt/files/outreach_placement.system.md b/backend/internal/library/prompt/files/outreach_placement.system.md index 3a39508..4939eef 100644 --- a/backend/internal/library/prompt/files/outreach_placement.system.md +++ b/backend/internal/library/prompt/files/outreach_placement.system.md @@ -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 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。 diff --git a/backend/internal/library/prompt/files/outreach_placement.user.md b/backend/internal/library/prompt/files/outreach_placement.user.md index a1dc397..31ccc0c 100644 --- a/backend/internal/library/prompt/files/outreach_placement.user.md +++ b/backend/internal/library/prompt/files/outreach_placement.user.md @@ -8,5 +8,6 @@ 目標貼文作者:@{{author_name}} 目標貼文: {{target_text}} +{{regenerate_line}} 請產生 {{count}} 則留言草稿,每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。 \ No newline at end of file diff --git a/backend/internal/library/threadsapi/extended.go b/backend/internal/library/threadsapi/extended.go index 9ae4382..4bdd2a5 100644 --- a/backend/internal/library/threadsapi/extended.go +++ b/backend/internal/library/threadsapi/extended.go @@ -11,30 +11,49 @@ import ( ) const ( - // userThreadsListFields lists only fields supported on GET /{user-id}/threads. - userThreadsListFields = "id,text,permalink,timestamp" + // userThreadsListFields lists fields supported on GET /{user-id}/threads. + userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode" ) type MediaItem struct { - ID string `json:"id"` - Text string `json:"text"` - Permalink string `json:"permalink"` - Timestamp string `json:"timestamp"` - LikeCount int `json:"like_count"` - ReplyCount int `json:"reply_count"` + ID string `json:"id"` + Shortcode string `json:"shortcode"` + Text string `json:"text"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + TopicTag string `json:"topic_tag"` + LikeCount int `json:"like_count"` + ReplyCount int `json:"reply_count"` } type ProfileLookupResult struct { - Username string `json:"username"` - Name string `json:"name"` + Username string `json:"username"` + Name string `json:"name"` + ProfilePictureURL string `json:"profile_picture_url,omitempty"` + Biography string `json:"biography,omitempty"` + FollowerCount int `json:"follower_count,omitempty"` + LikesCount int `json:"likes_count,omitempty"` + QuotesCount int `json:"quotes_count,omitempty"` + RepostsCount int `json:"reposts_count,omitempty"` + ViewsCount int `json:"views_count,omitempty"` + IsVerified bool `json:"is_verified,omitempty"` } type MentionItem struct { - ID string `json:"id"` - Text string `json:"text"` - Username string `json:"username"` - Permalink string `json:"permalink"` - Timestamp string `json:"timestamp"` + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + 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"` } type LocationItem struct { @@ -98,6 +117,90 @@ func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error { return nil } +// ProfilePosts lists recent public posts for a username (threads_profile_discovery). +func (c *Client) ProfilePosts(ctx context.Context, username string, limit int, after string) ([]MediaItem, string, error) { + if !c.Enabled() { + return nil, "", fmt.Errorf("threads api access token is required") + } + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username == "" { + return nil, "", fmt.Errorf("username is required") + } + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("username", username) + params.Set("fields", "id,shortcode,permalink,text,timestamp") + params.Set("limit", fmt.Sprintf("%d", limit)) + if after = strings.TrimSpace(after); after != "" { + params.Set("after", after) + } + endpoint := graphBaseURL + "/profile_posts?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, "", err + } + body, status, err := doRequest(req) + if err != nil { + return nil, "", err + } + if status != http.StatusOK { + return nil, "", parseAPIError(body, status) + } + var payload struct { + Data []MediaItem `json:"data"` + Paging struct { + Cursors struct { + After string `json:"after"` + } `json:"cursors"` + } `json:"paging"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, "", fmt.Errorf("parse profile_posts: %w", err) + } + return payload.Data, strings.TrimSpace(payload.Paging.Cursors.After), nil +} + +// FindMediaIDByAuthorShortcode scans profile_posts pages until shortcode is found. +func (c *Client) FindMediaIDByAuthorShortcode(ctx context.Context, username, shortcode string) (string, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + shortcode = strings.TrimSpace(shortcode) + if username == "" || shortcode == "" { + return "", fmt.Errorf("username and shortcode are required") + } + wantSuffix := "/post/" + shortcode + after := "" + for page := 0; page < 3; page++ { + items, next, err := c.ProfilePosts(ctx, username, 50, after) + if err != nil { + return "", err + } + for _, item := range items { + id := strings.TrimSpace(item.ID) + if !IsNumericMediaID(id) { + continue + } + if strings.EqualFold(strings.TrimSpace(item.Shortcode), shortcode) { + return id, nil + } + link := strings.TrimSpace(item.Permalink) + if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) { + return id, nil + } + } + if next == "" || len(items) == 0 { + break + } + after = next + } + return "", fmt.Errorf("profile_posts did not include @%s post %s", username, shortcode) +} + // ProfileLookup fetches a public profile by username (threads_profile_discovery). func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLookupResult, error) { if !c.Enabled() { @@ -110,7 +213,7 @@ func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLo params := url.Values{} params.Set("access_token", c.accessToken) params.Set("username", username) - params.Set("fields", "username,name") + params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified") endpoint := graphBaseURL + "/profile_lookup?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -130,14 +233,50 @@ func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLo return &out, nil } -// UserMentions lists posts mentioning the user (threads_manage_mentions). +type mentionItemRaw struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` + IsQuotePost bool `json:"is_quote_post"` + HasReplies bool `json:"has_replies"` + RootPost json.RawMessage `json:"root_post"` + ParentID string `json:"parent_id"` +} + +func normalizeMentionItem(raw mentionItemRaw) MentionItem { + return MentionItem{ + ID: strings.TrimSpace(raw.ID), + Text: strings.TrimSpace(raw.Text), + Username: strings.TrimSpace(raw.Username), + Permalink: strings.TrimSpace(raw.Permalink), + Timestamp: strings.TrimSpace(raw.Timestamp), + MediaType: strings.TrimSpace(raw.MediaType), + IsReply: raw.IsReply, + IsQuotePost: raw.IsQuotePost, + HasReplies: raw.HasReplies, + RootPostID: parseMediaRefID(raw.RootPost), + ParentID: strings.TrimSpace(raw.ParentID), + } +} + +// UserMentions lists the first page of posts mentioning the user (threads_manage_mentions). func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) { + items, _, err := c.UserMentionsPage(ctx, userID, limit, "") + return items, err +} + +// UserMentionsPage lists one cursor page of mentions. nextAfter is empty when there is no next page. +func (c *Client) UserMentionsPage(ctx context.Context, userID string, limit int, after string) ([]MentionItem, string, error) { if !c.Enabled() { - return nil, fmt.Errorf("threads api access token is required") + return nil, "", fmt.Errorf("threads api access token is required") } userID = strings.TrimSpace(userID) if userID == "" { - return nil, fmt.Errorf("threads user id is required") + return nil, "", fmt.Errorf("threads user id is required") } if limit <= 0 { limit = 25 @@ -147,27 +286,39 @@ func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([] } params := url.Values{} params.Set("access_token", c.accessToken) - params.Set("fields", "id,text,username,permalink,timestamp") + params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id") params.Set("limit", fmt.Sprintf("%d", limit)) + if after = strings.TrimSpace(after); after != "" { + params.Set("after", after) + } endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, err + return nil, "", err } body, status, err := doRequest(req) if err != nil { - return nil, err + return nil, "", err } if status != http.StatusOK { - return nil, parseAPIError(body, status) + return nil, "", parseAPIError(body, status) } var payload struct { - Data []MentionItem `json:"data"` + Data []mentionItemRaw `json:"data"` + Paging struct { + Cursors struct { + After string `json:"after"` + } `json:"cursors"` + } `json:"paging"` } if err := json.Unmarshal(body, &payload); err != nil { - return nil, fmt.Errorf("parse mentions: %w", err) + return nil, "", fmt.Errorf("parse mentions: %w", err) } - return payload.Data, nil + out := make([]MentionItem, 0, len(payload.Data)) + for _, raw := range payload.Data { + out = append(out, normalizeMentionItem(raw)) + } + return out, strings.TrimSpace(payload.Paging.Cursors.After), nil } // LocationSearch searches public locations (threads_location_tagging). diff --git a/backend/internal/library/threadsapi/fields_test.go b/backend/internal/library/threadsapi/fields_test.go index df182d4..ae3314f 100644 --- a/backend/internal/library/threadsapi/fields_test.go +++ b/backend/internal/library/threadsapi/fields_test.go @@ -11,9 +11,9 @@ func TestUserThreadsListFieldsExcludeEngagement(t *testing.T) { } } -func TestMediaReplyListFieldsExcludeEngagement(t *testing.T) { - if fieldInList(mediaReplyListFields, "like_count") { - t.Fatalf("mediaReplyListFields must not request like_count: %q", mediaReplyListFields) +func TestMediaConversationFieldsExcludeEngagement(t *testing.T) { + if fieldInList(mediaConversationFields, "like_count") { + t.Fatalf("mediaConversationFields must not request like_count: %q", mediaConversationFields) } } diff --git a/backend/internal/library/threadsapi/media.go b/backend/internal/library/threadsapi/media.go index f9baa46..b0e203d 100644 --- a/backend/internal/library/threadsapi/media.go +++ b/backend/internal/library/threadsapi/media.go @@ -10,6 +10,50 @@ import ( "time" ) +type MediaDetail struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + MediaType string `json:"media_type"` + IsReply bool `json:"is_reply"` +} + +func (c *Client) GetMediaDetail(ctx context.Context, mediaID string) (*MediaDetail, error) { + if !c.Enabled() { + return nil, fmt.Errorf("threads api access token is required") + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, fmt.Errorf("threads media id is required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply") + endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + body, status, err := doRequest(req) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, parseAPIError(body, status) + } + var out MediaDetail + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("parse media detail: %w", err) + } + out.ID = strings.TrimSpace(out.ID) + if out.ID == "" { + return nil, fmt.Errorf("threads media detail missing id") + } + return &out, nil +} + type MediaStats struct { LikeCount int `json:"like_count"` ReplyCount int `json:"reply_count"` diff --git a/backend/internal/library/threadsapi/publish.go b/backend/internal/library/threadsapi/publish.go index 421621a..be7a2d6 100644 --- a/backend/internal/library/threadsapi/publish.go +++ b/backend/internal/library/threadsapi/publish.go @@ -184,12 +184,12 @@ func publishContainer(ctx context.Context, userID, token, containerID string) (s } func waitForContainerReady(ctx context.Context, containerID, token string) error { - for attempt := 0; attempt < 20; attempt++ { + for attempt := 0; attempt < 12; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(3 * time.Second): + case <-time.After(2 * time.Second): } } params := url.Values{} diff --git a/backend/internal/library/threadsapi/replies.go b/backend/internal/library/threadsapi/replies.go index 882841d..7f957f9 100644 --- a/backend/internal/library/threadsapi/replies.go +++ b/backend/internal/library/threadsapi/replies.go @@ -10,19 +10,61 @@ import ( "strings" ) -const mediaReplyListFields = "id,text,username,permalink,timestamp,parent_id" +const mediaConversationFields = "id,text,username,permalink,timestamp,is_reply,is_reply_owned_by_me,has_replies,replied_to,root_post,parent_id,media_type,media_url,thumbnail_url,gif_url,children{id,media_type,media_url,thumbnail_url}" + +type ReplyMediaChild struct { + ID string `json:"id"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` +} type ReplyItem struct { - ID string `json:"id"` - Text string `json:"text"` - Username string `json:"username"` - Permalink string `json:"permalink"` - Timestamp string `json:"timestamp"` - LikeCount int `json:"like_count"` - ParentID string `json:"parent_id"` + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + LikeCount int `json:"like_count"` + ParentID string `json:"parent_id"` + RepliedToID string `json:"replied_to_id"` + RootPostID string `json:"root_post_id"` + IsReply bool `json:"is_reply"` + IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` + HasReplies bool `json:"has_replies"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + GifURL string `json:"gif_url"` + MediaChildren []ReplyMediaChild `json:"media_children,omitempty"` +} + +type replyItemRaw struct { + ID string `json:"id"` + Text string `json:"text"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Timestamp string `json:"timestamp"` + LikeCount int `json:"like_count"` + ParentID string `json:"parent_id"` + RepliedTo json.RawMessage `json:"replied_to"` + RootPost json.RawMessage `json:"root_post"` + IsReply bool `json:"is_reply"` + IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` + HasReplies bool `json:"has_replies"` + MediaType string `json:"media_type"` + MediaURL string `json:"media_url"` + ThumbnailURL string `json:"thumbnail_url"` + GifURL string `json:"gif_url"` + Children json.RawMessage `json:"children"` } func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) { + return c.MediaConversation(ctx, mediaID, limit) +} + +// MediaConversation returns flattened top-level and nested replies for a thread root. +func (c *Client) MediaConversation(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) { if !c.Enabled() { return nil, fmt.Errorf("threads api access token is required") } @@ -33,39 +75,146 @@ func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([ if limit <= 0 { limit = 10 } - if limit > 50 { - limit = 50 + if limit > 100 { + limit = 100 } - params := url.Values{} - params.Set("access_token", c.accessToken) - params.Set("fields", mediaReplyListFields) - params.Set("limit", fmt.Sprintf("%d", limit)) + out := make([]ReplyItem, 0, limit) + nextURL := "" + for len(out) < limit { + pageLimit := limit - len(out) + if pageLimit > 50 { + pageLimit = 50 + } + endpoint := nextURL + if endpoint == "" { + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("fields", mediaConversationFields) + params.Set("limit", fmt.Sprintf("%d", pageLimit)) + params.Set("reverse", "false") + endpoint = graphBaseURL + "/" + url.PathEscape(mediaID) + "/conversation?" + params.Encode() + } - endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/replies?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - res, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + res, err := c.http.Do(req) + if err != nil { + return nil, err + } - body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) - if err != nil { - return nil, err - } - if res.StatusCode != http.StatusOK { - return nil, parseAPIError(body, res.StatusCode) - } + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + res.Body.Close() + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, parseAPIError(body, res.StatusCode) + } - var payload struct { - Data []ReplyItem `json:"data"` + var payload struct { + Data []replyItemRaw `json:"data"` + Paging struct { + Next string `json:"next"` + } `json:"paging"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse threads conversation: %w", err) + } + for _, raw := range payload.Data { + out = append(out, normalizeReplyItem(raw)) + if len(out) >= limit { + break + } + } + nextURL = strings.TrimSpace(payload.Paging.Next) + if nextURL == "" || len(payload.Data) == 0 { + break + } } - if err := json.Unmarshal(body, &payload); err != nil { - return nil, fmt.Errorf("parse threads replies: %w", err) - } - return payload.Data, nil + return out, nil } + +func normalizeReplyItem(raw replyItemRaw) ReplyItem { + repliedToID := parseMediaRefID(raw.RepliedTo) + if repliedToID == "" { + repliedToID = strings.TrimSpace(raw.ParentID) + } + return ReplyItem{ + ID: strings.TrimSpace(raw.ID), + Text: strings.TrimSpace(raw.Text), + Username: strings.TrimSpace(raw.Username), + Permalink: strings.TrimSpace(raw.Permalink), + Timestamp: strings.TrimSpace(raw.Timestamp), + LikeCount: raw.LikeCount, + ParentID: strings.TrimSpace(raw.ParentID), + RepliedToID: repliedToID, + RootPostID: parseMediaRefID(raw.RootPost), + IsReply: raw.IsReply, + IsReplyOwnedByMe: raw.IsReplyOwnedByMe, + HasReplies: raw.HasReplies, + MediaType: strings.TrimSpace(raw.MediaType), + MediaURL: strings.TrimSpace(raw.MediaURL), + ThumbnailURL: strings.TrimSpace(raw.ThumbnailURL), + GifURL: strings.TrimSpace(raw.GifURL), + MediaChildren: parseMediaChildren(raw.Children), + } +} + +func parseMediaChildren(raw json.RawMessage) []ReplyMediaChild { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var asList []ReplyMediaChild + if err := json.Unmarshal(raw, &asList); err == nil { + return normalizeMediaChildren(asList) + } + + var wrapped struct { + Data []ReplyMediaChild `json:"data"` + } + if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Data) > 0 { + return normalizeMediaChildren(wrapped.Data) + } + return nil +} + +func normalizeMediaChildren(items []ReplyMediaChild) []ReplyMediaChild { + out := make([]ReplyMediaChild, 0, len(items)) + for _, item := range items { + child := ReplyMediaChild{ + ID: strings.TrimSpace(item.ID), + MediaType: strings.TrimSpace(item.MediaType), + MediaURL: strings.TrimSpace(item.MediaURL), + ThumbnailURL: strings.TrimSpace(item.ThumbnailURL), + } + if child.ID == "" && child.MediaURL == "" && child.ThumbnailURL == "" { + continue + } + out = append(out, child) + } + if len(out) == 0 { + return nil + } + return out +} + +func parseMediaRefID(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return strings.TrimSpace(asString) + } + var asObject struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &asObject); err == nil { + return strings.TrimSpace(asObject.ID) + } + return "" +} \ No newline at end of file diff --git a/backend/internal/library/threadsapi/replies_test.go b/backend/internal/library/threadsapi/replies_test.go new file mode 100644 index 0000000..041a714 --- /dev/null +++ b/backend/internal/library/threadsapi/replies_test.go @@ -0,0 +1,69 @@ +package threadsapi + +import ( + "encoding/json" + "testing" +) + +func TestParseMediaRefIDObjectAndString(t *testing.T) { + obj := json.RawMessage(`{"id":"123"}`) + if got := parseMediaRefID(obj); got != "123" { + t.Fatalf("object ref: got %q", got) + } + str := json.RawMessage(`"456"`) + if got := parseMediaRefID(str); got != "456" { + t.Fatalf("string ref: got %q", got) + } +} + +func TestNormalizeReplyItemPrefersRepliedTo(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "child", + ParentID: "legacy-parent", + RepliedTo: json.RawMessage(`{"id":"real-parent"}`), + Text: "hello", + }) + if item.RepliedToID != "real-parent" { + t.Fatalf("expected replied_to id, got %q", item.RepliedToID) + } +} + +func TestNormalizeReplyItemKeepsMediaURLs(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "img-reply", + MediaType: "IMAGE", + MediaURL: "https://example.com/photo.jpg", + ThumbnailURL: "https://example.com/thumb.jpg", + GifURL: "https://example.com/cat.gif", + }) + if item.MediaURL != "https://example.com/photo.jpg" { + t.Fatalf("media_url: got %q", item.MediaURL) + } + if item.GifURL != "https://example.com/cat.gif" { + t.Fatalf("gif_url: got %q", item.GifURL) + } +} + +func TestNormalizeReplyItemParsesCarouselChildren(t *testing.T) { + item := normalizeReplyItem(replyItemRaw{ + ID: "carousel-reply", + MediaType: "CAROUSEL_ALBUM", + Children: json.RawMessage(`{"data":[{"id":"c1","media_type":"IMAGE","media_url":"https://example.com/1.jpg"},{"id":"c2","media_type":"VIDEO","media_url":"https://example.com/2.mp4","thumbnail_url":"https://example.com/2.jpg"}]}`), + }) + if len(item.MediaChildren) != 2 { + t.Fatalf("expected 2 media children, got %d", len(item.MediaChildren)) + } + if item.MediaChildren[0].MediaURL != "https://example.com/1.jpg" { + t.Fatalf("first child media_url: got %q", item.MediaChildren[0].MediaURL) + } + if item.MediaChildren[1].ThumbnailURL != "https://example.com/2.jpg" { + t.Fatalf("second child thumbnail_url: got %q", item.MediaChildren[1].ThumbnailURL) + } +} + +func TestParseMediaChildrenAcceptsArray(t *testing.T) { + children := parseMediaChildren(json.RawMessage(`[{"id":"x","media_type":"IMAGE","media_url":"https://example.com/x.jpg"}]`)) + if len(children) != 1 || children[0].ID != "x" { + t.Fatalf("unexpected children: %#v", children) + } +} \ No newline at end of file diff --git a/backend/internal/library/threadsapi/resolve_media.go b/backend/internal/library/threadsapi/resolve_media.go new file mode 100644 index 0000000..71edb03 --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media.go @@ -0,0 +1,298 @@ +package threadsapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +var threadsPermalinkRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`) + +// IsNumericMediaID reports whether the value looks like a Threads Graph media id. +func IsNumericMediaID(raw string) bool { + raw = strings.TrimSpace(raw) + if len(raw) < 8 { + return false + } + for _, r := range raw { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func parsePermalinkParts(permalink string) (username, shortcode string) { + permalink = strings.TrimSpace(permalink) + if permalink == "" { + return "", "" + } + permalink = strings.Split(permalink, "?")[0] + permalink = strings.Split(permalink, "#")[0] + match := threadsPermalinkRe.FindStringSubmatch(permalink) + if len(match) < 3 { + return "", "" + } + return strings.TrimSpace(match[1]), strings.TrimSpace(match[2]) +} + +type ResolveReplyMediaInput struct { + ExternalID string + Permalink string + HintText string +} + +// ResolveReplyMediaID returns a Graph API media id suitable for reply_to_id. +func (c *Client) ResolveReplyMediaID(ctx context.Context, externalID, permalink string) (string, error) { + return c.ResolveReplyMedia(ctx, ResolveReplyMediaInput{ + ExternalID: externalID, + Permalink: permalink, + }) +} + +// ResolveReplyMedia resolves shortcode/permalink targets into numeric media ids for reply_to_id. +func (c *Client) ResolveReplyMedia(ctx context.Context, in ResolveReplyMediaInput) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("threads api access token is required") + } + candidate := strings.TrimSpace(in.ExternalID) + if IsNumericMediaID(candidate) { + return candidate, nil + } + username, shortcode := parsePermalinkParts(in.Permalink) + if !IsNumericMediaID(candidate) && candidate != "" { + shortcode = candidate + } + if IsNumericMediaID(shortcode) { + return shortcode, nil + } + if shortcode == "" { + return "", fmt.Errorf("缺少有效的 Threads 貼文 ID") + } + if username == "" { + return "", fmt.Errorf("permalink 缺少作者資訊,無法解析貼文 ID") + } + + for _, query := range keywordQueriesFromHint(in.HintText) { + if id, err := c.lookupMediaIDByKeywordText(ctx, username, shortcode, query); err == nil && id != "" { + return id, nil + } + } + if id, err := c.lookupMediaIDViaProfilePosts(ctx, username, shortcode); err == nil && id != "" { + return id, nil + } + if id, err := c.lookupMediaIDByAuthorAndShortcode(ctx, username, shortcode); err == nil && id != "" { + return id, nil + } + return "", fmt.Errorf( + "無法將貼文 shortcode 轉成 Threads media id(%s/@%s/post/%s)。請確認 OAuth 具備 threads_profile_discovery 與 threads_keyword_search,或重新以 API 海巡抓取貼文", + shortcode, username, shortcode, + ) +} + +func keywordQueriesFromHint(hint string) []string { + hint = strings.TrimSpace(hint) + if hint == "" { + return nil + } + hint = strings.Join(strings.Fields(hint), " ") + seen := map[string]struct{}{} + out := make([]string, 0, 3) + add := func(query string) { + query = strings.TrimSpace(query) + if query == "" || utf8.RuneCountInString(query) < 4 { + return + } + if _, ok := seen[query]; ok { + return + } + seen[query] = struct{}{} + out = append(out, query) + } + add(trimRunes(hint, 80)) + if idx := strings.IndexAny(hint, "。!?!?.\n"); idx > 0 { + add(trimRunes(hint[:idx], 60)) + } + add(trimRunes(hint, 32)) + return out +} + +func trimRunes(raw string, max int) string { + raw = strings.TrimSpace(raw) + if max <= 0 || utf8.RuneCountInString(raw) <= max { + return raw + } + runes := []rune(raw) + return strings.TrimSpace(string(runes[:max])) +} + +func (c *Client) lookupMediaIDViaProfilePosts(ctx context.Context, username, shortcode string) (string, error) { + return c.FindMediaIDByAuthorShortcode(ctx, username, shortcode) +} + +func (c *Client) lookupMediaIDByKeywordText(ctx context.Context, username, shortcode, query string) (string, error) { + query = strings.TrimSpace(query) + if query == "" { + return "", fmt.Errorf("keyword query is required") + } + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && id != "" { + return id, nil + } + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) +} + +func (c *Client) lookupMediaIDByAuthorAndShortcode(ctx context.Context, username, shortcode string) (string, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + shortcode = strings.TrimSpace(shortcode) + if username == "" || shortcode == "" { + return "", fmt.Errorf("username and shortcode are required") + } + type lookupStrategy struct { + searchType string + withAuthor bool + } + strategies := []lookupStrategy{ + {searchType: "TOP", withAuthor: true}, + {searchType: "RECENT", withAuthor: true}, + {searchType: "TOP", withAuthor: false}, + {searchType: "RECENT", withAuthor: false}, + } + var lastErr error + for _, strategy := range strategies { + id, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, strategy.searchType, strategy.withAuthor, shortcode) + if err == nil && id != "" { + return id, nil + } + if err != nil { + lastErr = err + } + } + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) +} + +func (c *Client) keywordSearchMediaByShortcode( + ctx context.Context, + username, query, searchType string, + withAuthor bool, + wantShortcode string, +) (string, error) { + query = strings.TrimSpace(query) + wantShortcode = strings.TrimSpace(wantShortcode) + if query == "" || wantShortcode == "" { + return "", fmt.Errorf("query and shortcode are required") + } + params := url.Values{} + params.Set("access_token", c.accessToken) + params.Set("q", query) + params.Set("search_type", searchType) + if withAuthor { + params.Set("author_username", username) + } + params.Set("fields", "id,permalink,shortcode") + params.Set("limit", "25") + endpoint := graphBaseURL + "/keyword_search?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + res, err := c.http.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return "", err + } + if res.StatusCode != http.StatusOK { + return "", parseAPIError(body, res.StatusCode) + } + var payload struct { + Data []struct { + ID string `json:"id"` + Permalink string `json:"permalink"` + Shortcode string `json:"shortcode"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + wantSuffix := "/post/" + wantShortcode + for _, item := range payload.Data { + id := strings.TrimSpace(item.ID) + if !IsNumericMediaID(id) { + continue + } + if withAuthor { + link := strings.TrimSpace(item.Permalink) + if link != "" && !strings.Contains(strings.ToLower(link), "/@"+strings.ToLower(username)+"/") { + continue + } + } + if strings.EqualFold(strings.TrimSpace(item.Shortcode), wantShortcode) { + return id, nil + } + link := strings.TrimSpace(item.Permalink) + if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) { + return id, nil + } + } + return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, wantShortcode) +} + +// PrimeReplyTargetForPublish registers a public post via Threads API search before reply_to_id publish. +// Meta only allows replying to public posts that were recently returned by keyword_search. +func (c *Client) PrimeReplyTargetForPublish(ctx context.Context, in ResolveReplyMediaInput, mediaID string) error { + if !c.Enabled() { + return fmt.Errorf("threads api access token is required") + } + username, shortcode := parsePermalinkParts(in.Permalink) + if candidate := strings.TrimSpace(in.ExternalID); !IsNumericMediaID(candidate) && candidate != "" { + shortcode = candidate + } + mediaID = strings.TrimSpace(mediaID) + if username == "" || shortcode == "" || mediaID == "" { + return fmt.Errorf("缺少作者、shortcode 或 media id,無法預先搜尋貼文") + } + + // Fast path: shortcode / author queries with author_username filter (fewer API round-trips). + for _, query := range []string{shortcode, username, "@" + username} { + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && strings.TrimSpace(id) == mediaID { + return nil + } + } + } + for _, query := range keywordQueriesFromHint(in.HintText) { + for _, searchType := range []string{"TOP", "RECENT"} { + id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) + if err == nil && strings.TrimSpace(id) == mediaID { + return nil + } + } + } + if id, err := c.FindMediaIDByAuthorShortcode(ctx, username, shortcode); err == nil && strings.TrimSpace(id) == mediaID { + for _, searchType := range []string{"TOP", "RECENT"} { + if _, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, searchType, true, shortcode); err == nil { + return nil + } + } + } + return fmt.Errorf( + "Threads API 搜尋不到 @%s 的這篇貼文(%s)。回覆他人貼文前必須先用 keyword_search 找到它,請確認 threads_keyword_search 權限已核准,或改用「開始海巡(API)」重新抓取", + username, shortcode, + ) +} \ No newline at end of file diff --git a/backend/internal/library/threadsapi/resolve_media_hint_test.go b/backend/internal/library/threadsapi/resolve_media_hint_test.go new file mode 100644 index 0000000..21142cd --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media_hint_test.go @@ -0,0 +1,13 @@ +package threadsapi + +import "testing" + +func TestKeywordQueriesFromHint(t *testing.T) { + queries := keywordQueriesFromHint("最近換季皮膚好乾,大家有推薦的保濕產品嗎?") + if len(queries) == 0 { + t.Fatal("expected keyword queries") + } + if queries[0] == "" { + t.Fatal("first query empty") + } +} \ No newline at end of file diff --git a/backend/internal/library/threadsapi/resolve_media_test.go b/backend/internal/library/threadsapi/resolve_media_test.go new file mode 100644 index 0000000..2bc9d84 --- /dev/null +++ b/backend/internal/library/threadsapi/resolve_media_test.go @@ -0,0 +1,19 @@ +package threadsapi + +import "testing" + +func TestIsNumericMediaID(t *testing.T) { + if !IsNumericMediaID("18123456789012345") { + t.Fatal("expected numeric media id") + } + if IsNumericMediaID("AbCdEf123") { + t.Fatal("shortcode should not be numeric media id") + } +} + +func TestParsePermalinkParts(t *testing.T) { + user, code := parsePermalinkParts("https://www.threads.net/@alice/post/AbCdEf123") + if user != "alice" || code != "AbCdEf123" { + t.Fatalf("got %q %q", user, code) + } +} \ No newline at end of file diff --git a/backend/internal/logic/brand/create_brand_product_logic.go b/backend/internal/logic/brand/create_brand_product_logic.go index 85e631e..76c5184 100644 --- a/backend/internal/logic/brand/create_brand_product_logic.go +++ b/backend/internal/logic/brand/create_brand_product_logic.go @@ -38,6 +38,7 @@ func (l *CreateBrandProductLogic) CreateBrandProduct(req *types.CreateBrandProdu BrandID: req.ID, Label: req.Label, ProductContext: req.ProductContext, + PlacementURL: req.PlacementURL, MatchTags: req.MatchTags, }) if err != nil { diff --git a/backend/internal/logic/brand/generate_outreach_drafts_logic.go b/backend/internal/logic/brand/generate_outreach_drafts_logic.go index 40dd871..d0bcbf5 100644 --- a/backend/internal/logic/brand/generate_outreach_drafts_logic.go +++ b/backend/internal/logic/brand/generate_outreach_drafts_logic.go @@ -5,21 +5,12 @@ package brand import ( "context" - "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" liboutreach "haixun-backend/internal/library/outreach" - libplacement "haixun-backend/internal/library/placement" - 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" outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" - scanpostentity "haixun-backend/internal/model/scan_post/domain/entity" - scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -46,225 +37,40 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate return nil, err } scanPostID := strings.TrimSpace(req.ScanPostID) - count := 2 - if req.Count > 0 { - count = req.Count - } if scanPostID == "" { return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required") } - if count > 4 { - count = 4 + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") } - brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID) - if err != nil { - return nil, err - } - voicePersona := "" - voiceID := strings.TrimSpace(req.VoicePersonaID) - if voiceID != "" { - vp, vpErr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, voiceID) - if vpErr != nil { - return nil, vpErr - } - voicePersona = vp.Persona - } - productID := strings.TrimSpace(req.ProductID) - if productID == "" { - productID = strings.TrimSpace(brand.ProductID) - } - post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID) - if err != nil { - return nil, err - } - - topicLabel := strings.TrimSpace(post.SearchTag) - placementReason := buildPlacementReason(post) - if graph, graphErr := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); 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) - } - } - - userPrompt, err := liboutreach.BuildUserPrompt(liboutreach.GenerateInput{ - Persona: voicePersona, - TopicLabel: topicLabel, - AudienceBrief: brand.TargetAudience, - ProductBrief: productBriefForBrand(brand, productID, post.SearchTag), - PlacementReason: placementReason, - TargetText: post.Text, - AuthorName: post.Author, - Count: count, - }) - 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") - } - if strings.TrimSpace(voicePersona) != "" { - systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n人設與語氣:\n" + strings.TrimSpace(voicePersona) - } - - credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return nil, err - } - - result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, + saved, err := liboutreach.GenerateDraft(l.ctx, outreachDepsFrom(l.svcCtx), liboutreach.GenerateDraftRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + TopicID: strings.TrimSpace(req.TopicID), + ScanPostID: scanPostID, + Count: req.Count, + VoicePersonaID: req.VoicePersonaID, + ProductID: req.ProductID, + Regenerate: req.Regenerate, }) if err != nil { return nil, err } - - parsed, err := liboutreach.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 := l.svcCtx.OutreachDraft.Create(l.ctx, outreachusecase.CreateRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - TopicID: strings.TrimSpace(req.TopicID), - ScanPostID: scanPostID, - Relevance: parsed.Relevance, - Reason: parsed.Reason, - Drafts: draftItems, - }) - if err != nil { - return nil, err - } - _, _ = l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - PostID: scanPostID, - Status: scanpostentity.OutreachStatusDrafted, - }) - return toOutreachDraftData(saved), nil } -func findGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return nil +func outreachDepsFrom(svcCtx *svc.ServiceContext) liboutreach.GenerateDraftDeps { + return liboutreach.GenerateDraftDeps{ + Brand: svcCtx.Brand, + Persona: svcCtx.Persona, + ScanPost: svcCtx.ScanPost, + KnowledgeGraph: svcCtx.KnowledgeGraph, + ThreadsAccount: svcCtx.ThreadsAccount, + AI: svcCtx.AI, + OutreachDraft: svcCtx.OutreachDraft, } - 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 string) string { - if brand == nil { - return "" - } - if product := pickProductForOutreach(brand, productID, searchTag); product != nil { - merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label) - 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, productID, searchTag string) *branddomain.ProductSummary { - if brand == nil || len(brand.Products) == 0 { - return nil - } - if id := strings.TrimSpace(productID); id != "" { - for i := range brand.Products { - if brand.Products[i].ID == id { - return &brand.Products[i] - } - } - } - tag := strings.TrimSpace(searchTag) - if tag == "" { - return &brand.Products[0] - } - bestScore := -1 - var best *branddomain.ProductSummary - for i := range brand.Products { - score := libplacement.ScoreProductForTag(tag, brand.Products[i].MatchTags) - if score > bestScore { - bestScore = score - best = &brand.Products[i] - } - } - if best != nil && bestScore > 0 { - return best - } - return &brand.Products[0] } func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData { @@ -287,4 +93,4 @@ func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOut Drafts: drafts, CreateAt: saved.CreateAt, } -} +} \ No newline at end of file diff --git a/backend/internal/logic/brand/mapper.go b/backend/internal/logic/brand/mapper.go index 5e6bf1e..bd18f9c 100644 --- a/backend/internal/logic/brand/mapper.go +++ b/backend/internal/logic/brand/mapper.go @@ -34,6 +34,7 @@ func toBrandProductData(item domusecase.ProductSummary) types.BrandProductData { ID: item.ID, Label: item.Label, ProductContext: item.ProductContext, + PlacementURL: item.PlacementURL, MatchTags: append([]string(nil), item.MatchTags...), CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, diff --git a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go index 34cb05d..cc9e067 100644 --- a/backend/internal/logic/brand/patch_scan_post_outreach_logic.go +++ b/backend/internal/logic/brand/patch_scan_post_outreach_logic.go @@ -73,6 +73,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { GraphNodeID: post.GraphNodeID, SearchTag: post.SearchTag, QueryDimension: post.QueryDimension, + RecencyDays: post.RecencyDays, ExternalID: post.ExternalID, Permalink: post.Permalink, Author: post.Author, diff --git a/backend/internal/logic/brand/publish_outreach_draft_logic.go b/backend/internal/logic/brand/publish_outreach_draft_logic.go index f28428f..2a2d965 100644 --- a/backend/internal/logic/brand/publish_outreach_draft_logic.go +++ b/backend/internal/logic/brand/publish_outreach_draft_logic.go @@ -9,7 +9,10 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libplacement "haixun-backend/internal/library/placement" libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/library/threadspost" scanpostentity "haixun-backend/internal/model/scan_post/domain/entity" scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" @@ -38,7 +41,7 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre return nil, err } scanPostID := strings.TrimSpace(req.ScanPostID) - text := strings.TrimSpace(req.Text) + text := liboutreach.NormalizeDraftText(req.Text) confirm := req.Confirm if scanPostID == "" { return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required") @@ -49,6 +52,9 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre if text == "" { return nil, app.For(code.Brand).InputMissingRequired("text is required") } + if err := threadspost.ValidateReply(text); err != nil { + return nil, app.For(code.Brand).InputInvalidFormat(err.Error()) + } if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil { return nil, err @@ -57,26 +63,49 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre if err != nil { return nil, err } - externalID := strings.TrimSpace(post.ExternalID) - if externalID == "" { - return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 external_id,無法透過 API 回覆") + rawTargetID := libplacement.ResolveReplyTargetID(post.ExternalID, post.Permalink) + if rawTargetID == "" { + return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 Threads 貼文 ID(external_id 或有效 permalink),無法透過 API 回覆") } - cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid) + cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsReplyCredential(l.ctx, tenantID, uid) if err != nil { return nil, err } + storageState := "" + if session, sessErr := l.svcCtx.ThreadsAccount.GetBrowserSession(l.ctx, tenantID, uid, cred.AccountID); sessErr == nil && session != nil { + storageState = strings.TrimSpace(session.StorageState) + } + resolveInput := libplacement.ReplyMediaResolveInput{ + AccessToken: cred.AccessToken, + StorageState: storageState, + ExternalID: rawTargetID, + Permalink: post.Permalink, + HintText: post.Text, + } + replyToID, err := libplacement.ResolveReplyMediaForPublish(l.ctx, resolveInput) + if err != nil { + return nil, app.For(code.Brand).InputMissingRequired(err.Error()) + } + apiClient := libthreads.NewClient(cred.AccessToken) + if err := apiClient.PrimeReplyTargetForPublish(l.ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: rawTargetID, + Permalink: post.Permalink, + HintText: post.Text, + }, replyToID); err != nil { + return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 無法回覆此貼文:" + err.Error()) + } result, err := libthreads.PublishReply(l.ctx, libthreads.PublishReplyInput{ ThreadsUserID: cred.ThreadsUserID, AccessToken: cred.AccessToken, - ReplyToID: externalID, + ReplyToID: replyToID, Text: text, }) if err != nil { return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發送留言失敗:" + err.Error()) } - updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{ + patch := scanpostusecase.UpdateOutreachRequest{ TenantID: tenantID, OwnerUID: uid, BrandID: req.ID, @@ -84,7 +113,11 @@ func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutre Status: scanpostentity.OutreachStatusPublished, PublishedReplyID: result.MediaID, PublishedPermalink: result.Permalink, - }) + } + if libthreads.IsNumericMediaID(replyToID) && !libthreads.IsNumericMediaID(post.ExternalID) { + patch.ExternalID = replyToID + } + updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, patch) if err != nil { return nil, err } diff --git a/backend/internal/logic/brand/start_brand_scan_job_logic.go b/backend/internal/logic/brand/start_brand_scan_job_logic.go index b6c8a81..21ba896 100644 --- a/backend/internal/logic/brand/start_brand_scan_job_logic.go +++ b/backend/internal/logic/brand/start_brand_scan_job_logic.go @@ -123,11 +123,8 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡") } payload := map[string]any{ diff --git a/backend/internal/logic/brand/update_brand_product_logic.go b/backend/internal/logic/brand/update_brand_product_logic.go index 34e8b5d..185518e 100644 --- a/backend/internal/logic/brand/update_brand_product_logic.go +++ b/backend/internal/logic/brand/update_brand_product_logic.go @@ -5,6 +5,7 @@ package brand import ( "context" + "strings" domusecase "haixun-backend/internal/model/brand/domain/usecase" "haixun-backend/internal/svc" @@ -32,15 +33,18 @@ func (l *UpdateBrandProductLogic) UpdateBrandProduct(req *types.UpdateBrandProdu if err != nil { return nil, err } + placementURL := strings.TrimSpace(req.PlacementURL) item, err := l.svcCtx.Brand.UpdateProduct(l.ctx, domusecase.UpdateProductRequest{ - TenantID: tenantID, - OwnerUID: uid, - BrandID: req.ID, - ProductID: req.ProductID, - Label: req.Label, - ProductContext: req.ProductContext, - MatchTags: req.MatchTags, - MatchTagsSet: req.MatchTags != nil, + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + ProductID: req.ProductID, + Label: req.Label, + ProductContext: req.ProductContext, + PlacementURL: &placementURL, + PlacementURLSet: true, + MatchTags: req.MatchTags, + MatchTagsSet: req.MatchTags != nil, }) if err != nil { return nil, err diff --git a/backend/internal/logic/brand/update_outreach_draft_logic.go b/backend/internal/logic/brand/update_outreach_draft_logic.go new file mode 100644 index 0000000..2a226b0 --- /dev/null +++ b/backend/internal/logic/brand/update_outreach_draft_logic.go @@ -0,0 +1,68 @@ +package brand + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + "haixun-backend/internal/library/threadspost" + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateOutreachDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOutreachDraftLogic { + return &UpdateOutreachDraftLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateOutreachDraftLogic) UpdateOutreachDraft(req *types.UpdateOutreachDraftHandlerReq) (*types.GenerateOutreachDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + text := liboutreach.NormalizeDraftText(req.Text) + if text == "" { + return nil, app.For(code.Brand).InputMissingRequired("text is required") + } + if err := threadspost.ValidateReply(text); err != nil { + return nil, app.For(code.Brand).InputInvalidFormat(err.Error()) + } + if req.DraftIndex < 0 { + return nil, app.For(code.Brand).InputInvalidFormat("draft_index must be >= 0") + } + + if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + + updated, err := l.svcCtx.OutreachDraft.UpdateDraftItem(l.ctx, outreachusecase.UpdateDraftItemRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + DraftID: draftID, + DraftIndex: req.DraftIndex, + Text: text, + }) + if err != nil { + return nil, err + } + return toOutreachDraftData(updated), nil +} \ No newline at end of file diff --git a/backend/internal/logic/member/mapper.go b/backend/internal/logic/member/mapper.go index ae262f9..44edd5e 100644 --- a/backend/internal/logic/member/mapper.go +++ b/backend/internal/logic/member/mapper.go @@ -33,6 +33,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl BraveSearchLang: settings.BraveSearchLang, ExaUserLocation: settings.ExaUserLocation, ExpandStrategy: settings.ExpandStrategy, + DevModeEnabled: settings.DevModeEnabled, } } diff --git a/backend/internal/logic/member/update_member_placement_settings_logic.go b/backend/internal/logic/member/update_member_placement_settings_logic.go index 14e918f..a0defad 100644 --- a/backend/internal/logic/member/update_member_placement_settings_logic.go +++ b/backend/internal/logic/member/update_member_placement_settings_logic.go @@ -40,6 +40,7 @@ func (l *UpdateMemberPlacementSettingsLogic) UpdateMemberPlacementSettings(req * BraveSearchLang: req.BraveSearchLang, ExaUserLocation: req.ExaUserLocation, ExpandStrategy: req.ExpandStrategy, + DevModeEnabled: req.DevModeEnabled, }) if err != nil { return nil, err diff --git a/backend/internal/logic/persona/copy_draft_mapper.go b/backend/internal/logic/persona/copy_draft_mapper.go new file mode 100644 index 0000000..3872eca --- /dev/null +++ b/backend/internal/logic/persona/copy_draft_mapper.go @@ -0,0 +1,30 @@ +package persona + +import ( + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + "haixun-backend/internal/types" +) + +func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData { + return types.CopyDraftData{ + ID: item.ID, + PersonaID: item.PersonaID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + Status: item.Status, + PublishQueueID: item.PublishQueueID, + PublishedMediaID: item.PublishedMediaID, + PublishedPermalink: item.PublishedPermalink, + PublishedAt: item.PublishedAt, + CreateAt: item.CreateAt, + } +} \ No newline at end of file diff --git a/backend/internal/logic/persona/generate_from_content_formula_logic.go b/backend/internal/logic/persona/generate_from_content_formula_logic.go new file mode 100644 index 0000000..15a00e7 --- /dev/null +++ b/backend/internal/logic/persona/generate_from_content_formula_logic.go @@ -0,0 +1,142 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libformula "haixun-backend/internal/library/formula" + "haixun-backend/internal/library/placement" + "haixun-backend/internal/library/style8d" + "haixun-backend/internal/library/websearch" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GenerateFromContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateFromContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateFromContentFormulaLogic { + return &GenerateFromContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types.GenerateFromContentFormulaHandlerReq) (*types.GenerateFromContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + topic := strings.TrimSpace(req.Topic) + if topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic is required") + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return nil, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") + } + formula, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.AccountID, req.FormulaID) + if err != nil { + return nil, err + } + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + + researchNotes := "" + if req.UseWebSearch { + research, rerr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if rerr == nil && placement.WebSearchAvailable(research) { + memberCtx, merr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if merr == nil { + webClient := websearch.New(memberCtx.WebSearchConfig()) + if webClient.Enabled() { + resp, _ := webClient.Search(l.ctx, websearch.SearchOptions{ + Query: topic + " " + strings.TrimSpace(req.Brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if len(resp.Results) > 0 { + var b strings.Builder + for _, snip := range resp.Results { + if snip.Title != "" { + b.WriteString("- ") + b.WriteString(snip.Title) + b.WriteString("\n") + } + if snip.Snippet != "" { + b.WriteString(snip.Snippet) + b.WriteString("\n") + } + } + researchNotes = strings.TrimSpace(b.String()) + } + } + } + } + } + + list := make([]types.CopyDraftData, 0, count) + for i := 0; i < count; i++ { + generated, genErr := libformula.GenerateDraft(l.ctx, l.svcCtx.AI, aiReq, libformula.GenerateInput{ + Topic: topic, + Brief: req.Brief, + PersonaBlock: personaBlock, + ResearchNotes: researchNotes, + Formula: *formula, + }) + if genErr != nil { + return nil, app.For(code.AI).SvcThirdParty("公式生成失敗:" + genErr.Error()) + } + saved, saveErr := l.svcCtx.CopyDraft.Create(l.ctx, copydraftdomain.CreateRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + FormulaID: formula.ID, + DraftType: copydraftentity.DraftTypeFormula, + Text: generated.Text, + Angle: generated.Angle, + Hook: generated.Hook, + Rationale: generated.Rationale, + ReferenceNotes: generated.StructureNotes, + Sources: []string{formula.Label}, + }) + if saveErr != nil { + return nil, saveErr + } + list = append(list, toCopyDraftData(*saved)) + } + return &types.GenerateFromContentFormulaData{ + List: list, + Message: fmt.Sprintf("已依公式產生 %d 篇草稿,已加入內容夾", len(list)), + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/persona/list_persona_content_inbox_logic.go b/backend/internal/logic/persona/list_persona_content_inbox_logic.go new file mode 100644 index 0000000..219849e --- /dev/null +++ b/backend/internal/logic/persona/list_persona_content_inbox_logic.go @@ -0,0 +1,106 @@ +package persona + +import ( + "context" + "math" + + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListPersonaContentInboxLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPersonaContentInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaContentInboxLogic { + return &ListPersonaContentInboxLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPersonaContentInboxLogic) ListPersonaContentInbox(req *types.ListPersonaContentInboxHandlerReq) (*types.ListPersonaContentInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := req.ID + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + page := int(req.Page) + if page <= 0 { + page = 1 + } + pageSize := int(req.PageSize) + if pageSize <= 0 { + pageSize = 12 + } + result, err := l.svcCtx.CopyDraft.ListInbox(l.ctx, copydraftdomain.InboxListRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + Status: req.Status, + RangeStart: req.RangeStart, + RangeEnd: req.RangeEnd, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + + queueIDs := make([]string, 0) + for _, item := range result.Items { + if item.PublishQueueID != "" { + queueIDs = append(queueIDs, item.PublishQueueID) + } + } + queueByID := map[string]int64{} + if len(queueIDs) > 0 { + queueItems, qerr := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, queueIDs) + if qerr == nil { + for _, q := range queueItems { + queueByID[q.ID] = q.ScheduledAt + } + } + } + + list := make([]types.ContentInboxItemData, 0, len(result.Items)) + for _, item := range result.Items { + scheduledAt := queueByID[item.PublishQueueID] + lifecycle, groupDate := inboxLifecycle(item, scheduledAt) + list = append(list, types.ContentInboxItemData{ + CopyDraftData: toCopyDraftData(item), + ScheduledAt: scheduledAt, + Lifecycle: lifecycle, + GroupDate: groupDate, + }) + } + + totalPages := int64(0) + if pageSize > 0 { + totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize))) + } + return &types.ListPersonaContentInboxData{ + Pagination: types.PaginationData{ + Total: result.Total, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: totalPages, + }, + List: list, + }, nil +} + +func inboxLifecycle(item copydraftdomain.CopyDraftSummary, scheduledAt int64) (lifecycle string, groupDate int64) { + if item.PublishedAt > 0 { + return "published", item.PublishedAt + } + if item.PublishQueueID != "" || scheduledAt > 0 { + if scheduledAt > 0 { + return "scheduled", scheduledAt + } + return "scheduled", item.CreateAt + } + return "draft", item.CreateAt +} \ No newline at end of file diff --git a/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go new file mode 100644 index 0000000..5f68b53 --- /dev/null +++ b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go @@ -0,0 +1,58 @@ +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/library/clock" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type SchedulePersonaCopyDraftLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSchedulePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaCopyDraftLogic { + return &SchedulePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.SchedulePersonaCopyDraftHandlerReq) (*types.ScheduleCopyDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { + return nil, err + } + draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, req.DraftID) + if err != nil { + return nil, err + } + scheduledAt := req.ScheduledAt + if scheduledAt <= 0 { + scheduledAt = clock.NowUnixNano() + } + item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, + PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID, + Text: draft.Text, ScheduledAt: scheduledAt, + }) + if err != nil { + return nil, err + } + _, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, + }) + return &types.ScheduleCopyDraftsData{ + Scheduled: 1, + List: []types.PublishQueueItemData{publishQueueData(item)}, + Message: fmt.Sprintf("已排程草稿 %s", draft.ID), + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go index 61fe85e..a00aac5 100644 --- a/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go +++ b/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go @@ -2,7 +2,10 @@ package placement_topic import ( "context" + "strings" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" brandlogic "haixun-backend/internal/logic/brand" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,6 +32,13 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea if err != nil { return nil, err } + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") + } + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = strings.TrimSpace(scope.Topic.ProductID) + } return brandlogic.NewGenerateOutreachDraftsLogic(l.ctx, l.svcCtx).GenerateOutreachDrafts(&types.GenerateOutreachDraftsHandlerReq{ BrandPath: types.BrandPath{ID: scope.BrandID}, GenerateOutreachDraftsReq: types.GenerateOutreachDraftsReq{ @@ -36,7 +46,7 @@ func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutrea TopicID: scope.TopicID, Count: req.Count, VoicePersonaID: req.VoicePersonaID, - ProductID: scope.Topic.ProductID, + ProductID: productID, }, }) } diff --git a/backend/internal/logic/placement_topic/mapper.go b/backend/internal/logic/placement_topic/mapper.go index a25ae52..ab7f04b 100644 --- a/backend/internal/logic/placement_topic/mapper.go +++ b/backend/internal/logic/placement_topic/mapper.go @@ -163,6 +163,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { GraphNodeID: post.GraphNodeID, SearchTag: post.SearchTag, QueryDimension: post.QueryDimension, + RecencyDays: post.RecencyDays, ExternalID: post.ExternalID, Permalink: post.Permalink, Author: post.Author, diff --git a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go index 3c5c85a..143f18e 100644 --- a/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go +++ b/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go @@ -31,7 +31,8 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req * if len(req.Updates) == 0 { return nil, app.For(code.Brand).InputMissingRequired("updates is required") } - if _, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID); err != nil { + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { return nil, err } updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates)) @@ -63,6 +64,7 @@ func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req * graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ TenantID: tenantID, OwnerUID: uid, + BrandID: scope.BrandID, TopicID: req.ID, Updates: updates, }) diff --git a/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go new file mode 100644 index 0000000..845f709 --- /dev/null +++ b/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go @@ -0,0 +1,127 @@ +package placement_topic + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPlacementTopicOutreachDraftJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPlacementTopicOutreachDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicOutreachDraftJobLogic { + return &StartPlacementTopicOutreachDraftJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPlacementTopicOutreachDraftJobLogic) StartPlacementTopicOutreachDraftJob( + req *types.StartPlacementTopicOutreachDraftJobHandlerReq, +) (*types.StartPlacementTopicOutreachDraftJobsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + scanPostIDs := append([]string{}, req.ScanPostIDs...) + if id := strings.TrimSpace(req.ScanPostID); id != "" { + scanPostIDs = append(scanPostIDs, id) + } + seen := map[string]struct{}{} + unique := make([]string, 0, len(scanPostIDs)) + for _, id := range scanPostIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("scan_post_id or scan_post_ids is required") + } + if strings.TrimSpace(req.VoicePersonaID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.VoicePersonaID); err != nil { + return nil, err + } + + count := req.Count + if count <= 0 { + count = 3 + } + if count > 4 { + count = 4 + } + + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = strings.TrimSpace(scope.Topic.ProductID) + } + + jobs := make([]types.StartBrandScanJobData, 0, len(unique)) + for _, scanPostID := range unique { + if _, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, scope.BrandID, scanPostID); err != nil { + return nil, err + } + regenerate := false + if existing, existingErr := l.svcCtx.OutreachDraft.GetLatestByScanPost( + l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, scanPostID, + ); existingErr == nil && existing != nil && len(existing.Drafts) > 0 { + regenerate = true + } + payload := map[string]any{ + "brand_id": scope.BrandID, + "topic_id": scope.TopicID, + "scan_post_id": scanPostID, + "count": count, + "voice_persona_id": strings.TrimSpace(req.VoicePersonaID), + "regenerate": regenerate, + } + if productID != "" { + payload["product_id"] = productID + } + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-outreach-draft", + Scope: "placement_topic", + ScopeID: scope.TopicID, + TenantID: tenantID, + OwnerUID: uid, + Payload: payload, + }) + if err != nil { + return nil, err + } + jobs = append(jobs, types.StartBrandScanJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + Message: "獲客草稿背景任務已建立", + }) + } + + message := "獲客草稿已在背景執行,可繼續瀏覽其他頁面" + if len(jobs) > 1 { + message = fmt.Sprintf("已送出 %d 個獲客草稿背景任務,可繼續瀏覽其他頁面", len(jobs)) + } + return &types.StartPlacementTopicOutreachDraftJobsData{ + Jobs: jobs, + Message: message, + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go index f0688e3..7a27e83 100644 --- a/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go +++ b/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go @@ -68,6 +68,7 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. } dualTrack := true patrolMode := req.PatrolMode + testPatrol := req.TestPatrol brandForPatrol := scope.Brand brandForPatrol.ProductID = scope.Topic.ProductID brandForPatrol.ResearchMap = scope.Topic.ResearchMap @@ -80,15 +81,22 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. if graph != nil { patrolNodes = graph.Nodes } + if len(nodeIDs) == 0 && selected > 0 && graph != nil { + for _, node := range graph.Nodes { + if node.SelectedForScan { + nodeIDs = append(nodeIDs, node.ID) + } + } + } patrolKeywords := libkg.ResolveScanPatrolKeywords( req.PatrolKeywords, scope.Topic.ResearchMap.PatrolKeywords, patrolInput, patrolNodes, ) - if patrolMode || (len(nodeIDs) == 0 && selected == 0) { + if patrolMode || selected > 0 || len(nodeIDs) > 0 { if len(patrolKeywords) == 0 { - return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") + return nil, app.For(code.Brand).InputMissingRequired("請先勾選痛點節點或產生研究地圖,系統會綜合知識圖譜與產品自動整理海巡關鍵字") } patrolMode = true } else if graph == nil { @@ -98,21 +106,33 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. if err != nil { return nil, err } + placementSettings, err := l.svcCtx.Placement.Get(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) if err != nil { return nil, err } - if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { - return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") - } - if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { - return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) - } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + if testPatrol { + if placementSettings == nil || !placementSettings.DevModeEnabled { + return nil, app.For(code.Setting).InputMissingRequired("請先在設定頁開啟「開發模式(測試海巡)」") + } + if !memberCtx.BrowserConnected { + return nil, app.For(code.Setting).InputMissingRequired("測試海巡需先同步 Chrome Extension Session") + } + memberCtx = placement.MemberContextForCrawlerOnly(memberCtx) + patrolMode = true + } else { + if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { + return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") + } + if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) { + return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research)) + } + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡") + } } payload := map[string]any{ "topic_id": scope.TopicID, @@ -125,6 +145,9 @@ func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types. payload["patrol_mode"] = true payload["patrol_keywords"] = patrolKeywords } + if testPatrol { + payload["test_patrol"] = true + } for key, value := range memberCtx.PayloadFields() { payload[key] = value } diff --git a/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go new file mode 100644 index 0000000..c271759 --- /dev/null +++ b/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go @@ -0,0 +1,42 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdatePlacementTopicOutreachDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdatePlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicOutreachDraftLogic { + return &UpdatePlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdatePlacementTopicOutreachDraftLogic) UpdatePlacementTopicOutreachDraft( + req *types.UpdatePlacementTopicOutreachDraftHandlerReq, +) (*types.GenerateOutreachDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return brandlogic.NewUpdateOutreachDraftLogic(l.ctx, l.svcCtx).UpdateOutreachDraft(&types.UpdateOutreachDraftHandlerReq{ + BrandPath: types.BrandPath{ID: scope.BrandID}, + DraftID: req.DraftID, + UpdateOutreachDraftReq: types.UpdateOutreachDraftReq{ + DraftIndex: req.DraftIndex, + Text: req.Text, + }, + }) +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/analyze_content_formula_logic.go b/backend/internal/logic/threads_account/analyze_content_formula_logic.go new file mode 100644 index 0000000..0ba9f0a --- /dev/null +++ b/backend/internal/logic/threads_account/analyze_content_formula_logic.go @@ -0,0 +1,227 @@ +package threads_account + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libformula "haixun-backend/internal/library/formula" + libownpost "haixun-backend/internal/library/ownpost" + liboutreach "haixun-backend/internal/library/outreach" + libviral "haixun-backend/internal/library/viral" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type AnalyzeContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAnalyzeContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzeContentFormulaLogic { + return &AnalyzeContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *AnalyzeContentFormulaLogic) AnalyzeContentFormula(req *types.AnalyzeContentFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + sourceType := strings.TrimSpace(req.SourceType) + if sourceType == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("source_type is required") + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := l.resolvePersonaBlock(tenantID, uid, req.PersonaID) + + var analysis *libformula.PastedAnalysis + var sourceRef, postText, author, permalink string + var metrics *cfentity.SourceMetrics + + switch sourceType { + case cfentity.SourcePaste: + postText = strings.TrimSpace(req.PostText) + if postText == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required for paste") + } + author = strings.TrimSpace(req.AuthorName) + analysis, err = libformula.AnalyzePasted(l.ctx, l.svcCtx.AI, aiReq, libformula.AnalyzePastedInput{ + PostText: postText, AuthorName: author, PersonaBlock: personaBlock, + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, + }) + sourceRef = "paste" + case cfentity.SourceOwnPost: + mediaID := strings.TrimSpace(req.MediaID) + if mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required for own_post") + } + review, rerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, mediaID) + if rerr != nil || review == nil || req.ForceRefresh { + gen := NewGenerateOwnPostFormulaLogic(l.ctx, l.svcCtx) + force := req.ForceRefresh + genData, genErr := gen.GenerateOwnPostFormula(&types.GenerateOwnPostFormulaHandlerReq{ + ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID}, + GenerateOwnPostFormulaReq: types.GenerateOwnPostFormulaReq{ + MediaID: mediaID, PersonaID: req.PersonaID, PostText: req.PostText, + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, Views: req.Views, + Force: force, + }, + }) + if genErr != nil { + return nil, genErr + } + analysis = &libformula.PastedAnalysis{ + Summary: genData.Summary, Wins: genData.Wins, Improvements: genData.Improvements, + Formula: genData.Formula, PostTemplate: genData.PostTemplate, + HookPattern: genData.HookPattern, Structure: genData.Structure, + ReplicationTips: genData.ReplicationTips, Avoid: genData.Avoid, + } + } else { + analysis = libformula.FromOwnPostReview(&libownpost.PostFormulaReview{ + 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, + }) + } + postText = strings.TrimSpace(req.PostText) + if postText == "" && review != nil { + postText = review.PostText + } + sourceRef = mediaID + case cfentity.SourceScanPost: + scanPostID := strings.TrimSpace(req.ScanPostID) + personaID := strings.TrimSpace(req.PersonaID) + if scanPostID == "" || personaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("scan_post_id and persona_id are required for scan_post") + } + post, perr := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID) + if perr != nil { + return nil, perr + } + postText = post.Text + author = post.Author + permalink = post.Permalink + analysis, err = l.analyzeViralPost(aiReq, post.Text, post.Author, post.LikeCount, post.ReplyCount, post.SearchTag, personaBlock) + sourceRef = scanPostID + metrics = &cfentity.SourceMetrics{LikeCount: post.LikeCount, ReplyCount: post.ReplyCount} + case cfentity.SourceKeywordSearch: + postText = strings.TrimSpace(req.PostText) + if postText == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required after selecting a search result") + } + author = strings.TrimSpace(req.AuthorName) + permalink = strings.TrimSpace(req.Permalink) + analysis, err = l.analyzeViralPost(aiReq, postText, author, req.LikeCount, req.ReplyCount, req.Keyword, personaBlock) + sourceRef = strings.TrimSpace(req.Keyword) + metrics = &cfentity.SourceMetrics{ + LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, + Views: req.Views, RepostCount: req.RepostCount, + QuoteCount: req.QuoteCount, Shares: req.Shares, + } + default: + return nil, app.For(code.ThreadsAccount).InputMissingRequired("unsupported source_type") + } + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("分析失敗:" + err.Error()) + } + if analysis == nil { + return nil, app.For(code.AI).SvcThirdParty("分析未產出有效公式") + } + + label := strings.TrimSpace(req.SaveLabel) + if label == "" { + label = defaultFormulaLabel(sourceType, postText, author) + } + saved, err := l.svcCtx.ContentFormula.Create(l.ctx, cfdom.CreateRequest{ + TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, + Label: label, SourceType: sourceType, SourceRef: sourceRef, + SourcePostText: postText, SourceAuthor: author, SourcePermalink: permalink, + SourceMetrics: metrics, + Summary: analysis.Summary, Wins: analysis.Wins, Improvements: analysis.Improvements, + Formula: analysis.Formula, PostTemplate: analysis.PostTemplate, + HookPattern: analysis.HookPattern, Structure: analysis.Structure, + ReplicationTips: analysis.ReplicationTips, Avoid: analysis.Avoid, + }) + if err != nil { + return nil, err + } + data := toContentFormulaData(*saved) + return &types.AnalyzeContentFormulaData{ + Formula: data, + Message: fmt.Sprintf("已分析並存入公式庫:%s", data.Label), + }, nil +} + +func (l *AnalyzeContentFormulaLogic) resolvePersonaBlock(tenantID, uid, personaID string) string { + personaID = strings.TrimSpace(personaID) + if personaID == "" { + return "" + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return "" + } + return liboutreach.FormatVoicePersonaBlock(persona) +} + +func (l *AnalyzeContentFormulaLogic) analyzeViralPost( + aiReq domai.GenerateRequest, + postText, author string, likes, replies int, tag, personaBlock string, +) (*libformula.PastedAnalysis, error) { + out, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ + Provider: aiReq.Provider, Model: aiReq.Model, Credential: aiReq.Credential, + System: libviral.BuildAnalyzeViralSystemPrompt(), + Messages: []domai.Message{{Role: "user", Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{ + PostText: postText, AuthorName: author, LikeCount: likes, ReplyCount: replies, + SearchTag: tag, TopicLabel: tag, Persona: personaBlock, + })}}, + }) + if err != nil { + return nil, err + } + parsed, err := libviral.ParseAnalyzeViralOutput(out.Text) + if err != nil { + return nil, err + } + return libformula.FromViralAnalysis( + parsed.HookPattern, parsed.StructurePattern, parsed.EmotionalTrigger, + parsed.ReplicationStrategy, parsed.KeyTakeaways, + ), nil +} + +func defaultFormulaLabel(sourceType, postText, author string) string { + preview := strings.TrimSpace(postText) + if len([]rune(preview)) > 24 { + preview = string([]rune(preview)[:24]) + "…" + } + if author != "" { + return fmt.Sprintf("%s · @%s", sourceType, author) + } + if preview != "" { + return preview + } + return sourceType + " 公式" +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/content_formula_mapper.go b/backend/internal/logic/threads_account/content_formula_mapper.go new file mode 100644 index 0000000..77e34b4 --- /dev/null +++ b/backend/internal/logic/threads_account/content_formula_mapper.go @@ -0,0 +1,58 @@ +package threads_account + +import ( + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/types" +) + +func toContentFormulaData(item cfdom.FormulaSummary) types.ContentFormulaData { + var metrics *types.ContentFormulaSourceMetricsData + if item.SourceMetrics != nil { + metrics = &types.ContentFormulaSourceMetricsData{ + LikeCount: item.SourceMetrics.LikeCount, + ReplyCount: item.SourceMetrics.ReplyCount, + Views: item.SourceMetrics.Views, + RepostCount: item.SourceMetrics.RepostCount, + QuoteCount: item.SourceMetrics.QuoteCount, + Shares: item.SourceMetrics.Shares, + } + } + return types.ContentFormulaData{ + ID: item.ID, + AccountID: item.AccountID, + Label: item.Label, + SourceType: item.SourceType, + SourceRef: item.SourceRef, + SourcePostText: item.SourcePostText, + SourceAuthor: item.SourceAuthor, + SourcePermalink: item.SourcePermalink, + SourceMetrics: metrics, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + Tags: item.Tags, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toSourceMetrics(data *types.ContentFormulaSourceMetricsData) *cfentity.SourceMetrics { + if data == nil { + return nil + } + return &cfentity.SourceMetrics{ + LikeCount: data.LikeCount, + ReplyCount: data.ReplyCount, + Views: data.Views, + RepostCount: data.RepostCount, + QuoteCount: data.QuoteCount, + Shares: data.Shares, + } +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/delete_content_formula_logic.go b/backend/internal/logic/threads_account/delete_content_formula_logic.go new file mode 100644 index 0000000..23f9c98 --- /dev/null +++ b/backend/internal/logic/threads_account/delete_content_formula_logic.go @@ -0,0 +1,32 @@ +package threads_account + +import ( + "context" + "fmt" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeleteContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContentFormulaLogic { + return &DeleteContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeleteContentFormulaLogic) DeleteContentFormula(req *types.GetContentFormulaHandlerReq) (*types.DeleteContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.ContentFormula.Delete(l.ctx, tenantID, uid, req.ID, req.FormulaID); err != nil { + return nil, err + } + return &types.DeleteContentFormulaData{ + FormulaID: req.FormulaID, + Message: fmt.Sprintf("已刪除公式 %s", req.FormulaID), + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/generate_own_post_formula_logic.go b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go new file mode 100644 index 0000000..89c33a4 --- /dev/null +++ b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go @@ -0,0 +1,126 @@ +package threads_account + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libownpost "haixun-backend/internal/library/ownpost" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateOwnPostFormulaLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateOwnPostFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostFormulaLogic { + return &GenerateOwnPostFormulaLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula( + req *types.GenerateOwnPostFormulaHandlerReq, +) (*types.GenerateOwnPostFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req.MediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required") + } + + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + if !req.Force { + if cached, cerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, req.MediaID); cerr == nil && cached != nil { + if l.svcCtx.OwnPostFormula.IsFresh(cached, 0) { + data := toOwnPostFormulaReviewData(cached) + data.FromCache = true + return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil + } + } + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + personaBlock := "" + if personaID := strings.TrimSpace(req.PersonaID); personaID != "" { + persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if perr != nil { + return nil, perr + } + personaBlock = liboutreach.FormatVoicePersonaBlock(persona) + } else if personaID = strings.TrimSpace(account.PersonaID); personaID != "" { + persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if perr == nil { + personaBlock = liboutreach.FormatVoicePersonaBlock(persona) + } + } + + accountName := account.DisplayName + if accountName == "" { + accountName = account.Username + } + + review, err := libownpost.GeneratePostFormula(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + }, libownpost.PostFormulaInput{ + AccountName: accountName, + PersonaBlock: personaBlock, + PostText: req.PostText, + TopicTag: req.TopicTag, + MediaType: req.MediaType, + LikeCount: req.LikeCount, + ReplyCount: req.ReplyCount, + Views: req.Views, + RepostCount: req.RepostCount, + QuoteCount: req.QuoteCount, + Shares: req.Shares, + Timestamp: req.Timestamp, + }) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("AI 貼文覆盤失敗:" + err.Error()) + } + saved, err := l.svcCtx.OwnPostFormula.SaveGenerated( + l.ctx, + tenantID, + uid, + req.ID, + req.MediaID, + strings.TrimSpace(req.PersonaID), + req.PostText, + review, + ) + if err != nil { + return nil, err + } + data := toOwnPostFormulaReviewData(saved) + data.FromCache = false + return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go new file mode 100644 index 0000000..c3b5b78 --- /dev/null +++ b/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go @@ -0,0 +1,98 @@ +package threads_account + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + liboutreach "haixun-backend/internal/library/outreach" + libownpost "haixun-backend/internal/library/ownpost" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GenerateOwnPostReplyDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGenerateOwnPostReplyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostReplyDraftLogic { + return &GenerateOwnPostReplyDraftLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GenerateOwnPostReplyDraftLogic) GenerateOwnPostReplyDraft( + req *types.GenerateOwnPostReplyDraftHandlerReq, +) (*types.GenerateOwnPostReplyDraftData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req.MediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required") + } + + account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + personaID := strings.TrimSpace(req.PersonaID) + if personaID == "" { + personaID = strings.TrimSpace(account.PersonaID) + } + if personaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先選擇人設(persona_id)") + } + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) + if err != nil { + return nil, err + } + personaBlock := liboutreach.FormatVoicePersonaBlock(persona) + if strings.TrimSpace(personaBlock) == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("所選人設尚未填寫語氣或 8D 風格,請先到人設頁補齊") + } + + accountName := account.DisplayName + if accountName == "" { + accountName = account.Username + } + + text, err := libownpost.GenerateReplyDraft(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + }, libownpost.ReplyDraftInput{ + AccountName: accountName, + PersonaBlock: personaBlock, + PostText: req.PostText, + ReplyText: req.ReplyText, + ReplyToID: req.ReplyToID, + ThreadPostText: req.ThreadPostText, + ParentReplyText: req.ParentReplyText, + }) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("AI 回覆草稿生成失敗:" + err.Error()) + } + return &types.GenerateOwnPostReplyDraftData{Text: text}, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/get_content_formula_logic.go b/backend/internal/logic/threads_account/get_content_formula_logic.go new file mode 100644 index 0000000..2588879 --- /dev/null +++ b/backend/internal/logic/threads_account/get_content_formula_logic.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GetContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetContentFormulaLogic { + return &GetContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetContentFormulaLogic) GetContentFormula(req *types.GetContentFormulaHandlerReq) (*types.ContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.ID, req.FormulaID) + if err != nil { + return nil, err + } + data := toContentFormulaData(*item) + return &data, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go new file mode 100644 index 0000000..8d0ac8e --- /dev/null +++ b/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go @@ -0,0 +1,30 @@ +package threads_account + +import ( + "context" + + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ImportOwnPostToContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewImportOwnPostToContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportOwnPostToContentFormulaLogic { + return &ImportOwnPostToContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ImportOwnPostToContentFormulaLogic) ImportOwnPostToContentFormula(req *types.ImportOwnPostFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) { + analyze := NewAnalyzeContentFormulaLogic(l.ctx, l.svcCtx) + return analyze.AnalyzeContentFormula(&types.AnalyzeContentFormulaHandlerReq{ + ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID}, + AnalyzeContentFormulaReq: types.AnalyzeContentFormulaReq{ + SourceType: cfentity.SourceOwnPost, + MediaID: req.MediaID, + SaveLabel: req.Label, + }, + }) +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/list_content_formulas_logic.go b/backend/internal/logic/threads_account/list_content_formulas_logic.go new file mode 100644 index 0000000..a9f0ca8 --- /dev/null +++ b/backend/internal/logic/threads_account/list_content_formulas_logic.go @@ -0,0 +1,63 @@ +package threads_account + +import ( + "context" + "math" + + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type ListContentFormulasLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListContentFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentFormulasLogic { + return &ListContentFormulasLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListContentFormulasLogic) ListContentFormulas(req *types.ListContentFormulasHandlerReq) (*types.ListContentFormulasData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + page := int(req.Page) + if page <= 0 { + page = 1 + } + pageSize := int(req.PageSize) + if pageSize <= 0 { + pageSize = 20 + } + result, err := l.svcCtx.ContentFormula.List(l.ctx, cfdom.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + SourceType: req.SourceType, + Tag: req.Tag, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + list := make([]types.ContentFormulaData, 0, len(result.Items)) + for _, item := range result.Items { + list = append(list, toContentFormulaData(item)) + } + totalPages := int64(0) + if pageSize > 0 { + totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize))) + } + return &types.ListContentFormulasData{ + Pagination: types.PaginationData{ + Total: result.Total, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: totalPages, + }, + List: list, + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/list_mention_inbox_logic.go b/backend/internal/logic/threads_account/list_mention_inbox_logic.go new file mode 100644 index 0000000..f3662a5 --- /dev/null +++ b/backend/internal/logic/threads_account/list_mention_inbox_logic.go @@ -0,0 +1,53 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMentionInboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMentionInboxLogic { + return &ListMentionInboxLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListMentionInboxLogic) ListMentionInbox(req *types.ListMentionInboxHandlerReq) (*types.ListMentionInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.List(l.ctx, mentioninboxdomain.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + return &types.ListMentionInboxData{ + List: toMentionInboxItems(result.List), + Pagination: types.PaginationData{ + Total: result.Pagination.Total, + Page: result.Pagination.Page, + PageSize: result.Pagination.PageSize, + TotalPages: result.Pagination.TotalPages, + }, + ReadyTotal: result.Pagination.ReadyTotal, + NewestSyncedAt: result.Pagination.NewestSyncedAt, + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/list_own_post_formulas_logic.go b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go new file mode 100644 index 0000000..7664abc --- /dev/null +++ b/backend/internal/logic/threads_account/list_own_post_formulas_logic.go @@ -0,0 +1,43 @@ +package threads_account + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListOwnPostFormulasLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListOwnPostFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOwnPostFormulasLogic { + return &ListOwnPostFormulasLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListOwnPostFormulasLogic) ListOwnPostFormulas(req *types.ListOwnPostFormulasHandlerReq) (*types.ListOwnPostFormulasData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + items, err := l.svcCtx.OwnPostFormula.ListByAccount(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + list := make([]types.OwnPostFormulaReviewData, 0, len(items)) + for i := range items { + list = append(list, toOwnPostFormulaReviewData(&items[i])) + } + return &types.ListOwnPostFormulasData{List: list}, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/mapper.go b/backend/internal/logic/threads_account/mapper.go index 9695d3d..3efafaa 100644 --- a/backend/internal/logic/threads_account/mapper.go +++ b/backend/internal/logic/threads_account/mapper.go @@ -44,6 +44,8 @@ func toPostPerformanceData(result *domusecase.PostPerformanceResult) *types.Thre for _, item := range result.Posts { posts = append(posts, types.ThreadsPostPerformanceItem{ MediaID: item.MediaID, Text: item.Text, Permalink: item.Permalink, Timestamp: item.Timestamp, + MediaType: item.MediaType, MediaURL: item.MediaURL, ThumbnailURL: item.ThumbnailURL, + TopicTag: item.TopicTag, Shortcode: item.Shortcode, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, RepostCount: item.RepostCount, QuoteCount: item.QuoteCount, Views: item.Views, Shares: item.Shares, InsightsStatus: item.InsightsStatus, InsightsMessage: item.InsightsMessage, }) diff --git a/backend/internal/logic/threads_account/mention_inbox_mapper.go b/backend/internal/logic/threads_account/mention_inbox_mapper.go new file mode 100644 index 0000000..89bb9ce --- /dev/null +++ b/backend/internal/logic/threads_account/mention_inbox_mapper.go @@ -0,0 +1,32 @@ +package threads_account + +import ( + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/types" +) + +func toMentionInboxItems(items []mentioninboxdomain.MentionSummary) []types.MentionInboxItemData { + out := make([]types.MentionInboxItemData, 0, len(items)) + for _, item := range items { + out = append(out, types.MentionInboxItemData{ + MediaID: item.MediaID, + AuthorUsername: item.AuthorUsername, + Text: item.Text, + Permalink: item.Permalink, + Shortcode: item.Shortcode, + Timestamp: item.Timestamp, + MediaType: item.MediaType, + IsReply: item.IsReply, + IsQuotePost: item.IsQuotePost, + HasReplies: item.HasReplies, + RootPostID: item.RootPostID, + ParentID: item.ParentID, + ThreadPostText: item.ThreadPostText, + ParentReplyText: item.ParentReplyText, + ReplyReady: item.ReplyReady, + ReplyReadyMsg: item.ReplyReadyMsg, + SyncedAt: item.SyncedAt, + }) + } + return out +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/own_post_formula_mapper.go b/backend/internal/logic/threads_account/own_post_formula_mapper.go new file mode 100644 index 0000000..063a5e7 --- /dev/null +++ b/backend/internal/logic/threads_account/own_post_formula_mapper.go @@ -0,0 +1,25 @@ +package threads_account + +import ( + "haixun-backend/internal/model/own_post_formula/domain/entity" + "haixun-backend/internal/types" +) + +func toOwnPostFormulaReviewData(item *entity.Review) types.OwnPostFormulaReviewData { + if item == nil { + return types.OwnPostFormulaReviewData{} + } + return types.OwnPostFormulaReviewData{ + MediaID: item.MediaID, + ReviewedAt: item.UpdateAt, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + } +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/patch_content_formula_logic.go b/backend/internal/logic/threads_account/patch_content_formula_logic.go new file mode 100644 index 0000000..95f85a6 --- /dev/null +++ b/backend/internal/logic/threads_account/patch_content_formula_logic.go @@ -0,0 +1,42 @@ +package threads_account + +import ( + "context" + + cfdom "haixun-backend/internal/model/content_formula/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type PatchContentFormulaLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchContentFormulaLogic { + return &PatchContentFormulaLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *PatchContentFormulaLogic) PatchContentFormula(req *types.PatchContentFormulaHandlerReq) (*types.ContentFormulaData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentFormula.Update(l.ctx, cfdom.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + FormulaID: req.FormulaID, + Label: req.Label, + Formula: req.Formula, + PostTemplate: req.PostTemplate, + HookPattern: req.HookPattern, + Structure: req.Structure, + Tags: req.Tags, + }) + if err != nil { + return nil, err + } + data := toContentFormulaData(*item) + return &data, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/publish_mention_reply_logic.go b/backend/internal/logic/threads_account/publish_mention_reply_logic.go new file mode 100644 index 0000000..f477d24 --- /dev/null +++ b/backend/internal/logic/threads_account/publish_mention_reply_logic.go @@ -0,0 +1,46 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublishMentionReplyLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublishMentionReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishMentionReplyLogic { + return &PublishMentionReplyLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *PublishMentionReplyLogic) PublishMentionReply(req *types.PublishMentionReplyHandlerReq) (*types.PublishMentionReplyData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.PublishReply(l.ctx, mentioninboxdomain.PublishReplyRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + MediaID: req.MediaID, + Text: req.Text, + }) + if err != nil { + return nil, err + } + return &types.PublishMentionReplyData{ + MediaID: result.MediaID, + Permalink: result.Permalink, + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/run_threads_api_playground_logic.go b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go index 463d978..af9017f 100644 --- a/backend/internal/logic/threads_account/run_threads_api_playground_logic.go +++ b/backend/internal/logic/threads_account/run_threads_api_playground_logic.go @@ -27,7 +27,8 @@ func (l *RunThreadsAPIPlaygroundLogic) RunThreadsAPIPlayground( result, err := l.svcCtx.ThreadsAccount.RunAPIPlayground(l.ctx, tenantID, uid, req.ID, domusecase.APIPlaygroundRequest{ Action: body.Action, Query: body.Query, Username: body.Username, MediaID: body.MediaID, ReplyID: body.ReplyID, ReplyToID: body.ReplyToID, - Text: body.Text, LocationQuery: body.LocationQuery, Limit: body.Limit, + Text: body.Text, Permalink: body.Permalink, HintText: body.HintText, SkipPrime: body.SkipPrime, + LocationQuery: body.LocationQuery, Limit: body.Limit, Hide: body.Hide, SearchType: body.SearchType, }) if err != nil { diff --git a/backend/internal/logic/threads_account/search_content_formula_posts_logic.go b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go new file mode 100644 index 0000000..cce5c5b --- /dev/null +++ b/backend/internal/logic/threads_account/search_content_formula_posts_logic.go @@ -0,0 +1,80 @@ +package threads_account + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + libviral "haixun-backend/internal/library/viral" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type SearchContentFormulaPostsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSearchContentFormulaPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SearchContentFormulaPostsLogic { + return &SearchContentFormulaPostsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *SearchContentFormulaPostsLogic) SearchContentFormulaPosts(req *types.SearchContentFormulaPostsHandlerReq) (*types.SearchContentFormulaPostsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + keyword := strings.TrimSpace(req.Keyword) + if keyword == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("keyword is required") + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil { + return nil, err + } + token, err := l.svcCtx.ThreadsAccount.ResolveAccountAPIAccessToken(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + searchType := strings.TrimSpace(req.SearchType) + if searchType == "" { + searchType = "TOP" + } + items, err := client.KeywordSearch(l.ctx, libthreads.KeywordSearchOptions{ + Query: keyword, Limit: limit, SearchType: searchType, + }) + if err != nil { + return nil, app.For(code.ThreadsAccount).SvcThirdParty("keyword_search 失敗:" + err.Error()) + } + list := make([]types.ContentFormulaSearchPostData, 0, len(items)) + for _, item := range items { + text := strings.TrimSpace(item.Text) + if text == "" { + continue + } + author := strings.TrimSpace(item.Username) + list = append(list, types.ContentFormulaSearchPostData{ + Text: text, + Author: author, + Permalink: item.Permalink, + MediaID: item.ID, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + EngagementScore: libviral.ScorePost(item.LikeCount, item.ReplyCount), + }) + } + return &types.SearchContentFormulaPostsData{ + List: list, + Message: fmt.Sprintf("找到 %d 篇相似貼文", len(list)), + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/sync_mention_inbox_logic.go b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go new file mode 100644 index 0000000..76ae175 --- /dev/null +++ b/backend/internal/logic/threads_account/sync_mention_inbox_logic.go @@ -0,0 +1,47 @@ +package threads_account + +import ( + "context" + + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SyncMentionInboxLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSyncMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncMentionInboxLogic { + return &SyncMentionInboxLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SyncMentionInboxLogic) SyncMentionInbox(req *types.SyncMentionInboxHandlerReq) (*types.SyncMentionInboxData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.MentionInbox.SyncFromAPI(l.ctx, mentioninboxdomain.SyncRequest{ + TenantID: tenantID, + OwnerUID: uid, + AccountID: req.ID, + Limit: req.Limit, + MaxPages: req.MaxPages, + }) + if err != nil { + return nil, err + } + return &types.SyncMentionInboxData{ + Synced: result.Synced, + Ready: result.Ready, + Total: result.Total, + }, nil +} \ No newline at end of file diff --git a/backend/internal/model/brand/domain/entity/product.go b/backend/internal/model/brand/domain/entity/product.go index 573e57b..d08361b 100644 --- a/backend/internal/model/brand/domain/entity/product.go +++ b/backend/internal/model/brand/domain/entity/product.go @@ -4,6 +4,7 @@ type Product struct { ID string `bson:"id"` Label string `bson:"label"` ProductContext string `bson:"product_context"` + PlacementURL string `bson:"placement_url,omitempty"` MatchTags []string `bson:"match_tags,omitempty"` CreateAt int64 `bson:"create_at"` UpdateAt int64 `bson:"update_at"` diff --git a/backend/internal/model/brand/domain/usecase/usecase.go b/backend/internal/model/brand/domain/usecase/usecase.go index f9affa0..7217db3 100644 --- a/backend/internal/model/brand/domain/usecase/usecase.go +++ b/backend/internal/model/brand/domain/usecase/usecase.go @@ -10,6 +10,7 @@ type ProductSummary struct { ID string Label string ProductContext string + PlacementURL string MatchTags []string CreateAt int64 UpdateAt int64 @@ -89,18 +90,21 @@ type CreateProductRequest struct { BrandID string Label string ProductContext string + PlacementURL string MatchTags []string } type UpdateProductRequest struct { - TenantID string - OwnerUID string - BrandID string - ProductID string - Label *string - ProductContext *string - MatchTags []string - MatchTagsSet bool + TenantID string + OwnerUID string + BrandID string + ProductID string + Label *string + ProductContext *string + PlacementURL *string + PlacementURLSet bool + MatchTags []string + MatchTagsSet bool } type UseCase interface { diff --git a/backend/internal/model/brand/usecase/products.go b/backend/internal/model/brand/usecase/products.go index 03dc6de..6f556a5 100644 --- a/backend/internal/model/brand/usecase/products.go +++ b/backend/internal/model/brand/usecase/products.go @@ -41,6 +41,7 @@ func (u *brandUseCase) CreateProduct(ctx context.Context, req domusecase.CreateP ID: uuid.NewString(), Label: label, ProductContext: contextRaw, + PlacementURL: strings.TrimSpace(req.PlacementURL), MatchTags: normalizeMatchTags(req.MatchTags), } created, err := u.repo.PushProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, product) @@ -77,6 +78,9 @@ func (u *brandUseCase) UpdateProduct(ctx context.Context, req domusecase.UpdateP if req.MatchTagsSet { patch["products.$.match_tags"] = normalizeMatchTags(req.MatchTags) } + if req.PlacementURLSet { + patch["products.$.placement_url"] = strings.TrimSpace(ptrString(req.PlacementURL)) + } if len(patch) == 0 { return nil, app.For(code.Brand).InputMissingRequired("product patch is empty") } @@ -134,12 +138,20 @@ func toProductSummary(item entity.Product) domusecase.ProductSummary { ID: item.ID, Label: item.Label, ProductContext: item.ProductContext, + PlacementURL: item.PlacementURL, MatchTags: append([]string(nil), item.MatchTags...), CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, } } +func ptrString(v *string) string { + if v == nil { + return "" + } + return *v +} + func normalizeMatchTags(tags []string) []string { if len(tags) == 0 { return nil diff --git a/backend/internal/model/brand/usecase/products_placement_url_test.go b/backend/internal/model/brand/usecase/products_placement_url_test.go new file mode 100644 index 0000000..0183238 --- /dev/null +++ b/backend/internal/model/brand/usecase/products_placement_url_test.go @@ -0,0 +1,115 @@ +package usecase + +import ( + "context" + "testing" + + "haixun-backend/internal/model/brand/domain/entity" + domusecase "haixun-backend/internal/model/brand/domain/usecase" +) + +type memoryBrandRepo struct { + brand *entity.Brand +} + +func (m *memoryBrandRepo) Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error) { + m.brand = brand + return brand, nil +} + +func (m *memoryBrandRepo) FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) { + return m.brand, nil +} + +func (m *memoryBrandRepo) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) { + return nil, nil +} + +func (m *memoryBrandRepo) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) { + return m.brand, nil +} + +func (m *memoryBrandRepo) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error { + return nil +} + +func (m *memoryBrandRepo) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) { + m.brand.Products = append(m.brand.Products, product) + return &product, nil +} + +func (m *memoryBrandRepo) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) { + for i := range m.brand.Products { + if m.brand.Products[i].ID != productID { + continue + } + if v, ok := patch["products.$.label"].(string); ok { + m.brand.Products[i].Label = v + } + if v, ok := patch["products.$.product_context"].(string); ok { + m.brand.Products[i].ProductContext = v + } + if v, ok := patch["products.$.placement_url"].(string); ok { + m.brand.Products[i].PlacementURL = v + } + item := m.brand.Products[i] + return &item, nil + } + return nil, nil +} + +func (m *memoryBrandRepo) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error { + return nil +} + +func (m *memoryBrandRepo) EnsureIndexes(ctx context.Context) error { + return nil +} + +func TestCreateAndUpdateProductPlacementURL(t *testing.T) { + repo := &memoryBrandRepo{ + brand: &entity.Brand{ + ID: "brand-1", + TenantID: "tenant-1", + OwnerUID: "user-1", + Status: entity.StatusOpen, + }, + } + uc := NewUseCase(repo) + ctx := context.Background() + + created, err := uc.CreateProduct(ctx, domusecase.CreateProductRequest{ + TenantID: "tenant-1", + OwnerUID: "user-1", + BrandID: "brand-1", + Label: "主力商品", + ProductContext: "賣點描述", + PlacementURL: "https://example.com/product", + }) + if err != nil { + t.Fatal(err) + } + if created.PlacementURL != "https://example.com/product" { + t.Fatalf("create placement_url = %q", created.PlacementURL) + } + + label := "主力商品" + contextRaw := "賣點描述" + placementURL := "https://example.com/updated" + updated, err := uc.UpdateProduct(ctx, domusecase.UpdateProductRequest{ + TenantID: "tenant-1", + OwnerUID: "user-1", + BrandID: "brand-1", + ProductID: created.ID, + Label: &label, + ProductContext: &contextRaw, + PlacementURL: &placementURL, + PlacementURLSet: true, + }) + if err != nil { + t.Fatal(err) + } + if updated.PlacementURL != placementURL { + t.Fatalf("update placement_url = %q, want %q", updated.PlacementURL, placementURL) + } +} \ No newline at end of file diff --git a/backend/internal/model/brand/usecase/usecase.go b/backend/internal/model/brand/usecase/usecase.go index 57059f1..1b0edba 100644 --- a/backend/internal/model/brand/usecase/usecase.go +++ b/backend/internal/model/brand/usecase/usecase.go @@ -100,6 +100,9 @@ func (u *brandUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) return nil, err } patch := patchToMap(req.Patch) + if len(patch) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("brand patch is empty") + } if req.Patch.ProductID != nil { snapshot := placement.ResolveBrandProductContext(*brand, strings.TrimSpace(*req.Patch.ProductID), "") patch["product_context"] = snapshot diff --git a/backend/internal/model/content_formula/domain/entity/formula.go b/backend/internal/model/content_formula/domain/entity/formula.go new file mode 100644 index 0000000..95cd5c5 --- /dev/null +++ b/backend/internal/model/content_formula/domain/entity/formula.go @@ -0,0 +1,45 @@ +package entity + +const CollectionName = "content_formulas" + +const ( + SourcePaste = "paste" + SourceOwnPost = "own_post" + SourceScanPost = "scan_post" + SourceKeywordSearch = "keyword_search" +) + +type SourceMetrics struct { + LikeCount int `bson:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty"` + Views int `bson:"views,omitempty"` + RepostCount int `bson:"repost_count,omitempty"` + QuoteCount int `bson:"quote_count,omitempty"` + Shares int `bson:"shares,omitempty"` +} + +type ContentFormula struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + Label string `bson:"label"` + SourceType string `bson:"source_type"` + SourceRef string `bson:"source_ref,omitempty"` + SourcePostText string `bson:"source_post_text,omitempty"` + SourceAuthor string `bson:"source_author,omitempty"` + SourcePermalink string `bson:"source_permalink,omitempty"` + SourceMetrics *SourceMetrics `bson:"source_metrics,omitempty"` + Summary string `bson:"summary"` + Wins []string `bson:"wins,omitempty"` + Improvements []string `bson:"improvements,omitempty"` + Formula string `bson:"formula"` + PostTemplate string `bson:"post_template"` + HookPattern string `bson:"hook_pattern"` + Structure string `bson:"structure"` + ReplicationTips []string `bson:"replication_tips,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + Tags []string `bson:"tags,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} \ No newline at end of file diff --git a/backend/internal/model/content_formula/domain/repository/repository.go b/backend/internal/model/content_formula/domain/repository/repository.go new file mode 100644 index 0000000..854927f --- /dev/null +++ b/backend/internal/model/content_formula/domain/repository/repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/content_formula/domain/entity" +) + +type ListFilter struct { + TenantID string + OwnerUID string + AccountID string + SourceType string + Tag string + Page int + PageSize int +} + +type ListResult struct { + Items []entity.ContentFormula + Total int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, formula *entity.ContentFormula) error + Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) + Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error + List(ctx context.Context, filter ListFilter) (*ListResult, error) +} \ No newline at end of file diff --git a/backend/internal/model/content_formula/domain/usecase/usecase.go b/backend/internal/model/content_formula/domain/usecase/usecase.go new file mode 100644 index 0000000..a782603 --- /dev/null +++ b/backend/internal/model/content_formula/domain/usecase/usecase.go @@ -0,0 +1,90 @@ +package usecase + +import ( + "context" + + "haixun-backend/internal/model/content_formula/domain/entity" +) + +type FormulaSummary struct { + ID string + AccountID string + Label string + SourceType string + SourceRef string + SourcePostText string + SourceAuthor string + SourcePermalink string + SourceMetrics *entity.SourceMetrics + Summary string + Wins []string + Improvements []string + Formula string + PostTemplate string + HookPattern string + Structure string + ReplicationTips []string + Avoid []string + Tags []string + CreateAt int64 + UpdateAt int64 +} + +type CreateRequest struct { + TenantID string + OwnerUID string + AccountID string + Label string + SourceType string + SourceRef string + SourcePostText string + SourceAuthor string + SourcePermalink string + SourceMetrics *entity.SourceMetrics + Summary string + Wins []string + Improvements []string + Formula string + PostTemplate string + HookPattern string + Structure string + ReplicationTips []string + Avoid []string + Tags []string +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + AccountID string + FormulaID string + Label *string + Formula *string + PostTemplate *string + HookPattern *string + Structure *string + Tags []string +} + +type ListRequest struct { + TenantID string + OwnerUID string + AccountID string + SourceType string + Tag string + Page int + PageSize int +} + +type ListResult struct { + Items []FormulaSummary + Total int64 +} + +type UseCase interface { + Create(ctx context.Context, req CreateRequest) (*FormulaSummary, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*FormulaSummary, error) + Update(ctx context.Context, req UpdateRequest) (*FormulaSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error + List(ctx context.Context, req ListRequest) (*ListResult, error) +} \ No newline at end of file diff --git a/backend/internal/model/content_formula/repository/mongo.go b/backend/internal/model/content_formula/repository/mongo.go new file mode 100644 index 0000000..db36c74 --- /dev/null +++ b/backend/internal/model/content_formula/repository/mongo.go @@ -0,0 +1,165 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_formula/domain/entity" + domrepo "haixun-backend/internal/model/content_formula/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "update_at", Value: -1}, + }, + }, + }) + return err +} + +func actorFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + } +} + +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func (r *mongoRepository) Create(ctx context.Context, formula *entity.ContentFormula) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if formula == nil { + return app.For(code.ThreadsAccount).InputMissingRequired("formula is required") + } + _, err := r.collection.InsertOne(ctx, formula) + return err +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + formulaID = strings.TrimSpace(formulaID) + if formulaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula_id is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = formulaID + var out entity.ContentFormula + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("patch is required") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = strings.TrimSpace(formulaID) + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.ContentFormula + err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := actorFilter(tenantID, ownerUID, accountID) + filter["_id"] = strings.TrimSpace(formulaID) + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("content formula not found") + } + return nil +} + +func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter) (*domrepo.ListResult, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + page, pageSize := normalizePage(filter.Page, filter.PageSize) + mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID) + if sourceType := strings.TrimSpace(filter.SourceType); sourceType != "" { + mongoFilter["source_type"] = sourceType + } + if tag := strings.TrimSpace(filter.Tag); tag != "" { + mongoFilter["tags"] = tag + } + total, err := r.collection.CountDocuments(ctx, mongoFilter) + if err != nil { + return nil, err + } + opts := options.Find(). + SetSort(bson.D{{Key: "update_at", Value: -1}}). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, mongoFilter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []entity.ContentFormula + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return &domrepo.ListResult{Items: items, Total: total}, nil +} \ No newline at end of file diff --git a/backend/internal/model/content_formula/usecase/usecase.go b/backend/internal/model/content_formula/usecase/usecase.go new file mode 100644 index 0000000..2236ca5 --- /dev/null +++ b/backend/internal/model/content_formula/usecase/usecase.go @@ -0,0 +1,184 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_formula/domain/entity" + domrepo "haixun-backend/internal/model/content_formula/domain/repository" + domusecase "haixun-backend/internal/model/content_formula/domain/usecase" + + "github.com/google/uuid" +) + +type useCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { + return &useCase{repo: repo} +} + +func (u *useCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.FormulaSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + label := strings.TrimSpace(req.Label) + if label == "" { + label = "未命名公式" + } + sourceType := strings.TrimSpace(req.SourceType) + if sourceType == "" { + sourceType = entity.SourcePaste + } + formulaText := strings.TrimSpace(req.Formula) + if formulaText == "" && strings.TrimSpace(req.PostTemplate) == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula or post_template is required") + } + now := clock.NowUnixNano() + item := &entity.ContentFormula{ + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + AccountID: req.AccountID, + Label: label, + SourceType: sourceType, + SourceRef: strings.TrimSpace(req.SourceRef), + SourcePostText: strings.TrimSpace(req.SourcePostText), + SourceAuthor: strings.TrimSpace(req.SourceAuthor), + SourcePermalink: strings.TrimSpace(req.SourcePermalink), + SourceMetrics: req.SourceMetrics, + Summary: strings.TrimSpace(req.Summary), + Wins: req.Wins, + Improvements: req.Improvements, + Formula: formulaText, + PostTemplate: strings.TrimSpace(req.PostTemplate), + HookPattern: strings.TrimSpace(req.HookPattern), + Structure: strings.TrimSpace(req.Structure), + ReplicationTips: req.ReplicationTips, + Avoid: req.Avoid, + Tags: req.Tags, + CreateAt: now, + UpdateAt: now, + } + if err := u.repo.Create(ctx, item); err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*domusecase.FormulaSummary, error) { + if err := requireActor(tenantID, ownerUID, accountID); err != nil { + return nil, err + } + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, formulaID) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.FormulaSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + patch := map[string]interface{}{"update_at": clock.NowUnixNano()} + if req.Label != nil { + patch["label"] = strings.TrimSpace(*req.Label) + } + if req.Formula != nil { + patch["formula"] = strings.TrimSpace(*req.Formula) + } + if req.PostTemplate != nil { + patch["post_template"] = strings.TrimSpace(*req.PostTemplate) + } + if req.HookPattern != nil { + patch["hook_pattern"] = strings.TrimSpace(*req.HookPattern) + } + if req.Structure != nil { + patch["structure"] = strings.TrimSpace(*req.Structure) + } + if req.Tags != nil { + patch["tags"] = req.Tags + } + if len(patch) == 1 { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("no fields to update") + } + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.FormulaID, patch) + if err != nil { + return nil, err + } + summary := toSummary(*item) + return &summary, nil +} + +func (u *useCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error { + if err := requireActor(tenantID, ownerUID, accountID); err != nil { + return err + } + return u.repo.Delete(ctx, tenantID, ownerUID, accountID, formulaID) +} + +func (u *useCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil { + return nil, err + } + result, err := u.repo.List(ctx, domrepo.ListFilter{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + AccountID: req.AccountID, + SourceType: req.SourceType, + Tag: req.Tag, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + out := make([]domusecase.FormulaSummary, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, toSummary(item)) + } + return &domusecase.ListResult{Items: out, Total: result.Total}, nil +} + +func requireActor(tenantID, ownerUID, accountID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + if strings.TrimSpace(accountID) == "" { + return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + return nil +} + +func toSummary(item entity.ContentFormula) domusecase.FormulaSummary { + return domusecase.FormulaSummary{ + ID: item.ID, + AccountID: item.AccountID, + Label: item.Label, + SourceType: item.SourceType, + SourceRef: item.SourceRef, + SourcePostText: item.SourcePostText, + SourceAuthor: item.SourceAuthor, + SourcePermalink: item.SourcePermalink, + SourceMetrics: item.SourceMetrics, + Summary: item.Summary, + Wins: item.Wins, + Improvements: item.Improvements, + Formula: item.Formula, + PostTemplate: item.PostTemplate, + HookPattern: item.HookPattern, + Structure: item.Structure, + ReplicationTips: item.ReplicationTips, + Avoid: item.Avoid, + Tags: item.Tags, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} \ No newline at end of file diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index a192603..71d3395 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -6,6 +6,7 @@ const ( DraftTypeViralReplica = "viral-replica" DraftTypeReplicate = "replicate" DraftTypeMatrix = "matrix" + DraftTypeFormula = "formula" ) type CopyDraft struct { @@ -15,6 +16,7 @@ type CopyDraft struct { PersonaID string `bson:"persona_id"` CopyMissionID string `bson:"copy_mission_id,omitempty"` ScanPostID string `bson:"scan_post_id,omitempty"` + FormulaID string `bson:"formula_id,omitempty"` DraftType string `bson:"draft_type"` MatrixBatchID string `bson:"matrix_batch_id,omitempty"` SortOrder int `bson:"sort_order,omitempty"` diff --git a/backend/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go index 2dc27af..031ad7c 100644 --- a/backend/internal/model/copy_draft/domain/repository/repository.go +++ b/backend/internal/model/copy_draft/domain/repository/repository.go @@ -6,6 +6,22 @@ import ( "haixun-backend/internal/model/copy_draft/domain/entity" ) +type InboxFilter struct { + TenantID string + OwnerUID string + PersonaID string + Status string + RangeStart int64 + RangeEnd int64 + Page int + PageSize int +} + +type InboxListResult struct { + Items []entity.CopyDraft + Total int64 +} + type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, draft *entity.CopyDraft) error @@ -17,6 +33,7 @@ type Repository interface { DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) + ListInbox(ctx context.Context, filter InboxFilter) (*InboxListResult, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) ReplaceMissionMatrix( ctx context.Context, diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index 2aac7e9..c9a74f7 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -9,6 +9,7 @@ type CopyDraftSummary struct { PersonaID string CopyMissionID string ScanPostID string + FormulaID string DraftType string SortOrder int Text string @@ -48,6 +49,7 @@ type CreateRequest struct { PersonaID string CopyMissionID string ScanPostID string + FormulaID string DraftType string SortOrder int Text string @@ -80,6 +82,22 @@ type UpdateRequest struct { Patch CopyDraftPatch } +type InboxListRequest struct { + TenantID string + OwnerUID string + PersonaID string + Status string + RangeStart int64 + RangeEnd int64 + Page int + PageSize int +} + +type InboxListResult struct { + Items []CopyDraftSummary + Total int64 +} + type DeleteMissionMatrixDraftsRequest struct { TenantID string OwnerUID string @@ -97,6 +115,7 @@ type UseCase interface { MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error) MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error) + ListInbox(ctx context.Context, req InboxListRequest) (*InboxListResult, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error DeleteMissionMatrixDrafts(ctx context.Context, req DeleteMissionMatrixDraftsRequest) (int, error) diff --git a/backend/internal/model/copy_draft/repository/mongo.go b/backend/internal/model/copy_draft/repository/mongo.go index 24929e5..59d2f5d 100644 --- a/backend/internal/model/copy_draft/repository/mongo.go +++ b/backend/internal/model/copy_draft/repository/mongo.go @@ -92,6 +92,97 @@ func (r *mongoRepository) Create(ctx context.Context, draft *entity.CopyDraft) e return err } +func inboxStatusFilter(status string) bson.M { + switch strings.TrimSpace(status) { + case "draft": + return bson.M{ + "$and": []bson.M{ + {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}}, + {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}}, + }, + } + case "scheduled": + return bson.M{ + "publish_queue_id": bson.M{"$ne": ""}, + "$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}, + } + case "published": + return bson.M{"published_at": bson.M{"$gt": 0}} + default: + return bson.M{} + } +} + +func normalizeInboxPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 12 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func (r *mongoRepository) ListInbox(ctx context.Context, filter domrepo.InboxFilter) (*domrepo.InboxListResult, error) { + if r.collection == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + page, pageSize := normalizeInboxPage(filter.Page, filter.PageSize) + mongoFilter := personaFilter(filter.TenantID, filter.OwnerUID, filter.PersonaID) + if extra := inboxStatusFilter(filter.Status); len(extra) > 0 { + for k, v := range extra { + mongoFilter[k] = v + } + } + if filter.RangeStart > 0 || filter.RangeEnd > 0 { + rangeClauses := make([]bson.M, 0, 3) + createRange := bson.M{} + if filter.RangeStart > 0 { + createRange["$gte"] = filter.RangeStart + } + if filter.RangeEnd > 0 { + createRange["$lte"] = filter.RangeEnd + } + if len(createRange) > 0 { + rangeClauses = append(rangeClauses, bson.M{"create_at": createRange}) + } + publishedRange := bson.M{} + if filter.RangeStart > 0 { + publishedRange["$gte"] = filter.RangeStart + } + if filter.RangeEnd > 0 { + publishedRange["$lte"] = filter.RangeEnd + } + if len(publishedRange) > 0 { + rangeClauses = append(rangeClauses, bson.M{"published_at": publishedRange}) + } + if len(rangeClauses) > 0 { + mongoFilter["$or"] = rangeClauses + } + } + total, err := r.collection.CountDocuments(ctx, mongoFilter) + if err != nil { + return nil, err + } + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: -1}}). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, mongoFilter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.CopyDraft + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return &domrepo.InboxListResult{Items: out, Total: total}, nil +} + func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) { if r.collection == nil { return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/copy_draft/usecase/usecase.go b/backend/internal/model/copy_draft/usecase/usecase.go index e376d71..a3097fc 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -44,6 +44,7 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ PersonaID: req.PersonaID, CopyMissionID: strings.TrimSpace(req.CopyMissionID), ScanPostID: strings.TrimSpace(req.ScanPostID), + FormulaID: strings.TrimSpace(req.FormulaID), DraftType: draftType, SortOrder: req.SortOrder, Text: text, @@ -384,6 +385,30 @@ func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, persona return out, nil } +func (u *copyDraftUseCase) ListInbox(ctx context.Context, req domusecase.InboxListRequest) (*domusecase.InboxListResult, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + result, err := u.repo.ListInbox(ctx, domrepo.InboxFilter{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + Status: req.Status, + RangeStart: req.RangeStart, + RangeEnd: req.RangeEnd, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + return nil, err + } + out := make([]domusecase.CopyDraftSummary, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, toSummary(item)) + } + return &domusecase.InboxListResult{Items: out, Total: result.Total}, nil +} + func requireActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Auth).AuthUnauthorized("missing actor") @@ -400,6 +425,7 @@ func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary { PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, DraftType: item.DraftType, SortOrder: item.SortOrder, Text: item.Text, diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 739647b..6d5dd5d 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -100,6 +100,7 @@ type UseCase interface { EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error + EnsureGenerateOutreachDraftTemplate(ctx context.Context) error EnsureRefreshThreadsTokenTemplate(ctx context.Context) error EnsurePublishAnalyticsTemplate(ctx context.Context) error EnsureRefillPublishInventoryTemplate(ctx context.Context) error diff --git a/backend/internal/model/job/usecase/dedupe_test.go b/backend/internal/model/job/usecase/dedupe_test.go index b43c6a4..8c4162c 100644 --- a/backend/internal/model/job/usecase/dedupe_test.go +++ b/backend/internal/model/job/usecase/dedupe_test.go @@ -72,6 +72,18 @@ func TestCreateRun_DedupeLeaseBlocksParallel(t *testing.T) { } } +func TestBuildDedupeKey_IncludesScanPostID(t *testing.T) { + template := &entity.Template{ + Type: "generate-outreach-draft", + DedupeKeys: []string{"scan_post_id"}, + } + key1 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-1"}) + key2 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-2"}) + if key1 == "" || key2 == "" || key1 == key2 { + t.Fatalf("dedupe keys should differ by scan_post_id: %q vs %q", key1, key2) + } +} + func TestBuildDedupeKey_IncludesScopeID(t *testing.T) { template := &entity.Template{ Type: "demo", diff --git a/backend/internal/model/job/usecase/helpers.go b/backend/internal/model/job/usecase/helpers.go index 7a35576..222e8be 100644 --- a/backend/internal/model/job/usecase/helpers.go +++ b/backend/internal/model/job/usecase/helpers.go @@ -47,17 +47,13 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma switch key { case "scope_id": parts = append(parts, scopeID) - case "target": - if payload != nil { - if target, ok := payload["target"].(string); ok { - parts = append(parts, target) - } + default: + if payload == nil { + continue } - case "benchmark_username": - if payload != nil { - if username, ok := payload["benchmark_username"].(string); ok { - parts = append(parts, username) - } + value := dedupePayloadValue(payload, key) + if value != "" { + parts = append(parts, value) } } } @@ -66,6 +62,24 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma return hex.EncodeToString(sum[:]) } +func dedupePayloadValue(payload map[string]any, key string) string { + if payload == nil || key == "" { + return "" + } + if value := stringFromAny(payload[key]); value != "" { + return value + } + // Legacy aliases used by older payloads. + switch key { + case "target": + return stringFromAny(payload["target"]) + case "benchmark_username": + return stringFromAny(payload["benchmark_username"]) + default: + return "" + } +} + func firstPhaseFromTemplateSteps(steps []entity.StepProgress) string { if len(steps) == 0 { return "" diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 73af24e..086f9c0 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -23,6 +23,7 @@ const ( expandCopyMissionGraphTemplateType = "expand-copy-mission-graph" generateCopyMatrixTemplateType = "generate-copy-matrix" generateCopyDraftTemplateType = "generate-copy-draft" + generateOutreachDraftTemplateType = "generate-outreach-draft" refreshThreadsTokenTemplateType = "refresh-threads-token" publishAnalyticsTemplateType = "publish-analytics" refillPublishInventoryTemplateType = "refill-publish-inventory" @@ -108,6 +109,11 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error return err } +func (u *jobUseCase) EnsureGenerateOutreachDraftTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, generateOutreachDraftTemplate()) + return err +} + func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error { _, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate()) return err @@ -201,6 +207,32 @@ func generateCopyMatrixTemplate() *entity.Template { } } +func generateOutreachDraftTemplate() *entity.Template { + return &entity.Template{ + Type: generateOutreachDraftTemplateType, + Version: 1, + Name: "Generate Outreach Draft", + Description: "LLM outreach reply drafts for a placement scan post", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel), + DedupeKeys: []string{"scan_post_id"}, + TimeoutSeconds: 600, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "outreach_draft_generate", Name: "Outreach draft generate", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + }, + } +} + func generateCopyDraftTemplate() *entity.Template { return &entity.Template{ Type: generateCopyDraftTemplateType, diff --git a/backend/internal/model/knowledge_graph/usecase/topic_graph.go b/backend/internal/model/knowledge_graph/usecase/topic_graph.go new file mode 100644 index 0000000..d28fb4e --- /dev/null +++ b/backend/internal/model/knowledge_graph/usecase/topic_graph.go @@ -0,0 +1,57 @@ +package usecase + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/knowledge_graph/domain/entity" +) + +func isGraphNotFound(err error) bool { + if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound { + return true + } + return false +} + +// loadTopicGraph resolves a placement-topic graph, including legacy brand-scoped graphs. +func (u *knowledgeGraphUseCase) loadTopicGraph(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*entity.Graph, error) { + topicID = strings.TrimSpace(topicID) + if topicID == "" { + return nil, app.For(code.Brand).InputMissingRequired("topic id is required") + } + item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) + if err != nil && !isGraphNotFound(err) { + return nil, err + } + if item != nil { + return item, nil + } + brandID = strings.TrimSpace(brandID) + if brandID == "" { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) + if legacyErr != nil { + if isGraphNotFound(legacyErr) { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + return nil, legacyErr + } + if legacy == nil { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + legacyTopicID := strings.TrimSpace(legacy.TopicID) + if legacyTopicID != "" && legacyTopicID != topicID { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if legacyTopicID == "" { + if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil { + return nil, err + } + legacy.TopicID = topicID + } + return legacy, nil +} \ No newline at end of file diff --git a/backend/internal/model/knowledge_graph/usecase/usecase.go b/backend/internal/model/knowledge_graph/usecase/usecase.go index 5680b2e..32063d7 100644 --- a/backend/internal/model/knowledge_graph/usecase/usecase.go +++ b/backend/internal/model/knowledge_graph/usecase/usecase.go @@ -49,31 +49,10 @@ func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerU if err := requireScope(tenantID, ownerUID, "", topicID, ""); err != nil { return nil, err } - item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) + item, err := u.loadTopicGraph(ctx, tenantID, ownerUID, topicID, brandID) if err != nil { return nil, err } - if item == nil { - brandID = strings.TrimSpace(brandID) - if brandID != "" { - legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) - if legacyErr != nil { - return nil, legacyErr - } - if legacy != nil { - legacyTopicID := strings.TrimSpace(legacy.TopicID) - if legacyTopicID == "" || legacyTopicID == topicID { - if legacyTopicID == "" { - if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil { - return nil, err - } - legacy.TopicID = topicID - } - item = legacy - } - } - } - } summary := toSummary(item) return &summary, nil } @@ -140,7 +119,7 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase. case copyMissionID != "": current, err = u.repo.FindByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID) case topicID != "": - current, err = u.repo.FindByTopic(ctx, req.TenantID, req.OwnerUID, topicID) + current, err = u.loadTopicGraph(ctx, req.TenantID, req.OwnerUID, topicID, brandID) default: current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID) } diff --git a/backend/internal/model/mention_inbox/domain/entity/mention.go b/backend/internal/model/mention_inbox/domain/entity/mention.go new file mode 100644 index 0000000..a69eb10 --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/entity/mention.go @@ -0,0 +1,32 @@ +package entity + +const CollectionName = "threads_mention_inbox" + +type Mention struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + MediaID string `bson:"media_id"` + AuthorUsername string `bson:"author_username"` + Text string `bson:"text"` + Permalink string `bson:"permalink"` + Shortcode string `bson:"shortcode,omitempty"` + Timestamp string `bson:"timestamp,omitempty"` + MediaType string `bson:"media_type,omitempty"` + IsReply bool `bson:"is_reply"` + IsQuotePost bool `bson:"is_quote_post"` + HasReplies bool `bson:"has_replies"` + RootPostID string `bson:"root_post_id,omitempty"` + ParentID string `bson:"parent_id,omitempty"` + ThreadPostText string `bson:"thread_post_text,omitempty"` + ParentReplyText string `bson:"parent_reply_text,omitempty"` + ReplyReady bool `bson:"reply_ready"` + ReplyReadyMsg string `bson:"reply_ready_msg,omitempty"` + SyncedAt int64 `bson:"synced_at"` + UpdateAt int64 `bson:"update_at"` +} + +func DocID(accountID, mediaID string) string { + return accountID + ":" + mediaID +} \ No newline at end of file diff --git a/backend/internal/model/mention_inbox/domain/repository/repository.go b/backend/internal/model/mention_inbox/domain/repository/repository.go new file mode 100644 index 0000000..e01ca1a --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/repository/repository.go @@ -0,0 +1,23 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/mention_inbox/domain/entity" +) + +type ListResult struct { + Items []entity.Mention + Total int64 + ReadyTotal int64 + NewestSyncedAt int64 +} + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) + CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) + CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error) +} \ No newline at end of file diff --git a/backend/internal/model/mention_inbox/domain/usecase/usecase.go b/backend/internal/model/mention_inbox/domain/usecase/usecase.go new file mode 100644 index 0000000..96c9dc1 --- /dev/null +++ b/backend/internal/model/mention_inbox/domain/usecase/usecase.go @@ -0,0 +1,78 @@ +package usecase + +import "context" + +type MentionSummary struct { + MediaID string + AuthorUsername string + Text string + Permalink string + Shortcode string + Timestamp string + MediaType string + IsReply bool + IsQuotePost bool + HasReplies bool + RootPostID string + ParentID string + ThreadPostText string + ParentReplyText string + ReplyReady bool + ReplyReadyMsg string + SyncedAt int64 +} + +type Pagination struct { + Total int64 + ReadyTotal int64 + Page int64 + PageSize int64 + TotalPages int64 + NewestSyncedAt int64 +} + +type SyncRequest struct { + TenantID string + OwnerUID string + AccountID string + Limit int + MaxPages int +} + +type SyncResult struct { + Synced int + Ready int + Total int64 +} + +type ListRequest struct { + TenantID string + OwnerUID string + AccountID string + Page int + PageSize int +} + +type ListResult struct { + List []MentionSummary + Pagination Pagination +} + +type PublishReplyRequest struct { + TenantID string + OwnerUID string + AccountID string + MediaID string + Text string +} + +type PublishReplyResult struct { + MediaID string + Permalink string +} + +type UseCase interface { + SyncFromAPI(ctx context.Context, req SyncRequest) (*SyncResult, error) + List(ctx context.Context, req ListRequest) (*ListResult, error) + PublishReply(ctx context.Context, req PublishReplyRequest) (*PublishReplyResult, error) +} \ No newline at end of file diff --git a/backend/internal/model/mention_inbox/repository/mongo.go b/backend/internal/model/mention_inbox/repository/mongo.go new file mode 100644 index 0000000..5015626 --- /dev/null +++ b/backend/internal/model/mention_inbox/repository/mongo.go @@ -0,0 +1,184 @@ +package repository + +import ( + "context" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/mention_inbox/domain/entity" + domrepo "haixun-backend/internal/model/mention_inbox/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "media_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "synced_at", Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "timestamp", Value: -1}, + }, + }, + }) + return err +} + +func (r *mongoRepository) Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if item == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("mention is required") + } + filter := actorMediaFilter(item.TenantID, item.OwnerUID, item.AccountID, item.MediaID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, item, opts); err != nil { + return nil, err + } + return item, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Mention + err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("mention inbox item not found") + } + return nil, err + } + return &out, nil +} + +func normalizeMentionPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func accountFilter(tenantID, ownerUID, accountID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "account_id": accountID, + } +} + +func (r *mongoRepository) CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + return r.collection.CountDocuments(ctx, accountFilter(tenantID, ownerUID, accountID)) +} + +func (r *mongoRepository) CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) { + if r.collection == nil { + return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + filter := accountFilter(tenantID, ownerUID, accountID) + filter["reply_ready"] = true + return r.collection.CountDocuments(ctx, filter) +} + +func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domrepo.ListResult, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + page, pageSize = normalizeMentionPage(page, pageSize) + filter := accountFilter(tenantID, ownerUID, accountID) + total, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + return nil, err + } + sort := bson.D{{Key: "timestamp", Value: -1}, {Key: "synced_at", Value: -1}} + opts := options.Find(). + SetSort(sort). + SetSkip(int64((page - 1) * pageSize)). + SetLimit(int64(pageSize)) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + out := make([]entity.Mention, 0, pageSize) + for cur.Next(ctx) { + var item entity.Mention + if err := cur.Decode(&item); err != nil { + return nil, err + } + out = append(out, item) + } + if err := cur.Err(); err != nil { + return nil, err + } + var newest entity.Mention + newestErr := r.collection.FindOne(ctx, filter, options.FindOne().SetSort(bson.D{{Key: "synced_at", Value: -1}})).Decode(&newest) + newestSyncedAt := int64(0) + if newestErr == nil { + newestSyncedAt = newest.SyncedAt + } + readyTotal, err := r.CountReplyReadyByAccount(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + return &domrepo.ListResult{ + Items: out, + Total: total, + ReadyTotal: readyTotal, + NewestSyncedAt: newestSyncedAt, + }, nil +} + +func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "account_id": accountID, + "media_id": mediaID, + } +} \ No newline at end of file diff --git a/backend/internal/model/mention_inbox/usecase/usecase.go b/backend/internal/model/mention_inbox/usecase/usecase.go new file mode 100644 index 0000000..9535817 --- /dev/null +++ b/backend/internal/model/mention_inbox/usecase/usecase.go @@ -0,0 +1,347 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libthreads "haixun-backend/internal/library/threadsapi" + "haixun-backend/internal/model/mention_inbox/domain/entity" + domrepo "haixun-backend/internal/model/mention_inbox/domain/repository" + domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type mentionInboxUseCase struct { + repo domrepo.Repository + threadsAccounts threadsaccountdomain.UseCase +} + +func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase { + return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts} +} + +func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + + account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + client := libthreads.NewClient(token) + if !client.Enabled() { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") + } + + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" { + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + userID = profile.ID.String() + } + } + if userID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") + } + + perPage := req.Limit + if perPage <= 0 { + perPage = 25 + } + if perPage > 100 { + perPage = 100 + } + maxPages := req.MaxPages + if maxPages <= 0 { + maxPages = 4 + } + if maxPages > 20 { + maxPages = 20 + } + + now := clock.NowUnixNano() + synced := 0 + ready := 0 + after := "" + for page := 0; page < maxPages; page++ { + mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after) + if err != nil { + return nil, err + } + for _, hit := range mentions { + item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now) + u.fillContext(ctx, client, hit, item) + u.primeReplyTarget(ctx, client, hit, item) + if _, err := u.repo.Upsert(ctx, item); err != nil { + return nil, err + } + synced++ + if item.ReplyReady { + ready++ + } + } + if nextAfter == "" || len(mentions) == 0 { + break + } + after = nextAfter + } + + total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + + return &domusecase.SyncResult{ + Synced: synced, + Ready: ready, + Total: total, + }, nil +} + +func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + if _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil { + return nil, err + } + result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize) + if err != nil { + return nil, err + } + page, pageSize := normalizeListPage(req.Page, req.PageSize) + return &domusecase.ListResult{ + List: toSummaries(result.Items), + Pagination: domusecase.Pagination{ + Total: result.Total, + ReadyTotal: result.ReadyTotal, + Page: int64(page), + PageSize: int64(pageSize), + TotalPages: mentionTotalPages(result.Total, pageSize), + NewestSyncedAt: result.NewestSyncedAt, + }, + }, nil +} + +func normalizeListPage(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} + +func mentionTotalPages(total int64, pageSize int) int64 { + if total <= 0 { + return 0 + } + return (total + int64(pageSize) - 1) / int64(pageSize) +} + +func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) { + tenantID := strings.TrimSpace(req.TenantID) + ownerUID := strings.TrimSpace(req.OwnerUID) + accountID := strings.TrimSpace(req.AccountID) + mediaID := strings.TrimSpace(req.MediaID) + text := strings.TrimSpace(req.Text) + if tenantID == "" || ownerUID == "" { + return nil, app.For(code.Auth).AuthUnauthorized("missing actor") + } + if accountID == "" || mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") + } + if text == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("text is required") + } + + item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID) + if err != nil { + return nil, err + } + if !item.ReplyReady { + msg := strings.TrimSpace(item.ReplyReadyMsg) + if msg == "" { + msg = "此提及尚未完成回覆註冊,請先重新同步提及清單" + } + return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg) + } + + account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + userID := strings.TrimSpace(account.ThreadsUserID) + if userID == "" { + if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { + userID = profile.ID.String() + } + } + if userID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id") + } + + result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ + ThreadsUserID: userID, + AccessToken: token, + ReplyToID: mediaID, + Text: text, + }) + if err != nil { + return nil, err + } + out := &domusecase.PublishReplyResult{} + if result != nil { + out.MediaID = strings.TrimSpace(result.MediaID) + out.Permalink = strings.TrimSpace(result.Permalink) + } + return out, nil +} + +func (u *mentionInboxUseCase) buildMentionEntity( + tenantID, ownerUID, accountID string, + hit libthreads.MentionItem, + now int64, +) *entity.Mention { + mediaID := strings.TrimSpace(hit.ID) + return &entity.Mention{ + ID: entity.DocID(accountID, mediaID), + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: accountID, + MediaID: mediaID, + AuthorUsername: strings.TrimSpace(hit.Username), + Text: strings.TrimSpace(hit.Text), + Permalink: strings.TrimSpace(hit.Permalink), + Shortcode: shortcodeFromPermalink(hit.Permalink), + Timestamp: strings.TrimSpace(hit.Timestamp), + MediaType: strings.TrimSpace(hit.MediaType), + IsReply: hit.IsReply, + IsQuotePost: hit.IsQuotePost, + HasReplies: hit.HasReplies, + RootPostID: strings.TrimSpace(hit.RootPostID), + ParentID: strings.TrimSpace(hit.ParentID), + SyncedAt: now, + UpdateAt: now, + } +} + +func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { + mentionText := strings.TrimSpace(hit.Text) + if !hit.IsReply && !hit.IsQuotePost { + item.ThreadPostText = mentionText + return + } + + rootID := strings.TrimSpace(hit.RootPostID) + if rootID == "" { + rootID = strings.TrimSpace(hit.ID) + } + if rootID != "" { + if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil { + item.ThreadPostText = strings.TrimSpace(detail.Text) + } + } + if item.ThreadPostText == "" { + item.ThreadPostText = mentionText + } + + parentID := strings.TrimSpace(hit.ParentID) + mediaID := strings.TrimSpace(hit.ID) + if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID { + if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil { + item.ParentReplyText = strings.TrimSpace(detail.Text) + } + } +} + +func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) { + permalink := strings.TrimSpace(hit.Permalink) + mediaID := strings.TrimSpace(hit.ID) + if permalink == "" { + item.ReplyReady = false + item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標" + return + } + if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: mediaID, + Permalink: permalink, + HintText: strings.TrimSpace(hit.Text), + }, mediaID); err != nil { + item.ReplyReady = false + item.ReplyReadyMsg = err.Error() + return + } + item.ReplyReady = true + item.ReplyReadyMsg = "" +} + +func shortcodeFromPermalink(permalink string) string { + permalink = strings.Split(strings.TrimSpace(permalink), "?")[0] + permalink = strings.Split(permalink, "#")[0] + parts := strings.Split(permalink, "/post/") + if len(parts) < 2 { + return "" + } + return strings.TrimSpace(parts[len(parts)-1]) +} + +func toSummary(item *entity.Mention) domusecase.MentionSummary { + if item == nil { + return domusecase.MentionSummary{} + } + return domusecase.MentionSummary{ + MediaID: item.MediaID, + AuthorUsername: item.AuthorUsername, + Text: item.Text, + Permalink: item.Permalink, + Shortcode: item.Shortcode, + Timestamp: item.Timestamp, + MediaType: item.MediaType, + IsReply: item.IsReply, + IsQuotePost: item.IsQuotePost, + HasReplies: item.HasReplies, + RootPostID: item.RootPostID, + ParentID: item.ParentID, + ThreadPostText: item.ThreadPostText, + ParentReplyText: item.ParentReplyText, + ReplyReady: item.ReplyReady, + ReplyReadyMsg: item.ReplyReadyMsg, + SyncedAt: item.SyncedAt, + } +} + +func toSummaries(items []entity.Mention) []domusecase.MentionSummary { + out := make([]domusecase.MentionSummary, 0, len(items)) + for i := range items { + out = append(out, toSummary(&items[i])) + } + return out +} \ No newline at end of file diff --git a/backend/internal/model/outreach_draft/domain/repository/repository.go b/backend/internal/model/outreach_draft/domain/repository/repository.go index 1a64c56..2023a3c 100644 --- a/backend/internal/model/outreach_draft/domain/repository/repository.go +++ b/backend/internal/model/outreach_draft/domain/repository/repository.go @@ -9,5 +9,7 @@ import ( type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, draft *entity.OutreachDraft) error + GetByID(ctx context.Context, tenantID, ownerUID, brandID, draftID string) (*entity.OutreachDraft, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*entity.OutreachDraft, error) + UpdateDrafts(ctx context.Context, tenantID, ownerUID, brandID, draftID string, drafts []entity.DraftItem) (*entity.OutreachDraft, error) } diff --git a/backend/internal/model/outreach_draft/domain/usecase/usecase.go b/backend/internal/model/outreach_draft/domain/usecase/usecase.go index 2892847..846eb85 100644 --- a/backend/internal/model/outreach_draft/domain/usecase/usecase.go +++ b/backend/internal/model/outreach_draft/domain/usecase/usecase.go @@ -31,7 +31,17 @@ type CreateRequest struct { Drafts []DraftItem } +type UpdateDraftItemRequest struct { + TenantID string + OwnerUID string + BrandID string + DraftID string + DraftIndex int + Text string +} + type UseCase interface { Create(ctx context.Context, req CreateRequest) (*DraftSummary, error) GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*DraftSummary, error) + UpdateDraftItem(ctx context.Context, req UpdateDraftItemRequest) (*DraftSummary, error) } diff --git a/backend/internal/model/outreach_draft/repository/mongo.go b/backend/internal/model/outreach_draft/repository/mongo.go index 6a54553..badee67 100644 --- a/backend/internal/model/outreach_draft/repository/mongo.go +++ b/backend/internal/model/outreach_draft/repository/mongo.go @@ -84,6 +84,57 @@ func draftScopeFilter(tenantID, ownerUID, brandID, topicID string) bson.M { return filter } +func (r *mongoRepository) GetByID( + ctx context.Context, + tenantID, ownerUID, brandID, draftID string, +) (*entity.OutreachDraft, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["_id"] = draftID + var out entity.OutreachDraft + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("outreach draft not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) UpdateDrafts( + ctx context.Context, + tenantID, ownerUID, brandID, draftID string, + drafts []entity.DraftItem, +) (*entity.OutreachDraft, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return nil, app.For(code.Brand).InputMissingRequired("draft_id is required") + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["_id"] = draftID + update := bson.M{"$set": bson.M{"drafts": drafts}} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var out entity.OutreachDraft + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("outreach draft not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + func (r *mongoRepository) GetLatestByScanPost( ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string, diff --git a/backend/internal/model/outreach_draft/usecase/errors.go b/backend/internal/model/outreach_draft/usecase/errors.go index 75da245..be64b44 100644 --- a/backend/internal/model/outreach_draft/usecase/errors.go +++ b/backend/internal/model/outreach_draft/usecase/errors.go @@ -16,3 +16,19 @@ func errMissingBrand() error { func errMissingScanPost() error { return app.For(code.Brand).InputMissingRequired("scan_post_id is required") } + +func errMissingDraftID() error { + return app.For(code.Brand).InputMissingRequired("draft_id is required") +} + +func errMissingDraftText() error { + return app.For(code.Brand).InputMissingRequired("draft text cannot be empty") +} + +func errDraftNotFound() error { + return app.For(code.Brand).ResNotFound("outreach draft not found") +} + +func errInvalidDraftIndex() error { + return app.For(code.Brand).InputInvalidFormat("draft_index out of range") +} diff --git a/backend/internal/model/outreach_draft/usecase/usecase.go b/backend/internal/model/outreach_draft/usecase/usecase.go index 02175eb..bdf7b47 100644 --- a/backend/internal/model/outreach_draft/usecase/usecase.go +++ b/backend/internal/model/outreach_draft/usecase/usecase.go @@ -56,6 +56,39 @@ func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.Create return toSummary(record), nil } +func (u *outreachDraftUseCase) UpdateDraftItem( + ctx context.Context, + req domusecase.UpdateDraftItemRequest, +) (*domusecase.DraftSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + return nil, err + } + draftID := strings.TrimSpace(req.DraftID) + if draftID == "" { + return nil, errMissingDraftID() + } + text := strings.TrimSpace(req.Text) + if text == "" { + return nil, errMissingDraftText() + } + record, err := u.repo.GetByID(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID) + if err != nil { + return nil, err + } + if record == nil || len(record.Drafts) == 0 { + return nil, errDraftNotFound() + } + if req.DraftIndex < 0 || req.DraftIndex >= len(record.Drafts) { + return nil, errInvalidDraftIndex() + } + record.Drafts[req.DraftIndex].Text = text + updated, err := u.repo.UpdateDrafts(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID, record.Drafts) + if err != nil { + return nil, err + } + return toSummary(updated), nil +} + func (u *outreachDraftUseCase) GetLatestByScanPost( ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string, diff --git a/backend/internal/model/own_post_formula/domain/entity/review.go b/backend/internal/model/own_post_formula/domain/entity/review.go new file mode 100644 index 0000000..b8025c6 --- /dev/null +++ b/backend/internal/model/own_post_formula/domain/entity/review.go @@ -0,0 +1,28 @@ +package entity + +const CollectionName = "own_post_formula_reviews" + +type Review struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + AccountID string `bson:"account_id"` + MediaID string `bson:"media_id"` + PersonaID string `bson:"persona_id,omitempty"` + PostText string `bson:"post_text,omitempty"` + Summary string `bson:"summary"` + Wins []string `bson:"wins,omitempty"` + Improvements []string `bson:"improvements,omitempty"` + Formula string `bson:"formula"` + PostTemplate string `bson:"post_template"` + HookPattern string `bson:"hook_pattern"` + Structure string `bson:"structure"` + ReplicationTips []string `bson:"replication_tips,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +func ReviewDocID(accountID, mediaID string) string { + return accountID + ":" + mediaID +} \ No newline at end of file diff --git a/backend/internal/model/own_post_formula/domain/repository/repository.go b/backend/internal/model/own_post_formula/domain/repository/repository.go new file mode 100644 index 0000000..1484eb1 --- /dev/null +++ b/backend/internal/model/own_post_formula/domain/repository/repository.go @@ -0,0 +1,14 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/own_post_formula/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) +} \ No newline at end of file diff --git a/backend/internal/model/own_post_formula/repository/mongo.go b/backend/internal/model/own_post_formula/repository/mongo.go new file mode 100644 index 0000000..c889eee --- /dev/null +++ b/backend/internal/model/own_post_formula/repository/mongo.go @@ -0,0 +1,115 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/own_post_formula/domain/entity" + domrepo "haixun-backend/internal/model/own_post_formula/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "media_id", Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "account_id", Value: 1}, + {Key: "update_at", Value: -1}, + }, + }, + }) + return err +} + +func (r *mongoRepository) Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if review == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required") + } + filter := actorMediaFilter(review.TenantID, review.OwnerUID, review.AccountID, review.MediaID) + opts := options.Replace().SetUpsert(true) + if _, err := r.collection.ReplaceOne(ctx, filter, review, opts); err != nil { + return nil, err + } + return review, nil +} + +func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + var out entity.Review + err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, app.For(code.ThreadsAccount).ResNotFound("own post formula review not found") + } + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + cur, err := r.collection.Find( + ctx, + bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + }, + options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.Review + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M { + return bson.M{ + "tenant_id": strings.TrimSpace(tenantID), + "owner_uid": strings.TrimSpace(ownerUID), + "account_id": strings.TrimSpace(accountID), + "media_id": strings.TrimSpace(mediaID), + } +} \ No newline at end of file diff --git a/backend/internal/model/own_post_formula/usecase/usecase.go b/backend/internal/model/own_post_formula/usecase/usecase.go new file mode 100644 index 0000000..082f56a --- /dev/null +++ b/backend/internal/model/own_post_formula/usecase/usecase.go @@ -0,0 +1,93 @@ +package usecase + +import ( + "context" + "strings" + "time" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/clock" + libownpost "haixun-backend/internal/library/ownpost" + "haixun-backend/internal/model/own_post_formula/domain/entity" + domrepo "haixun-backend/internal/model/own_post_formula/domain/repository" +) + +const FormulaCacheTTL = 5 * time.Minute + +type UseCase interface { + Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) + SaveGenerated(ctx context.Context, tenantID, ownerUID, accountID, mediaID, personaID, postText string, review *libownpost.PostFormulaReview) (*entity.Review, error) + ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) + IsFresh(review *entity.Review, now int64) bool +} + +func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) { + return u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID) +} + +func (u *useCase) IsFresh(review *entity.Review, now int64) bool { + if review == nil || review.UpdateAt <= 0 { + return false + } + if now <= 0 { + now = clock.NowUnixNano() + } + return now-review.UpdateAt < int64(FormulaCacheTTL) +} + +type useCase struct { + repo domrepo.Repository +} + +func NewUseCase(repo domrepo.Repository) UseCase { + return &useCase{repo: repo} +} + +func (u *useCase) SaveGenerated( + ctx context.Context, + tenantID, ownerUID, accountID, mediaID, personaID, postText string, + review *libownpost.PostFormulaReview, +) (*entity.Review, error) { + if review == nil { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required") + } + accountID = strings.TrimSpace(accountID) + mediaID = strings.TrimSpace(mediaID) + if accountID == "" || mediaID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required") + } + now := clock.NowUnixNano() + doc := &entity.Review{ + ID: entity.ReviewDocID(accountID, mediaID), + TenantID: strings.TrimSpace(tenantID), + OwnerUID: strings.TrimSpace(ownerUID), + AccountID: accountID, + MediaID: mediaID, + PersonaID: strings.TrimSpace(personaID), + PostText: strings.TrimSpace(postText), + 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, + CreateAt: now, + UpdateAt: now, + } + if existing, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID); err == nil && existing != nil { + doc.CreateAt = existing.CreateAt + } + return u.repo.Upsert(ctx, doc) +} + +func (u *useCase) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required") + } + return u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID) +} \ No newline at end of file diff --git a/backend/internal/model/placement/usecase/settings.go b/backend/internal/model/placement/usecase/settings.go index 1e7f423..fee05de 100644 --- a/backend/internal/model/placement/usecase/settings.go +++ b/backend/internal/model/placement/usecase/settings.go @@ -28,6 +28,7 @@ type Settings struct { BraveSearchLang string ExaUserLocation string ExpandStrategy string + DevModeEnabled bool } type SettingsPatch struct { @@ -38,6 +39,7 @@ type SettingsPatch struct { BraveSearchLang *string ExaUserLocation *string ExpandStrategy *string + DevModeEnabled *bool } type UseCase interface { @@ -164,6 +166,7 @@ type storedSettings struct { BraveSearchLang string ExaUserLocation string ExpandStrategy string + DevModeEnabled bool } func defaultSettings() storedSettings { @@ -201,6 +204,9 @@ func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSe if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" { defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v)) } + if v, ok := raw["dev_mode_enabled"].(bool); ok { + defaults.DevModeEnabled = v + } return defaults } @@ -232,6 +238,9 @@ func applyPatch(current storedSettings, patch SettingsPatch) storedSettings { if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" { current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy)) } + if patch.DevModeEnabled != nil { + current.DevModeEnabled = *patch.DevModeEnabled + } return current } @@ -244,6 +253,7 @@ func (s storedSettings) toMap() map[string]interface{} { "brave_search_lang": s.BraveSearchLang, "exa_user_location": s.ExaUserLocation, "expand_strategy": s.ExpandStrategy, + "dev_mode_enabled": s.DevModeEnabled, } } @@ -267,6 +277,7 @@ func toPublic(stored storedSettings) *Settings { BraveSearchLang: stored.BraveSearchLang, ExaUserLocation: stored.ExaUserLocation, ExpandStrategy: strategy, + DevModeEnabled: stored.DevModeEnabled, } } diff --git a/backend/internal/model/placement/usecase/settings_dev_mode_test.go b/backend/internal/model/placement/usecase/settings_dev_mode_test.go new file mode 100644 index 0000000..e6607ef --- /dev/null +++ b/backend/internal/model/placement/usecase/settings_dev_mode_test.go @@ -0,0 +1,28 @@ +package usecase + +import "testing" + +func TestApplyPatchDevModeEnabled(t *testing.T) { + enabled := true + disabled := false + current := defaultSettings() + + next := applyPatch(current, SettingsPatch{DevModeEnabled: &enabled}) + if !next.DevModeEnabled { + t.Fatal("expected dev_mode_enabled true") + } + + next = applyPatch(next, SettingsPatch{DevModeEnabled: &disabled}) + if next.DevModeEnabled { + t.Fatal("expected dev_mode_enabled false after toggle off") + } +} + +func TestMergeSettingsDevModeEnabled(t *testing.T) { + merged := mergeSettings(defaultSettings(), map[string]interface{}{ + "dev_mode_enabled": true, + }) + if !merged.DevModeEnabled { + t.Fatal("expected merged dev_mode_enabled true") + } +} \ No newline at end of file diff --git a/backend/internal/model/publish_queue/domain/repository/repository.go b/backend/internal/model/publish_queue/domain/repository/repository.go index 8f23c1b..68c85cf 100644 --- a/backend/internal/model/publish_queue/domain/repository/repository.go +++ b/backend/internal/model/publish_queue/domain/repository/repository.go @@ -24,6 +24,7 @@ type Repository interface { EnsureIndexes(ctx context.Context) error Create(ctx context.Context, item *entity.QueueItem) error Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) + ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error) ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) diff --git a/backend/internal/model/publish_queue/domain/usecase/usecase.go b/backend/internal/model/publish_queue/domain/usecase/usecase.go index 1565cce..6012fb4 100644 --- a/backend/internal/model/publish_queue/domain/usecase/usecase.go +++ b/backend/internal/model/publish_queue/domain/usecase/usecase.go @@ -152,6 +152,7 @@ type UseCase interface { Create(ctx context.Context, req CreateRequest) (*QueueItemSummary, error) RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error) + ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]QueueItemSummary, error) List(ctx context.Context, req ListRequest) (*ListResult, error) ListRange(ctx context.Context, req RangeRequest) (*ListResult, error) Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error) diff --git a/backend/internal/model/publish_queue/repository/mongo.go b/backend/internal/model/publish_queue/repository/mongo.go index dc3f056..ab060ff 100644 --- a/backend/internal/model/publish_queue/repository/mongo.go +++ b/backend/internal/model/publish_queue/repository/mongo.go @@ -99,6 +99,34 @@ func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID return &out, nil } +func (r *mongoRepository) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) { + if r.collection == nil { + return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + trimmed := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id != "" { + trimmed = append(trimmed, id) + } + } + if len(trimmed) == 0 { + return nil, nil + } + filter := actorFilter(tenantID, ownerUID, "") + filter["_id"] = bson.M{"$in": trimmed} + cur, err := r.collection.Find(ctx, filter) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.QueueItem + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, page, pageSize int) (*domrepo.ListResult, error) { if r.collection == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/publish_queue/usecase/usecase.go b/backend/internal/model/publish_queue/usecase/usecase.go index 6139158..a3f427a 100644 --- a/backend/internal/model/publish_queue/usecase/usecase.go +++ b/backend/internal/model/publish_queue/usecase/usecase.go @@ -169,6 +169,21 @@ func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accou return &out, nil } +func (u *publishQueueUseCase) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]domusecase.QueueItemSummary, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + items, err := u.repo.ListByIDs(ctx, tenantID, ownerUID, ids) + if err != nil { + return nil, err + } + out := make([]domusecase.QueueItemSummary, 0, len(items)) + for _, item := range items { + out = append(out, toSummary(item)) + } + return out, nil +} + func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) { if err := requireActor(req.TenantID, req.OwnerUID); err != nil { return nil, err diff --git a/backend/internal/model/scan_post/domain/entity/outreach_patch.go b/backend/internal/model/scan_post/domain/entity/outreach_patch.go index 23381e6..2cf8893 100644 --- a/backend/internal/model/scan_post/domain/entity/outreach_patch.go +++ b/backend/internal/model/scan_post/domain/entity/outreach_patch.go @@ -4,4 +4,5 @@ type OutreachPatch struct { Status string PublishedReplyID string PublishedPermalink string + ExternalID string } diff --git a/backend/internal/model/scan_post/domain/entity/post.go b/backend/internal/model/scan_post/domain/entity/post.go index 1f0921f..37227aa 100644 --- a/backend/internal/model/scan_post/domain/entity/post.go +++ b/backend/internal/model/scan_post/domain/entity/post.go @@ -28,6 +28,7 @@ type ScanPost struct { GraphNodeID string `bson:"graph_node_id"` SearchTag string `bson:"search_tag"` QueryDimension string `bson:"query_dimension"` + RecencyDays int `bson:"recency_days,omitempty"` ExternalID string `bson:"external_id"` Permalink string `bson:"permalink"` Author string `bson:"author"` diff --git a/backend/internal/model/scan_post/domain/usecase/usecase.go b/backend/internal/model/scan_post/domain/usecase/usecase.go index 0e0ab16..93102eb 100644 --- a/backend/internal/model/scan_post/domain/usecase/usecase.go +++ b/backend/internal/model/scan_post/domain/usecase/usecase.go @@ -24,6 +24,7 @@ type ScanPostSummary struct { GraphNodeID string SearchTag string QueryDimension string + RecencyDays int ExternalID string Permalink string Author string @@ -102,6 +103,7 @@ type UpdateOutreachRequest struct { Status string PublishedReplyID string PublishedPermalink string + ExternalID string } type CheckpointRequest struct { diff --git a/backend/internal/model/scan_post/repository/mongo.go b/backend/internal/model/scan_post/repository/mongo.go index 5c955ff..4873003 100644 --- a/backend/internal/model/scan_post/repository/mongo.go +++ b/backend/internal/model/scan_post/repository/mongo.go @@ -7,6 +7,7 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/placement" libmongo "haixun-backend/internal/library/mongo" "haixun-backend/internal/model/scan_post/domain/entity" domrepo "haixun-backend/internal/model/scan_post/domain/repository" @@ -174,6 +175,7 @@ func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, owne err := r.collection.FindOne(ctx, filter).Decode(&existing) if err == nil { post.ID = existing.ID + post.ExternalID = placement.PreferReplyExternalID(existing.ExternalID, post.ExternalID) if existing.CreateAt > 0 { post.CreateAt = existing.CreateAt } @@ -291,6 +293,9 @@ func (r *mongoRepository) UpdateOutreach( if strings.TrimSpace(patch.PublishedPermalink) != "" { set["published_permalink"] = strings.TrimSpace(patch.PublishedPermalink) } + if strings.TrimSpace(patch.ExternalID) != "" { + set["external_id"] = strings.TrimSpace(patch.ExternalID) + } if len(set) == 0 { return r.Get(ctx, tenantID, ownerUID, brandID, postID) } @@ -450,6 +455,11 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f if filter.SemanticMin > 0 { query["semantic_score"] = bson.M{"$gte": filter.SemanticMin} } + if filter.Recent7dOnly { + for key, value := range recent7dListQuery(time.Now()) { + query[key] = value + } + } limit := filter.Limit if limit <= 0 { limit = 100 @@ -470,9 +480,10 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f return nil, err } if filter.Recent7dOnly { + now := time.Now() filtered := make([]entity.ScanPost, 0, len(out)) for _, item := range out { - if item.Priority == "gold" || item.Priority == "recent" { + if matchesRecent7dPost(item, now) { filtered = append(filtered, item) } } @@ -481,6 +492,36 @@ func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, f return out, nil } +const recent7dWindowDays = placement.IdealMaxPostAgeDays + +// recent7dListQuery matches posts from the recency track or with posted_at in the last 7 days. +func recent7dListQuery(now time.Time) bson.M { + cutoff := now.AddDate(0, 0, -recent7dWindowDays).Format("2006-01-02") + return bson.M{ + "$or": []bson.M{ + {"posted_at": bson.M{"$gte": cutoff}}, + { + "$and": []bson.M{ + {"posted_at": bson.M{"$in": []interface{}{nil, ""}}}, + {"recency_days": bson.M{"$lte": recent7dWindowDays, "$gt": 0}}, + }, + }, + }, + } +} + +func matchesRecent7dPost(item entity.ScanPost, now time.Time) bool { + postedAt := strings.TrimSpace(item.PostedAt) + if postedAt != "" { + ts, ok := placement.ParsePostedAt(postedAt) + if ok { + cutoff := now.AddDate(0, 0, -recent7dWindowDays) + return !ts.Before(cutoff) + } + } + return item.RecencyDays > 0 && item.RecencyDays <= recent7dWindowDays +} + // priorityListFilter maps UI track filters to stored priority values. // gold posts hit both relevance and recency tracks, so they belong in either filter. func priorityListFilter(priority string) bson.M { diff --git a/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go new file mode 100644 index 0000000..062c85d --- /dev/null +++ b/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go @@ -0,0 +1,54 @@ +package repository + +import ( + "testing" + "time" + + "haixun-backend/internal/model/scan_post/domain/entity" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestMatchesRecent7dPost_Includes7dWindowWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{RecencyDays: 7} + if !matchesRecent7dPost(item, now) { + t.Fatal("expected 7d-window post without date to match recent_7d filter") + } +} + +func TestMatchesRecent7dPost_Excludes14dWindowWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{RecencyDays: 14, QueryDimension: "recency"} + if matchesRecent7dPost(item, now) { + t.Fatal("expected 14d-window post without date to be excluded from recent_7d") + } +} + +func TestMatchesRecent7dPost_IncludesPostedWithin7Days(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{ + Priority: "relevant", + PostedAt: "2026-06-28", + } + if !matchesRecent7dPost(item, now) { + t.Fatal("expected post within 7 days to match") + } +} + +func TestMatchesRecent7dPost_ExcludesOldRelevantWithoutDate(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + item := entity.ScanPost{Priority: "relevant", QueryDimension: "relevance"} + if matchesRecent7dPost(item, now) { + t.Fatal("expected old relevance-only post without date to be excluded") + } +} + +func TestRecent7dListQuery_HasExpectedClauses(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + query := recent7dListQuery(now) + orClause, ok := query["$or"].([]bson.M) + if !ok || len(orClause) != 2 { + t.Fatalf("expected 2 OR branches, got %T %v", query["$or"], query["$or"]) + } +} \ No newline at end of file diff --git a/backend/internal/model/scan_post/usecase/usecase.go b/backend/internal/model/scan_post/usecase/usecase.go index 05988f8..558eac2 100644 --- a/backend/internal/model/scan_post/usecase/usecase.go +++ b/backend/internal/model/scan_post/usecase/usecase.go @@ -104,15 +104,9 @@ func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.Repla if err != nil { return 0, err } - keep := make([]string, 0, len(entities)) - for _, item := range entities { - if strings.TrimSpace(item.Permalink) != "" { - keep = append(keep, item.Permalink) - } - } - if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.ScanJobID, keep); err != nil { - return count, err - } + // Do not prune checkpoint posts after finalize. Crawl checkpoints may include + // candidates that fail strict final filters; pruning them made patrol results + // disappear from the results tab right after a job completed. return count, nil } @@ -146,6 +140,7 @@ func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: string(item.QueryDimension), + RecencyDays: item.RecencyDays, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, @@ -203,6 +198,7 @@ func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.Upd Status: status, PublishedReplyID: req.PublishedReplyID, PublishedPermalink: req.PublishedPermalink, + ExternalID: req.ExternalID, }) if err != nil { return nil, err @@ -384,6 +380,7 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: item.QueryDimension, + RecencyDays: item.RecencyDays, ExternalID: item.ExternalID, Permalink: item.Permalink, Author: item.Author, diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index ca21737..7d41efd 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -186,6 +186,9 @@ type APIPlaygroundRequest struct { ReplyID string ReplyToID string Text string + Permalink string + HintText string + SkipPrime bool LocationQuery string Limit int Hide bool @@ -223,6 +226,11 @@ type PostPerformanceItem struct { Text string Permalink string Timestamp string + MediaType string + MediaURL string + ThumbnailURL string + TopicTag string + Shortcode string LikeCount int ReplyCount int RepostCount int @@ -272,6 +280,7 @@ type UseCase interface { ResolveMemberResearchAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) + ResolveMemberThreadsReplyCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error) StartThreadsOAuth(ctx context.Context, tenantID, ownerUID, accountID string) (*OAuthStartResult, error) CompleteThreadsOAuthCallback(ctx context.Context, code, state string) (*OAuthCallbackResult, error) diff --git a/backend/internal/model/threads_account/usecase/api_playground.go b/backend/internal/model/threads_account/usecase/api_playground.go index 88c361b..c1f0883 100644 --- a/backend/internal/model/threads_account/usecase/api_playground.go +++ b/backend/internal/model/threads_account/usecase/api_playground.go @@ -105,12 +105,15 @@ func (u *threadsAccountUseCase) RunAPIPlayground( } else { payload, runErr = client.LocationSearch(ctx, q, limit) } - case "media_replies": + case "media_replies", "media_conversation": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { - payload, runErr = client.MediaReplies(ctx, mid, limit) + if limit < 30 { + limit = 30 + } + payload, runErr = client.MediaConversation(ctx, mid, limit) } case "media_stats": mid := strings.TrimSpace(req.MediaID) @@ -119,6 +122,13 @@ func (u *threadsAccountUseCase) RunAPIPlayground( } else { payload, runErr = client.GetMediaStats(ctx, mid) } + case "media_detail": + mid := strings.TrimSpace(req.MediaID) + if mid == "" { + runErr = fmt.Errorf("media_id 為必填") + } else { + payload, runErr = client.GetMediaDetail(ctx, mid) + } case "media_insights": mid := strings.TrimSpace(req.MediaID) if mid == "" { @@ -135,15 +145,51 @@ func (u *threadsAccountUseCase) RunAPIPlayground( ThreadsUserID: userID, AccessToken: token, Text: text, }) } + case "prime_reply_target": + replyTo := strings.TrimSpace(req.ReplyToID) + permalink := strings.TrimSpace(req.Permalink) + if replyTo == "" || permalink == "" { + runErr = fmt.Errorf("reply_to_id 與 permalink 為必填") + } else { + hint := strings.TrimSpace(req.HintText) + if hint == "" { + hint = strings.TrimSpace(req.Query) + } + if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: replyTo, + Permalink: permalink, + HintText: hint, + }, replyTo); primeErr != nil { + runErr = primeErr + } else { + payload = map[string]string{"primed": replyTo} + } + } case "publish_reply": text := strings.TrimSpace(req.Text) replyTo := strings.TrimSpace(req.ReplyToID) if userID == "" || text == "" || replyTo == "" { runErr = fmt.Errorf("threads_user_id、reply_to_id、text 為必填") } else { - payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ - ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, - }) + permalink := strings.TrimSpace(req.Permalink) + if permalink != "" && !req.SkipPrime { + hint := strings.TrimSpace(req.HintText) + if hint == "" { + hint = strings.TrimSpace(req.Query) + } + if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ + ExternalID: replyTo, + Permalink: permalink, + HintText: hint, + }, replyTo); primeErr != nil { + runErr = fmt.Errorf("回覆他人貼文前需先透過 keyword_search 註冊目標:%w", primeErr) + } + } + if runErr == nil { + payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ + ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, + }) + } } case "delete_media": mid := strings.TrimSpace(req.MediaID) diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go index 073aafe..34739fe 100644 --- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -6,42 +6,43 @@ import ( domusecase "haixun-backend/internal/model/threads_account/domain/usecase" ) -func TestApplyConnectionPatchSearchSourceInDevMode(t *testing.T) { - current := deriveConnectionPrefsFromDevMode(true) - patch := domusecase.ConnectionPrefsPatch{ - SearchSourceMode: strPtr("brave"), - } - next := applyConnectionPatch(current, patch) - if next.SearchSourceMode != "brave" { - t.Fatalf("expected brave, got %s", next.SearchSourceMode) - } - if !next.DevMode { - t.Fatal("expected dev mode to stay enabled") - } -} - func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) { - current := deriveConnectionPrefsFromDevMode(false) + current := formalConnectionPrefs() patch := domusecase.ConnectionPrefsPatch{ SearchSourceMode: strPtr("threads_crawler"), } next := applyConnectionPatch(current, patch) - if next.SearchSourceMode != "threads_crawler" { - t.Fatalf("expected threads_crawler, got %s", next.SearchSourceMode) + if next.SearchSourceMode != "threads_brave" { + t.Fatalf("expected crawler mode stripped to threads_brave, got %s", next.SearchSourceMode) + } + if next.DevMode { + t.Fatal("expected dev_mode to stay disabled") } } -func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { - prefs := domusecase.ConnectionPrefs{ - DevMode: false, - SearchSourceMode: "crawler", - } +func TestNormalizeConnectionPrefsKeepsScrapeReplies(t *testing.T) { + prefs := formalConnectionPrefs() + prefs.ScrapeReplies = true next := normalizeConnectionPrefs(prefs) - if next.SearchSourceMode != "crawler" { - t.Fatalf("expected crawler, got %s", next.SearchSourceMode) + if !next.ScrapeReplies { + t.Fatal("expected scrape_replies to persist") + } + if next.PublishViaApi != true || !next.SearchViaApi { + t.Fatal("expected formal API routing") + } +} + +func TestApplyConnectionPatchIgnoresLegacyDevMode(t *testing.T) { + current := formalConnectionPrefs() + enabled := true + next := applyConnectionPatch(current, domusecase.ConnectionPrefsPatch{ + DevMode: &enabled, + }) + if next.DevMode { + t.Fatal("account dev_mode should not enable crawler routing") } } func strPtr(v string) *string { return &v -} +} \ No newline at end of file diff --git a/backend/internal/model/threads_account/usecase/placement_context.go b/backend/internal/model/threads_account/usecase/placement_context.go index 6492b5a..deb35aa 100644 --- a/backend/internal/model/threads_account/usecase/placement_context.go +++ b/backend/internal/model/threads_account/usecase/placement_context.go @@ -53,5 +53,5 @@ func (u *threadsAccountUseCase) ResolveMemberPlacementContext( prefs.RepliesPerPost, ) ctxOut.ThreadsAPIAccessToken = apiToken - return ctxOut, nil + return placement.MemberContextForAPIOnly(ctxOut), nil } diff --git a/backend/internal/model/threads_account/usecase/post_performance.go b/backend/internal/model/threads_account/usecase/post_performance.go index 99987c7..bb29605 100644 --- a/backend/internal/model/threads_account/usecase/post_performance.go +++ b/backend/internal/model/threads_account/usecase/post_performance.go @@ -69,12 +69,17 @@ func (u *threadsAccountUseCase) ListPostPerformance( } for _, post := range posts { item := domusecase.PostPerformanceItem{ - MediaID: post.ID, - Text: post.Text, - Permalink: post.Permalink, - Timestamp: post.Timestamp, - LikeCount: post.LikeCount, - ReplyCount: post.ReplyCount, + MediaID: post.ID, + Text: post.Text, + Permalink: post.Permalink, + Timestamp: post.Timestamp, + MediaType: post.MediaType, + MediaURL: post.MediaURL, + ThumbnailURL: post.ThumbnailURL, + TopicTag: post.TopicTag, + Shortcode: post.Shortcode, + LikeCount: post.LikeCount, + ReplyCount: post.ReplyCount, } if stats, serr := client.GetMediaStats(ctx, post.ID); serr == nil && stats != nil { item.LikeCount = stats.LikeCount diff --git a/backend/internal/model/threads_account/usecase/publish_credentials.go b/backend/internal/model/threads_account/usecase/publish_credentials.go index 84c7e87..9f4e701 100644 --- a/backend/internal/model/threads_account/usecase/publish_credentials.go +++ b/backend/internal/model/threads_account/usecase/publish_credentials.go @@ -12,6 +12,20 @@ import ( func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential( ctx context.Context, tenantID, ownerUID string, +) (*domusecase.ThreadsPublishCredential, error) { + return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID) +} + +func (u *threadsAccountUseCase) ResolveMemberThreadsReplyCredential( + ctx context.Context, + tenantID, ownerUID string, +) (*domusecase.ThreadsPublishCredential, error) { + return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID) +} + +func (u *threadsAccountUseCase) resolveMemberThreadsAPICredential( + ctx context.Context, + tenantID, ownerUID string, ) (*domusecase.ThreadsPublishCredential, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err @@ -41,10 +55,6 @@ func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential( if !apiConnected { return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先完成 Threads API 連線後再發送留言") } - prefs, _ := u.loadConnectionPrefs(ctx, account.ID) - if prefs.DevMode && !prefs.PublishViaApi { - return nil, app.For(code.ThreadsAccount).InputMissingRequired("開發模式請在連線設定啟用 API 發文,或改用手動貼上留言") - } return &domusecase.ThreadsPublishCredential{ AccountID: account.ID, ThreadsUserID: userID, diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 29ec525..2c671bc 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -441,28 +441,14 @@ func accountLabel(account *entity.Account) string { } func defaultConnectionPrefs() domusecase.ConnectionPrefs { - return deriveConnectionPrefsFromDevMode(false) + return formalConnectionPrefs() } -// deriveConnectionPrefsFromDevMode maps the single dev_mode switch to concrete routing prefs. -// dev_mode off → everything via Threads API; dev_mode on → everything via browser crawler. -func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { - if devMode { - return domusecase.ConnectionPrefs{ - DevMode: true, - SearchViaApi: false, - SearchSourceMode: string(placement.SearchSourceThreadsCrawler), - PublishViaApi: false, - ScrapeReplies: true, - RepliesPerPost: defaultRepliesPerPost, - PublishHeaded: false, - PlaywrightDebug: false, - } - } +func formalConnectionPrefs() domusecase.ConnectionPrefs { return domusecase.ConnectionPrefs{ DevMode: false, SearchViaApi: true, - SearchSourceMode: string(placement.DefaultSearchSourceMode), + SearchSourceMode: string(placement.WithoutCrawler(placement.DefaultSearchSourceMode)), PublishViaApi: true, ScrapeReplies: false, RepliesPerPost: defaultRepliesPerPost, @@ -472,40 +458,12 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs { } func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs { - if patch.DevMode != nil { - if *patch.DevMode { - next := deriveConnectionPrefsFromDevMode(true) - if patch.SearchSourceMode != nil { - next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) - } else if strings.TrimSpace(current.SearchSourceMode) != "" { - next.SearchSourceMode = current.SearchSourceMode - } - return normalizeConnectionPrefs(next) - } - next := current - next.DevMode = false - if patch.SearchSourceMode != nil { - next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) - } - return normalizeConnectionPrefs(next) + next := normalizeConnectionPrefs(current) + if patch.ScrapeReplies != nil { + next.ScrapeReplies = *patch.ScrapeReplies } - // Legacy callers may still send granular fields; normalize by current dev flag. - devMode := current.DevMode - if patch.SearchViaApi != nil && !*patch.SearchViaApi { - devMode = true - } - if patch.PublishViaApi != nil && !*patch.PublishViaApi { - devMode = true - } - if patch.ScrapeReplies != nil && *patch.ScrapeReplies { - devMode = true - } - next := current - if devMode { - next = deriveConnectionPrefsFromDevMode(true) - if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil { - next.SearchSourceMode = current.SearchSourceMode - } + if patch.RepliesPerPost != nil && *patch.RepliesPerPost > 0 { + next.RepliesPerPost = *patch.RepliesPerPost } if patch.SearchSourceMode != nil { next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) @@ -514,20 +472,14 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C } func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs { - mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode) - if prefs.DevMode { - out := deriveConnectionPrefsFromDevMode(true) - out.SearchSourceMode = string(mode) - return out + out := formalConnectionPrefs() + out.ScrapeReplies = prefs.ScrapeReplies + if prefs.RepliesPerPost > 0 { + out.RepliesPerPost = prefs.RepliesPerPost } - prefs.DevMode = false - prefs.SearchViaApi = true - prefs.PublishViaApi = true - prefs.ScrapeReplies = false - prefs.PublishHeaded = false - prefs.PlaywrightDebug = false - prefs.SearchSourceMode = string(mode) - return prefs + mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode) + out.SearchSourceMode = string(placement.WithoutCrawler(mode)) + return out } func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs { diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index 5ea9766..cf00719 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -25,6 +25,9 @@ import ( cmatrixdomain "haixun-backend/internal/model/content_matrix/domain/usecase" cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" cmatrixusecase "haixun-backend/internal/model/content_matrix/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + contentformularepo "haixun-backend/internal/model/content_formula/repository" + contentformulausecase "haixun-backend/internal/model/content_formula/usecase" copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" copydraftrepo "haixun-backend/internal/model/copy_draft/repository" copydraftusecase "haixun-backend/internal/model/copy_draft/usecase" @@ -42,9 +45,14 @@ import ( memberdomain "haixun-backend/internal/model/member/domain/usecase" memberrepo "haixun-backend/internal/model/member/repository" memberuc "haixun-backend/internal/model/member/usecase" + mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase" + mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository" + mentioninboxusecase "haixun-backend/internal/model/mention_inbox/usecase" outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase" outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository" outreachdraftusecase "haixun-backend/internal/model/outreach_draft/usecase" + ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository" + ownpostformuladomain "haixun-backend/internal/model/own_post_formula/usecase" permissiondomain "haixun-backend/internal/model/permission/domain/usecase" permissionrepo "haixun-backend/internal/model/permission/repository" permissionuc "haixun-backend/internal/model/permission/usecase" @@ -118,6 +126,9 @@ type ServiceContext struct { PublishGuard publishguarddomain.UseCase PublishQueueEvent publishqueueeventdomain.UseCase StylePreset stylepresetdomain.UseCase + OwnPostFormula ownpostformuladomain.UseCase + ContentFormula contentformuladomain.UseCase + MentionInbox mentioninboxdomain.UseCase // Middlewares mounted per route group via generate/api `middleware:` directive. AuthJWT rest.Middleware @@ -224,6 +235,9 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureGenerateOutreachDraftTemplate(ctx); err != nil { + panic(err) + } if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil { panic(err) } @@ -299,6 +313,9 @@ func NewServiceContext(c config.Config) *ServiceContext { publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database()) publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database()) stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database()) + ownPostFormulaRepository := ownpostformularepo.NewMongoRepository(mongoClient.Database()) + contentFormulaRepository := contentformularepo.NewMongoRepository(mongoClient.Database()) + mentionInboxRepository := mentioninboxrepo.NewMongoRepository(mongoClient.Database()) var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore if redisClient != nil { threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient) @@ -333,6 +350,17 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := stylePresetRepository.EnsureIndexes(ctx); err != nil { panic(err) } + if err := ownPostFormulaRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := contentFormulaRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + if err := mentionInboxRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + ownPostFormulaUseCase := ownpostformuladomain.NewUseCase(ownPostFormulaRepository) + contentFormulaUseCase := contentformulausecase.NewUseCase(contentFormulaRepository) threadsAccountUseCase := threadsaccountusecase.NewUseCase( threadsAccountRepository, threadsAccountSecretsRepository, @@ -348,6 +376,7 @@ func NewServiceContext(c config.Config) *ServiceContext { secretsCipher, threadsOAuthConfig, ) + mentionInboxUseCase := mentioninboxusecase.NewUseCase(mentionInboxRepository, threadsAccountUseCase) placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) @@ -401,6 +430,9 @@ func NewServiceContext(c config.Config) *ServiceContext { PublishGuard: publishGuardUseCase, PublishQueueEvent: publishQueueEventUseCase, StylePreset: stylePresetUseCase, + OwnPostFormula: ownPostFormulaUseCase, + ContentFormula: contentFormulaUseCase, + MentionInbox: mentionInboxUseCase, } hostname, _ := os.Hostname() @@ -480,6 +512,16 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, AI: sc.AI, }) + jobworker.RegisterGenerateOutreachDraftHandler(runner, jobworker.GenerateOutreachDraftDeps{ + Jobs: jobUseCase, + Brand: brandUseCase, + Persona: personaUseCase, + ScanPost: scanPostUseCase, + KnowledgeGraph: knowledgeGraphUseCase, + ThreadsAccount: threadsAccountUseCase, + AI: sc.AI, + OutreachDraft: outreachDraftUseCase, + }) jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{ Jobs: jobUseCase, ThreadsAccount: threadsAccountUseCase, diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 8a06643..31b0cc5 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -44,6 +44,35 @@ type AIProvidersData struct { Providers []AIProviderOption `json:"providers"` } +type AnalyzeContentFormulaData struct { + Formula ContentFormulaData `json:"formula"` + Message string `json:"message"` +} + +type AnalyzeContentFormulaHandlerReq struct { + ThreadsAccountPath + AnalyzeContentFormulaReq +} + +type AnalyzeContentFormulaReq struct { + 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"` +} + type AnalyzeStyle8DData struct { PersonaID string `json:"persona_id"` PostCount int `json:"post_count"` @@ -134,6 +163,7 @@ type BrandProductData struct { 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"` @@ -171,6 +201,67 @@ type ClaimWorkerJobReq struct { WorkerID string `json:"worker_id" validate:"required"` } +type ContentFormulaData struct { + 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"` +} + +type ContentFormulaItemPath struct { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` +} + +type ContentFormulaPath struct { + ID string `path:"id" validate:"required"` + FormulaID string `path:"formulaId" validate:"required"` +} + +type ContentFormulaSearchPostData struct { + 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"` +} + +type ContentFormulaSourceMetricsData struct { + 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"` +} + +type ContentInboxItemData struct { + CopyDraftData + ScheduledAt int64 `json:"scheduled_at,omitempty"` + Lifecycle string `json:"lifecycle"` + GroupDate int64 `json:"group_date"` + FormulaLabel string `json:"formula_label,omitempty"` +} + type ContentMatrixData struct { ID string `json:"id,omitempty"` BrandID string `json:"brand_id"` @@ -207,6 +298,7 @@ type CopyDraftData struct { 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"` @@ -356,6 +448,7 @@ type CreateBrandProductHandlerReq struct { type CreateBrandProductReq struct { 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"` } @@ -422,6 +515,11 @@ type CreateThreadsAccountReq struct { Activate *bool `json:"activate,optional"` } +type DeleteContentFormulaData struct { + FormulaID string `json:"formula_id"` + Message string `json:"message"` +} + type DeleteCopyDraftData struct { DraftID string `json:"draft_id"` Message string `json:"message"` @@ -510,6 +608,24 @@ type GenerateCopyMissionMatrixReq struct { Count int `json:"count,optional"` } +type GenerateFromContentFormulaData struct { + List []CopyDraftData `json:"list"` + Message string `json:"message"` +} + +type GenerateFromContentFormulaHandlerReq struct { + ContentFormulaPath + GenerateFromContentFormulaReq +} + +type GenerateFromContentFormulaReq struct { + 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"` +} + type GenerateOutreachDraftsData struct { ID string `json:"id"` ScanPostID string `json:"scan_post_id"` @@ -530,6 +646,51 @@ type GenerateOutreachDraftsReq struct { Count int `json:"count,optional"` VoicePersonaID string `json:"voice_persona_id,optional"` ProductID string `json:"product_id,optional"` + Regenerate bool `json:"regenerate,optional"` +} + +type GenerateOwnPostFormulaData struct { + OwnPostFormulaReviewData +} + +type GenerateOwnPostFormulaHandlerReq struct { + ThreadsAccountPath + GenerateOwnPostFormulaReq +} + +type GenerateOwnPostFormulaReq struct { + 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"` +} + +type GenerateOwnPostReplyDraftData struct { + Text string `json:"text"` +} + +type GenerateOwnPostReplyDraftHandlerReq struct { + ThreadsAccountPath + GenerateOwnPostReplyDraftReq +} + +type GenerateOwnPostReplyDraftReq struct { + 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"` } type GeneratePersonaCopyDraftData struct { @@ -556,10 +717,24 @@ type GeneratePlacementTopicOutreachDraftsHandlerReq struct { GenerateOutreachDraftsReq } +type GetContentFormulaHandlerReq struct { + ContentFormulaItemPath +} + type HealthData struct { Pong string `json:"pong"` } +type ImportOwnPostFormulaHandlerReq struct { + ThreadsAccountPath + ImportOwnPostFormulaReq +} + +type ImportOwnPostFormulaReq struct { + MediaID string `json:"media_id" validate:"required"` + Label string `json:"label,optional"` +} + type ImportThreadsAccountSessionData struct { Success bool `json:"success"` Valid bool `json:"valid"` @@ -784,6 +959,23 @@ type ListBrandsData struct { List []BrandData `json:"list"` } +type ListContentFormulasData struct { + Pagination PaginationData `json:"pagination"` + List []ContentFormulaData `json:"list"` +} + +type ListContentFormulasHandlerReq struct { + ThreadsAccountPath + ListContentFormulasReq +} + +type ListContentFormulasReq struct { + Page int64 `form:"page,optional"` + PageSize int64 `form:"pageSize,optional"` + SourceType string `form:"source_type,optional"` + Tag string `form:"tag,optional"` +} + type ListCopyMissionCopyDraftsData struct { List []CopyDraftData `json:"list"` Total int `json:"total"` @@ -821,6 +1013,49 @@ type ListJobsReq struct { PageSize int64 `form:"pageSize,optional"` // page size } +type ListMentionInboxData struct { + List []MentionInboxItemData `json:"list"` + Pagination PaginationData `json:"pagination"` + ReadyTotal int64 `json:"ready_total"` + NewestSyncedAt int64 `json:"newest_synced_at,omitempty"` +} + +type ListMentionInboxHandlerReq struct { + ThreadsAccountPath + ListMentionInboxQuery +} + +type ListMentionInboxQuery struct { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type ListOwnPostFormulasData struct { + List []OwnPostFormulaReviewData `json:"list"` +} + +type ListOwnPostFormulasHandlerReq struct { + ThreadsAccountPath +} + +type ListPersonaContentInboxData struct { + Pagination PaginationData `json:"pagination"` + List []ContentInboxItemData `json:"list"` +} + +type ListPersonaContentInboxHandlerReq struct { + PersonaPath + ListPersonaContentInboxReq +} + +type ListPersonaContentInboxReq struct { + 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"` +} + type ListPersonaCopyDraftsData struct { List []CopyDraftData `json:"list"` Total int `json:"total"` @@ -956,6 +1191,27 @@ type MemberPlacementSettingsData struct { 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"` +} + +type MentionInboxItemData struct { + 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"` } type OutreachDraftItemData struct { @@ -964,6 +1220,21 @@ type OutreachDraftItemData struct { Rationale string `json:"rationale"` } +type OwnPostFormulaReviewData struct { + 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"` +} + type PaginationData struct { Total int64 `json:"total"` Page int64 `json:"page"` @@ -980,6 +1251,20 @@ type PatchAudienceSampleReq struct { Status string `json:"status"` } +type PatchContentFormulaHandlerReq struct { + ContentFormulaItemPath + PatchContentFormulaReq +} + +type PatchContentFormulaReq struct { + 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"` +} + type PatchCopyMissionGraphNodesHandlerReq struct { CopyMissionPath PatchKnowledgeGraphNodesReq @@ -1170,6 +1455,25 @@ type PublishInventoryPolicyData struct { UpdateAt int64 `json:"update_at"` } +type PublishMentionReplyData struct { + MediaID string `json:"media_id,omitempty"` + Permalink string `json:"permalink,omitempty"` +} + +type PublishMentionReplyHandlerReq struct { + PublishMentionReplyPath + PublishMentionReplyReq +} + +type PublishMentionReplyPath struct { + ID string `path:"id" validate:"required"` + MediaID string `path:"media_id" validate:"required"` +} + +type PublishMentionReplyReq struct { + Text string `json:"text"` +} + type PublishOutreachDraftData struct { ScanPostID string `json:"scan_post_id"` ReplyID string `json:"reply_id"` @@ -1315,6 +1619,9 @@ type RunThreadsAPIPlaygroundReq struct { 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"` @@ -1349,6 +1656,7 @@ type ScanPostData struct { 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"` @@ -1383,11 +1691,37 @@ type ScheduleCopyMissionDraftsHandlerReq struct { ScheduleCopyDraftsReq } +type SchedulePersonaCopyDraftHandlerReq struct { + CopyDraftPath + SchedulePersonaCopyDraftReq +} + +type SchedulePersonaCopyDraftReq struct { + AccountID string `json:"account_id" validate:"required"` + ScheduledAt int64 `json:"scheduled_at,optional"` +} + type SchedulePersonaDraftsHandlerReq struct { PersonaPath ScheduleCopyDraftsReq } +type SearchContentFormulaPostsData struct { + List []ContentFormulaSearchPostData `json:"list"` + Message string `json:"message"` +} + +type SearchContentFormulaPostsHandlerReq struct { + ThreadsAccountPath + SearchContentFormulaPostsReq +} + +type SearchContentFormulaPostsReq struct { + Keyword string `json:"keyword" validate:"required"` + SearchType string `json:"search_type,optional"` + Limit int `json:"limit,optional"` +} + type SettingData struct { ID string `json:"id"` Scope string `json:"scope"` @@ -1441,6 +1775,7 @@ type StartBrandScanJobReq struct { 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"` } @@ -1516,6 +1851,25 @@ type StartPersonaViralScanJobReq struct { Keywords []string `json:"keywords,optional"` } +type StartPlacementTopicOutreachDraftJobHandlerReq struct { + PlacementTopicPath + StartPlacementTopicOutreachDraftJobReq +} + +type StartPlacementTopicOutreachDraftJobReq struct { + 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"` +} + +type StartPlacementTopicOutreachDraftJobsData struct { + Jobs []StartBrandScanJobData `json:"jobs"` + Message string `json:"message"` +} + type StartPlacementTopicScanJobHandlerReq struct { PlacementTopicPath StartBrandScanJobReq @@ -1583,6 +1937,22 @@ type StylePresetPath struct { PresetID string `path:"presetId" validate:"required"` } +type SyncMentionInboxData struct { + Synced int `json:"synced"` + Ready int `json:"ready"` + Total int64 `json:"total"` +} + +type SyncMentionInboxHandlerReq struct { + ThreadsAccountPath + SyncMentionInboxReq +} + +type SyncMentionInboxReq struct { + Limit int `json:"limit,optional"` + MaxPages int `json:"max_pages,optional"` +} + type ThreadsAPIPlaygroundData struct { Action string `json:"action"` Ok bool `json:"ok"` @@ -1731,6 +2101,11 @@ type ThreadsPostPerformanceItem struct { 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"` @@ -1792,6 +2167,7 @@ type UpdateBrandProductHandlerReq struct { type UpdateBrandProductReq struct { Label *string `json:"label,optional"` ProductContext *string `json:"product_context,optional"` + PlacementURL string `json:"placement_url,optional"` MatchTags []string `json:"match_tags,optional"` } @@ -1870,6 +2246,18 @@ type UpdateMemberPlacementSettingsReq struct { 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"` +} + +type UpdateOutreachDraftHandlerReq struct { + BrandPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq +} + +type UpdateOutreachDraftReq struct { + DraftIndex int `json:"draft_index"` + Text string `json:"text" validate:"required"` } type UpdatePersonaHandlerReq struct { @@ -1890,6 +2278,12 @@ type UpdatePlacementTopicHandlerReq struct { UpdatePlacementTopicReq } +type UpdatePlacementTopicOutreachDraftHandlerReq struct { + PlacementTopicPath + DraftID string `path:"draftId" validate:"required"` + UpdateOutreachDraftReq +} + type UpdatePlacementTopicReq struct { BrandID *string `json:"brand_id,optional"` TopicName *string `json:"topic_name,optional"` diff --git a/backend/internal/worker/job/expand_graph.go b/backend/internal/worker/job/expand_graph.go index 4dc6691..31b5138 100644 --- a/backend/internal/worker/job/expand_graph.go +++ b/backend/internal/worker/job/expand_graph.go @@ -175,6 +175,14 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) var existing *kgusecase.GraphSummary if supplemental { + if scope.TopicID != "" { + existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID) + } else { + existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) + } + } else if scope.TopicID != "" { + existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID) + } else { existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) } @@ -379,7 +387,11 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions) } patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + libkg.RescoreGraphIntentFit(&graph, patrolInput) libkg.DeriveSearchTagsFromGraph(&graph, patrolInput) + if existing != nil && len(existing.Nodes) > 0 { + libkg.PreserveNodeSelectionByLabel(graph.Nodes, existing.Nodes) + } updateProgress("整理海巡關鍵字…", 88) if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil { @@ -809,13 +821,18 @@ func ensureResearchMap( } } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) entityMap := brandentity.ResearchMap{ AudienceSummary: parsed.AudienceSummary, ContentGoal: parsed.ContentGoal, - Questions: parsed.Questions, - Pillars: parsed.Pillars, + Questions: libkg.RankStringsByIntent(parsed.Questions, patrolInput), + Pillars: libkg.RankStringsByIntent(parsed.Pillars, patrolInput), Exclusions: parsed.Exclusions, - PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), + PatrolKeywords: libkg.FilterPatrolKeywordsByIntent( + libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), + patrolInput, + 18, + ), } targetAudience := strings.TrimSpace(brand.TargetAudience) if targetAudience == "" { diff --git a/backend/internal/worker/job/generate_outreach_draft.go b/backend/internal/worker/job/generate_outreach_draft.go new file mode 100644 index 0000000..93db13a --- /dev/null +++ b/backend/internal/worker/job/generate_outreach_draft.go @@ -0,0 +1,97 @@ +package job + +import ( + "context" + "fmt" + + liboutreach "haixun-backend/internal/library/outreach" + aiusecase "haixun-backend/internal/model/ai/usecase" + branddomain "haixun-backend/internal/model/brand/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase" + outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateOutreachDraftDeps struct { + Jobs jobdom.UseCase + Brand branddomain.UseCase + Persona personadomain.UseCase + ScanPost scanpostdomain.UseCase + KnowledgeGraph kgdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + AI aiusecase.UseCase + OutreachDraft outreachdraftdomain.UseCase +} + +func RegisterGenerateOutreachDraftHandler(runner *Runner, deps GenerateOutreachDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("outreach_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateOutreachDraft(ctx, step, deps) + }) +} + +func runGenerateOutreachDraft(ctx context.Context, step StepContext, deps GenerateOutreachDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + brandID := stringField(payload, "brand_id") + topicID := stringField(payload, "topic_id") + scanPostID := stringField(payload, "scan_post_id") + voicePersonaID := stringField(payload, "voice_persona_id") + if tenantID == "" || ownerUID == "" || brandID == "" || scanPostID == "" || voicePersonaID == "" { + return fmt.Errorf("generate-outreach-draft payload missing tenant_id, owner_uid, brand_id, scan_post_id, or voice_persona_id") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "outreach_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + updateProgress("讀取貼文與產品脈絡…", 15) + updateProgress("呼叫 AI 生成獲客草稿…", 45) + + count := intField(payload, "count") + if count <= 0 { + count = 3 + } + + regenerate := false + if v, ok := payload["regenerate"].(bool); ok { + regenerate = v + } + + _, err := liboutreach.GenerateDraft(ctx, liboutreach.GenerateDraftDeps{ + Brand: deps.Brand, + Persona: deps.Persona, + ScanPost: deps.ScanPost, + KnowledgeGraph: deps.KnowledgeGraph, + ThreadsAccount: deps.ThreadsAccount, + AI: deps.AI, + OutreachDraft: deps.OutreachDraft, + }, liboutreach.GenerateDraftRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + TopicID: topicID, + ScanPostID: scanPostID, + Count: count, + VoicePersonaID: voicePersonaID, + ProductID: stringField(payload, "product_id"), + Regenerate: regenerate, + }) + if err != nil { + return err + } + updateProgress("草稿已寫入", 100) + return nil +} \ No newline at end of file diff --git a/backend/internal/worker/job/scan_placement.go b/backend/internal/worker/job/scan_placement.go index 12d9fab..0bd8235 100644 --- a/backend/internal/worker/job/scan_placement.go +++ b/backend/internal/worker/job/scan_placement.go @@ -50,6 +50,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } patrolMode := boolField(payload, "patrol_mode") + testPatrol := boolField(payload, "test_patrol") brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID) if brandErr != nil { @@ -105,14 +106,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD if graphSummary != nil { patrolNodes = graphSummary.Nodes } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + patrolInput = libkg.OverlayResearchMap( + patrolInput, + researchMap.AudienceSummary, + researchMap.Questions, + researchMap.Pillars, + researchMap.PatrolKeywords, + ) patrolKeywords = libkg.ResolveScanPatrolKeywords( stringSliceField(payload, "patrol_keywords"), researchMap.PatrolKeywords, - libkg.PatrolTagInputFromBrand(brand, productBrief), - patrolNodes, + patrolInput, + libkg.NodesForPatrolKeywordDerivation(patrolNodes), ) if len(patrolKeywords) == 0 { - return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") + return fmt.Errorf("無法從產品與研究地圖產出足夠相關的搜尋關鍵字,請補充研究地圖或勾選圖譜節點") } } @@ -142,21 +151,25 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD if err != nil { return err } - if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { - return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定") - } - if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" { - return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{ - WebSearchProvider: memberCtx.WebSearchProvider, - BraveAPIKey: memberCtx.BraveAPIKey, - ExaAPIKey: memberCtx.ExaAPIKey, - })) - } - if memberCtx.DevMode && !memberCtx.BrowserConnected { - return fmt.Errorf("開發模式需先同步 Chrome Session") - } - if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { - return fmt.Errorf("正式模式需先完成 Threads API 連線") + if testPatrol { + if !memberCtx.BrowserConnected { + return fmt.Errorf("測試海巡需先同步 Chrome Extension Session") + } + memberCtx = placement.MemberContextForCrawlerOnly(memberCtx) + } else { + if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { + return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定") + } + if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" { + return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{ + WebSearchProvider: memberCtx.WebSearchProvider, + BraveAPIKey: memberCtx.BraveAPIKey, + ExaAPIKey: memberCtx.ExaAPIKey, + })) + } + if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return fmt.Errorf("請先完成 Threads API 連線後再開始海巡") + } } updateProgress := func(summary string, percentage int) { @@ -171,17 +184,27 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } exclusions := append([]string{}, researchMap.Exclusions...) + patrolInput := libkg.PatrolTagInputFromBrand(brand, strings.TrimSpace(brand.ProductBrief)) + if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { + patrolInput.ProductFeatures = formatted + } + patrolInput = libkg.OverlayResearchMap( + patrolInput, + researchMap.AudienceSummary, + researchMap.Questions, + researchMap.Pillars, + researchMap.PatrolKeywords, + ) + scanContext := placement.PostScanContextFromPatrolInput(patrolInput, exclusions) - if len(patrolKeywords) > 0 { + if testPatrol { + updateProgress(fmt.Sprintf("測試海巡(爬蟲):依 %d 組關鍵字準備搜尋…", len(patrolKeywords)), 5) + } else if len(patrolKeywords) > 0 { updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5) } else { updateProgress("準備置入海巡…", 5) } - if err := deps.ScanPost.ClearPlacementScan(ctx, tenantID, ownerUID, brandID, topicID); err != nil { - return err - } - webClient := websearch.New(memberCtx.WebSearchConfig()) crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID)) graphNodes := []libkg.Node{} @@ -200,6 +223,7 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD Nodes: graphNodes, PatrolKeywords: patrolKeywords, Exclusions: exclusions, + PatrolContext: scanContext, Member: memberCtx, WebSearch: webClient, Crawler: crawlerFn, @@ -267,12 +291,22 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD } } + summaryLabel := "雙軌海巡" + discoverNote := fmt.Sprintf( + "Threads API + %s Search API 雙管道搜尋;候選 %d 篇已寫入", + memberCtx.WebSearchProviderLabel(), + count, + ) + if testPatrol { + summaryLabel = "測試海巡(爬蟲)" + discoverNote = fmt.Sprintf("Chrome 爬蟲搜尋;候選 %d 篇已寫入", count) + } handoff := map[string]any{ "flow": "placement", "brand_id": brandID, "summary": fmt.Sprintf( - "雙軌海巡完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)", - count, gold, recent, solved, + "%s完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)", + summaryLabel, count, gold, recent, solved, ), "pain_breakdown": map[string]any{ "posts": count, @@ -281,6 +315,8 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD "solved_by_prod": solved, "replies": replyCount, }, + "discover_note": discoverNote, + "test_patrol": testPatrol, "next_route": "/outreach?brand=" + brandID, "needs_supplemental_expand": false, "search_source_mode": string(memberCtx.SearchSourceMode), diff --git a/backend/web/package-lock.json b/backend/web/package-lock.json index 14065b0..1d5090e 100644 --- a/backend/web/package-lock.json +++ b/backend/web/package-lock.json @@ -835,9 +835,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -851,9 +848,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -867,9 +861,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -883,9 +874,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -899,9 +887,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -915,9 +900,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -931,9 +913,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -947,9 +926,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -963,9 +939,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -979,9 +952,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -995,9 +965,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1011,9 +978,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1027,9 +991,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/backend/web/public/downloads/haixun-threads-sync.zip b/backend/web/public/downloads/haixun-threads-sync.zip new file mode 100644 index 0000000..0afd5f1 Binary files /dev/null and b/backend/web/public/downloads/haixun-threads-sync.zip differ diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx index eaaa280..11855db 100644 --- a/backend/web/src/App.tsx +++ b/backend/web/src/App.tsx @@ -13,8 +13,9 @@ import { PatrolPage } from "./pages/PatrolPage"; import { PersonasPage } from "./pages/PersonasPage"; import { PublishPage } from "./pages/PublishPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { BrandsPage } from "./pages/BrandsPage"; -const validPages = new Set(["dashboard", "accounts", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); +const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol", "jobs"]); export function App() { @@ -96,6 +97,8 @@ function renderPage(page: string, navigate: (key: string) => void, accountCount: switch (page) { case "accounts": return ; + case "brands": + return ; case "publish": return ; case "personas": diff --git a/backend/web/src/api/client.ts b/backend/web/src/api/client.ts index ff721ac..dfe9241 100644 --- a/backend/web/src/api/client.ts +++ b/backend/web/src/api/client.ts @@ -61,8 +61,50 @@ type RequestOptions = { retryOnUnauthorized?: boolean; }; +function isTransientGatewayError(status: number) { + return status === 502 || status === 503 || status === 504; +} + +function gatewayRetryDelayMs(attempt: number) { + return 400 + attempt * 600; +} + +async function sleep(ms: number) { + await new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function readApiEnvelope(response: Response): Promise | null> { + return (await response.json().catch(() => null)) as ApiEnvelope | null; +} + +function isProxyUnavailable(response: Response, envelope: ApiEnvelope | null) { + if (!isTransientGatewayError(response.status)) { + return false; + } + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return true; + } + return envelope == null; +} + export async function apiRequest(path: string, options: RequestOptions = {}): Promise { - const response = await fetch(path, buildRequestInit(options)); + const maxAttempts = 4; + let response: Response | null = null; + let envelope: ApiEnvelope | null = null; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + response = await fetch(path, buildRequestInit(options)); + envelope = await readApiEnvelope(response); + if (!isProxyUnavailable(response, envelope) || attempt === maxAttempts - 1) { + break; + } + await sleep(gatewayRetryDelayMs(attempt)); + } + + if (!response) { + throw new ApiError("後端服務暫時無法連線,請稍後再試", 502); + } if (response.status === 401 && options.auth && options.retryOnUnauthorized !== false) { const refreshed = await refreshAccessToken(); @@ -72,14 +114,19 @@ export async function apiRequest(path: string, options: RequestOptions = {}): onUnauthorized?.(); } - const envelope = (await response.json().catch(() => null)) as ApiEnvelope | null; if (!response.ok || !envelope || envelope.code !== 102000) { - throw new ApiError( - envelope?.message || `API request failed (${response.status})`, - response.status, - envelope?.code, - envelope?.error?.biz_code - ); + const message = envelope?.message?.trim(); + const fallback = isProxyUnavailable(response, envelope) + ? "無法連線到 threads-tool.30cm.net/api(nginx 連不到本機 8890)。請在伺服器確認後端是否在跑:cd thread-master/backend && make run" + : `API request failed (${response.status})`; + const detail = + message || + (response.status === 502 + ? isProxyUnavailable(response, envelope) + ? "請求逾時或閘道無回應(HTTP 502)。回覆他人 @ 提及較慢,請稍後重試;若持續失敗請到帳號頁確認 threads_keyword_search 權限。" + : "Threads 或外部 API 回傳失敗(HTTP 502)。請到帳號頁確認 Threads API 連線與 threads_keyword_search 權限。" + : fallback); + throw new ApiError(detail, response.status, envelope?.code, envelope?.error?.biz_code); } return envelope.data as T; } diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts index 09ac545..8368447 100644 --- a/backend/web/src/api/haixun.ts +++ b/backend/web/src/api/haixun.ts @@ -22,6 +22,7 @@ export type ThreadsAccount = { display_name?: string; username?: string; threads_user_id?: string; + persona_id?: string; browser_connected: boolean; api_connected: boolean; api_token_expires_at?: number; @@ -117,19 +118,52 @@ export type PublishHealth = { }>; }; +export type OwnPostPerformanceItem = { + media_id: string; + text?: string; + permalink?: string; + timestamp?: string; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + topic_tag?: string; + shortcode?: string; + like_count: number; + reply_count: number; + repost_count?: number; + quote_count?: number; + views?: number; + shares?: number; + insights_status?: string; + insights_message?: string; +}; + export type PostPerformance = { account_id: string; username?: string; + threads_user_id?: string; fetched_at: number; - posts: Array<{ - media_id: string; - text?: string; - permalink?: string; - like_count: number; - reply_count: number; - views?: number; - insights_status?: string; - }>; + account_insights?: { + status?: string; + message?: string; + metrics?: Array<{ name: string; value: number }>; + }; + posts: OwnPostPerformanceItem[]; +}; + +export type OwnPostFormulaReview = { + media_id?: string; + reviewed_at?: number; + from_cache?: boolean; + summary: string; + wins?: string[]; + improvements?: string[]; + formula?: string; + post_template?: string; + hook_pattern?: string; + structure?: string; + replication_tips?: string[]; + avoid?: string[]; }; export type Persona = { @@ -148,6 +182,7 @@ export type CopyDraft = { persona_id: string; copy_mission_id?: string; scan_post_id?: string; + formula_id?: string; draft_type: string; text: string; angle?: string; @@ -155,7 +190,62 @@ export type CopyDraft = { rationale?: string; status?: string; publish_queue_id?: string; + published_at?: number; published_permalink?: string; + create_at?: number; +}; + +export type ContentInboxItem = CopyDraft & { + scheduled_at?: number; + lifecycle: "draft" | "scheduled" | "published" | string; + group_date: number; + formula_label?: string; +}; + +export type ContentFormula = { + id: string; + account_id: string; + label: string; + source_type: string; + source_ref?: string; + source_post_text?: string; + source_author?: string; + source_permalink?: string; + summary?: string; + wins?: string[]; + improvements?: string[]; + formula: string; + post_template?: string; + hook_pattern?: string; + structure?: string; + replication_tips?: string[]; + avoid?: string[]; + tags?: string[]; + create_at: number; + update_at: number; +}; + +export type ViralScanPost = { + id: string; + search_tag: string; + permalink: string; + author: string; + text: string; + like_count: number; + reply_count: number; + engagement_score: number; + source: string; + create_at: number; +}; + +export type ContentFormulaSearchPost = { + text: string; + author: string; + permalink?: string; + media_id?: string; + like_count: number; + reply_count: number; + engagement_score: number; }; export type PublishSlot = { @@ -265,6 +355,39 @@ export type ThreadsPlaygroundResult = { data: string; }; +export type MentionInboxItem = { + media_id: string; + author_username?: string; + text?: string; + permalink?: string; + shortcode?: string; + timestamp?: string; + media_type?: string; + is_reply: boolean; + is_quote_post: boolean; + has_replies: boolean; + root_post_id?: string; + parent_id?: string; + thread_post_text?: string; + parent_reply_text?: string; + reply_ready: boolean; + reply_ready_msg?: string; + synced_at: number; +}; + +export type SyncMentionInboxResult = { + synced: number; + ready: number; + total: number; +}; + +export type MentionInboxList = { + list: MentionInboxItem[]; + pagination: Pagination; + ready_total?: number; + newest_synced_at?: number; +}; + export type ThreadsReply = { id: string; text?: string; @@ -273,6 +396,21 @@ export type ThreadsReply = { timestamp?: string; like_count?: number; parent_id?: string; + replied_to_id?: string; + root_post_id?: string; + is_reply?: boolean; + is_reply_owned_by_me?: boolean; + has_replies?: boolean; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + gif_url?: string; + media_children?: Array<{ + id?: string; + media_type?: string; + media_url?: string; + thumbnail_url?: string; + }>; }; export type ThreadsInspirationPost = { @@ -308,6 +446,212 @@ export type CopyMission = { research_map?: Record; }; +// ==================== Brand (full contract per backend) ==================== +export type ResearchItemData = { + title?: string; + url?: string; + snippet?: string; + query?: string; +}; + +export type ResearchMapData = { + audience_summary?: string; + content_goal?: string; + questions?: string[]; + pillars?: string[]; + exclusions?: string[]; + research_items?: ResearchItemData[]; + expand_strategy?: string; + patrol_keywords?: string[]; +}; + +export type BrandProductData = { + id: string; + label: string; + product_context: string; + match_tags?: string[]; + placement_url?: string; // 置入網址 + create_at: number; + update_at: number; +}; + +export type BrandData = { + id: string; + display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_brief?: string; + product_context?: string; + product_id?: string; + products?: BrandProductData[]; + target_audience?: string; + goals?: string; + research_map?: ResearchMapData; + create_at: number; + update_at: number; +}; + +export type ListBrandsData = { list: BrandData[] }; + +export type PlacementTopicData = { + id: string; + brand_id: string; + brand_display_name?: string; + topic_name?: string; + seed_query?: string; + brief?: string; + product_id?: string; + research_map?: ResearchMapData; + create_at: number; + update_at: number; +}; + +export type ListPlacementTopicsData = { list: PlacementTopicData[] }; + +export type KnowledgeGraphEvidenceData = { + url?: string; + snippet?: string; + query?: string; +}; + +export type BraveSourceData = { + query?: string; + title?: string; + url?: string; + snippet?: string; +}; + +export type KnowledgeGraphNodeData = { + id: string; + label: string; + node_kind: string; + type: string; + layer: number; + relation?: string; + placement_value?: string; + product_fit_score: number; + selected_for_scan: boolean; + relevance_tags: string[]; + recency_tags: string[]; + evidence?: KnowledgeGraphEvidenceData[]; +}; + +export type KnowledgeGraphEdgeData = { + from: string; + to: string; + relation: string; +}; + +export type KnowledgeGraphData = { + id: string; + brand_id: string; + seed: string; + nodes: KnowledgeGraphNodeData[]; + edges: KnowledgeGraphEdgeData[]; + brave_sources?: BraveSourceData[]; + expand_strategy?: string; + pain_tag_count: number; + generated_at: number; + create_at: number; + update_at: number; +}; + +export type ExpandKnowledgeGraphData = { + job_id: string; + status: string; + message: string; +}; + +export type ScanReplyData = { + external_id?: string; + author?: string; + text: string; + permalink?: string; + like_count?: number; + posted_at?: string; +}; + +export type GenerateOutreachDraftsData = { + id: string; + scan_post_id: string; + relevance: number; + reason: string; + drafts: Array<{ text: string; angle: string; rationale: string }>; + create_at: number; +}; + +export type PublishOutreachDraftData = { + scan_post_id: string; + reply_id?: string; + permalink?: string; + outreach_status: string; + published_permalink?: string; + message: string; +}; + +export type ScanPostData = { + id: string; + graph_node_id: string; + search_tag: string; + query_dimension: string; + recency_days?: number; + external_id: string; + permalink?: string; + author: string; + text: string; + priority: string; + placement_score: number; + product_fit_score: number; + solved_by_product: boolean; + source: string; + scan_job_id: string; + outreach_status?: string; + published_reply_id?: string; + published_permalink?: string; + outreach_update_at?: number; + posted_at?: string; + replies?: ScanReplyData[]; + latest_draft?: GenerateOutreachDraftsData; + create_at: number; +}; + +export type ListBrandScanPostsData = { + list: ScanPostData[]; + total: number; +}; + +export type ContentMatrixRowData = { + sort_order: number; + search_tag: string; + angle: string; + hook: string; + text: string; + reference_notes: string; + source_permalinks: string[]; + rationale: string; +}; + +export type ContentMatrixData = { + id?: string; + brand_id: string; + rows: ContentMatrixRowData[]; + generated_at: number; + create_at?: number; + update_at?: number; +}; + +export type BrandScanScheduleData = { + id?: string; + brand_id: string; + cron: string; + timezone: string; + enabled: boolean; + next_run_at?: number; + last_run_at?: number; +}; + +// Legacy aliases for existing PatrolPage usage (keep minimal surface) export type Brand = { id: string; display_name?: string; @@ -336,6 +680,7 @@ export type JobRun = { status: string; phase?: string; progress?: { percentage?: number; summary?: string }; + error?: string; create_at: number; update_at: number; }; @@ -378,6 +723,11 @@ export const api = { getThreadsConnection: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { auth: true }), updateThreadsConnection: (id: string, body: Record) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { method: "PATCH", body, auth: true }), + importThreadsAccountSession: (id: string, storageState: string) => + apiRequest<{ success: boolean; valid: boolean; synced: boolean; message: string; account_id: string; username?: string }>( + `/api/v1/threads-accounts/${id}/session/import`, + { method: "POST", body: { storageState }, auth: true } + ), threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), updateThreadsAiSettings: (id: string, body: Record) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), @@ -396,6 +746,22 @@ export const api = { publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }), threadsPlayground: (id: string, body: Record) => apiRequest(`/api/v1/threads-accounts/${id}/api/playground`, { method: "POST", body, auth: true }), + syncMentionInbox: (accountId: string, limit = 25, maxPages = 4) => + apiRequest(`/api/v1/threads-accounts/${accountId}/mention-inbox/sync`, { + method: "POST", + body: { limit, max_pages: maxPages }, + auth: true + }), + listMentionInbox: (accountId: string, page = 1, pageSize = 8) => + apiRequest( + `/api/v1/threads-accounts/${accountId}/mention-inbox?page=${page}&pageSize=${pageSize}`, + { auth: true } + ), + publishMentionReply: (accountId: string, mediaId: string, text: string) => + apiRequest<{ media_id?: string; permalink?: string }>( + `/api/v1/threads-accounts/${accountId}/mention-inbox/${encodeURIComponent(mediaId)}/reply`, + { method: "POST", body: { text }, auth: true } + ), publishQueue: (accountId: string, status = "", page = 1) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }), @@ -429,7 +795,50 @@ export const api = { resumePublishGuard: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard/resume`, { method: "POST", auth: true }), publishHealth: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-health?page=1&pageSize=10`, { auth: true }), - postPerformance: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=15`, { auth: true }), + postPerformance: (accountId: string, limit = 30) => + apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=${limit}`, { auth: true }), + generateOwnPostReplyDraft: ( + accountId: string, + body: { + media_id: string; + persona_id?: string; + reply_to_id?: string; + post_text?: string; + reply_text?: string; + thread_post_text?: string; + parent_reply_text?: string; + } + ) => + apiRequest<{ text: string }>(`/api/v1/threads-accounts/${accountId}/own-post-reply-draft`, { + method: "POST", + body, + auth: true + }), + generateOwnPostFormula: ( + accountId: string, + body: { + media_id: string; + persona_id?: string; + post_text?: string; + topic_tag?: string; + media_type?: string; + timestamp?: string; + like_count?: number; + reply_count?: number; + views?: number; + repost_count?: number; + quote_count?: number; + shares?: number; + force?: boolean; + } + ) => + apiRequest(`/api/v1/threads-accounts/${accountId}/own-post-formula`, { + method: "POST", + body, + auth: true + }), + listOwnPostFormulas: (accountId: string) => + apiRequest<{ list: OwnPostFormulaReview[] }>(`/api/v1/threads-accounts/${accountId}/own-post-formulas`, { auth: true }), personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }), createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }), @@ -437,11 +846,44 @@ export const api = { deletePersona: (id: string) => apiRequest(`/api/v1/personas/${id}`, { method: "DELETE", auth: true }), startStyleAnalysis: (id: string, benchmark_username: string) => apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }), + viralScanPosts: (personaId: string, limit = 30) => + apiRequest<{ list: ViralScanPost[]; total: number }>(`/api/v1/personas/${personaId}/viral-scan-posts?limit=${limit}`, { auth: true }), personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }), updateDraft: (personaId: string, draftId: string, body: Record) => apiRequest(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "PATCH", body, auth: true }), schedulePersonaDrafts: (personaId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) => apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/schedule`, { method: "POST", body, auth: true }), + contentInbox: (personaId: string, params?: { page?: number; pageSize?: number; status?: string; rangeStart?: number; rangeEnd?: number }) => { + const q = new URLSearchParams(); + if (params?.page) q.set("page", String(params.page)); + if (params?.pageSize) q.set("pageSize", String(params.pageSize)); + if (params?.status) q.set("status", params.status); + if (params?.rangeStart) q.set("rangeStart", String(params.rangeStart)); + if (params?.rangeEnd) q.set("rangeEnd", String(params.rangeEnd)); + const suffix = q.toString() ? `?${q.toString()}` : ""; + return apiRequest<{ pagination: Pagination; list: ContentInboxItem[] }>(`/api/v1/personas/${personaId}/content-inbox${suffix}`, { auth: true }); + }, + scheduleCopyDraft: (personaId: string, draftId: string, body: { account_id: string; scheduled_at?: number }) => + apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}/schedule`, { method: "POST", body, auth: true }), + generateFromFormula: (personaId: string, formulaId: string, body: { account_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/content-formulas/${formulaId}/generate`, { method: "POST", body, auth: true }), + contentFormulas: (accountId: string, params?: { page?: number; pageSize?: number; source_type?: string; tag?: string }) => { + const q = new URLSearchParams(); + if (params?.page) q.set("page", String(params.page)); + if (params?.pageSize) q.set("pageSize", String(params.pageSize)); + if (params?.source_type) q.set("source_type", params.source_type); + if (params?.tag) q.set("tag", params.tag); + const suffix = q.toString() ? `?${q.toString()}` : ""; + return apiRequest<{ pagination: Pagination; list: ContentFormula[] }>(`/api/v1/threads-accounts/${accountId}/content-formulas${suffix}`, { auth: true }); + }, + analyzeContentFormula: (accountId: string, body: Record) => + apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/analyze`, { method: "POST", body, auth: true }), + searchContentFormulaPosts: (accountId: string, body: { keyword: string; search_type?: string; limit?: number }) => + apiRequest<{ list: ContentFormulaSearchPost[]; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/search-posts`, { method: "POST", body, auth: true }), + importOwnPostToFormula: (accountId: string, body: { media_id: string; label?: string }) => + apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/import-own-post`, { method: "POST", body, auth: true }), + deleteContentFormula: (accountId: string, formulaId: string) => + apiRequest<{ formula_id: string; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/${formulaId}`, { method: "DELETE", auth: true }), stylePresets: (personaId: string) => apiRequest<{ list: StylePreset[] }>(`/api/v1/personas/${personaId}/style-presets`, { auth: true }), upsertStylePreset: (personaId: string, presetId: string, body: { name: string; tone?: string; cta?: string[]; banned_words?: string[]; notes?: string; apply?: boolean }) => apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "PUT", body, auth: true }), @@ -462,16 +904,132 @@ export const api = { scheduleMissionDrafts: (personaId: string, missionId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) => apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/copy-drafts/schedule`, { method: "POST", body, auth: true }), - brands: () => apiRequest<{ list: Brand[] }>("/api/v1/brands/", { auth: true }), - createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }), - updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }), + // Brand core + brands: () => apiRequest("/api/v1/brands/", { auth: true }), + createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }), + getBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { auth: true }), + updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }), + deleteBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { method: "DELETE", auth: true }), + + // Brand Products + listBrandProducts: (id: string) => apiRequest<{ list: BrandProductData[] }>(`/api/v1/brands/${id}/products`, { auth: true }), + createBrandProduct: (id: string, body: { label: string; product_context: string; match_tags?: string[]; placement_url?: string }) => + apiRequest(`/api/v1/brands/${id}/products`, { method: "POST", body, auth: true }), + updateBrandProduct: (id: string, productId: string, body: Record) => + apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "PATCH", body, auth: true }), + deleteBrandProduct: (id: string, productId: string) => + apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "DELETE", auth: true }), + + // Knowledge Graph + expandKnowledgeGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) => + apiRequest(`/api/v1/brands/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }), + getKnowledgeGraph: (id: string) => apiRequest(`/api/v1/brands/${id}/knowledge-graph`, { auth: true }), + patchKnowledgeGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) => + apiRequest(`/api/v1/brands/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }), + + // Scan jobs & posts startBrandScan: (id: string, body: Record) => - apiRequest<{ job_id: string; status: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }), - brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }), - generateOutreachDrafts: (id: string, body: Record) => + apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }), + listBrandScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.priority) qs.set("priority", params.priority); + if (params?.recent_7d) qs.set("recent_7d", "true"); + if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min)); + if (params?.limit) qs.set("limit", String(params.limit)); + const q = qs.toString() ? `?${qs.toString()}` : ""; + return apiRequest(`/api/v1/brands/${id}/scan-posts${q}`, { auth: true }); + }, + + // Outreach + generateOutreachDrafts: (id: string, body: { scan_post_id: string; topic_id?: string; count?: number; voice_persona_id?: string; product_id?: string }) => + apiRequest(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + // keep old simple name for PatrolPage (returns any for now) + generateOutreachDraftsLegacy: (id: string, body: Record) => apiRequest>(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + publishOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) => + apiRequest(`/api/v1/brands/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }), + patchScanPostOutreach: (id: string, postId: string, body: { outreach_status?: string }) => + apiRequest(`/api/v1/brands/${id}/scan-posts/${postId}`, { method: "PATCH", body, auth: true }), + + // Content Matrix + getBrandContentMatrix: (id: string) => apiRequest(`/api/v1/brands/${id}/content-matrix`, { auth: true }), + generateBrandContentMatrix: (id: string, body?: { count?: number }) => + apiRequest(`/api/v1/brands/${id}/content-matrix/generate`, { method: "POST", body: body || {}, auth: true }), + + // Scan Schedule + getBrandScanSchedule: (id: string) => apiRequest(`/api/v1/brands/${id}/scan-schedule`, { auth: true }), + upsertBrandScanSchedule: (id: string, body: { cron?: string; timezone?: string; enabled: boolean }) => + apiRequest(`/api/v1/brands/${id}/scan-schedule`, { method: "PUT", body, auth: true }), + + // Legacy simple (PatrolPage still uses these names) + brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }), + + // Placement Topics (海巡主題) - 主要用於海巡頁:新增主題、爬文、知識圖譜 + placementTopics: () => apiRequest("/api/v1/placement/topics", { auth: true }), + createPlacementTopic: (body: { brand_id: string; topic_name: string; seed_query: string; brief: string; product_id?: string }) => + apiRequest("/api/v1/placement/topics", { method: "POST", body, auth: true }), + getPlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { auth: true }), + updatePlacementTopic: (id: string, body: Record) => + apiRequest(`/api/v1/placement/topics/${id}`, { method: "PATCH", body, auth: true }), + deletePlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { method: "DELETE", auth: true }), + + // Placement Topic Knowledge Graph & Scan & Outreach + expandPlacementTopicGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) => + apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }), + getPlacementTopicGraph: (id: string) => apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph`, { auth: true }), + patchPlacementTopicGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) => + apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }), + + startPlacementTopicScan: ( + id: string, + body: { + graph_id?: string; + node_ids?: string[]; + dual_track?: boolean; + patrol_mode?: boolean; + test_patrol?: boolean; + patrol_keywords?: string[]; + } + ) => + apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/placement/topics/${id}/scan-jobs`, { method: "POST", body, auth: true }), + listPlacementTopicScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.priority) qs.set("priority", params.priority); + if (params?.recent_7d) qs.set("recent_7d", "true"); + if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min)); + if (params?.limit) qs.set("limit", String(params.limit)); + const q = qs.toString() ? `?${qs.toString()}` : ""; + return apiRequest(`/api/v1/placement/topics/${id}/scan-posts${q}`, { auth: true }); + }, + + generatePlacementTopicOutreachDrafts: (id: string, body: { scan_post_id: string; count?: number; voice_persona_id?: string; product_id?: string }) => + apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }), + startPlacementTopicOutreachDraftJobs: ( + id: string, + body: { + scan_post_id?: string; + scan_post_ids?: string[]; + count?: number; + voice_persona_id: string; + product_id?: string; + regenerate?: boolean; + }, + ) => + apiRequest<{ jobs: Array<{ job_id: string; status: string; message: string }>; message: string }>( + `/api/v1/placement/topics/${id}/outreach-draft-jobs`, + { method: "POST", body, auth: true }, + ), + publishPlacementTopicOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) => + apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }), + updatePlacementTopicOutreachDraft: (topicId: string, draftId: string, body: { draft_index: number; text: string }) => + apiRequest(`/api/v1/placement/topics/${topicId}/outreach-drafts/${draftId}`, { method: "PATCH", body, auth: true }), + deletePlacementTopicScanPost: (topicId: string, postId: string) => + apiRequest(`/api/v1/placement/topics/${topicId}/scan-posts/${postId}`, { method: "DELETE", auth: true }), + batchDeletePlacementTopicScanPosts: (topicId: string, postIds: string[]) => + apiRequest<{ deleted_count: number }>(`/api/v1/placement/topics/${topicId}/scan-posts/batch-delete`, { method: "POST", body: { post_ids: postIds }, auth: true }), jobs: (page = 1) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=20`, { auth: true }), + getJob: (id: string) => apiRequest(`/api/v1/jobs/${id}`, { auth: true }), cancelJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/cancel`, { method: "POST", auth: true }), retryJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/retry`, { method: "POST", auth: true }), jobSchedules: () => apiRequest<{ list: JobSchedule[]; pagination: Pagination }>("/api/v1/job/schedules?page=1&pageSize=50", { auth: true }), diff --git a/backend/web/src/components/AcIcon.tsx b/backend/web/src/components/AcIcon.tsx index c35708a..f7bb8aa 100644 --- a/backend/web/src/components/AcIcon.tsx +++ b/backend/web/src/components/AcIcon.tsx @@ -8,7 +8,8 @@ type IconName = | "job" | "settings" | "spark" - | "more"; + | "more" + | "brand"; const paths: Record = { home: "M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z", @@ -20,7 +21,8 @@ const paths: Record = { job: "M6 4h12v4H6V4Zm0 6h12v4H6v-4Zm0 6h12v4H6v-4Z", settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.5 5.5v-3l-2.2-.4a7.7 7.7 0 0 0-.7-1.6l1.3-1.9-2.1-2.1-1.9 1.3c-.5-.3-1.1-.5-1.7-.7L12.8 3h-3l-.4 2.1c-.6.2-1.1.4-1.7.7L5.9 4.5 3.8 6.6l1.3 1.9c-.3.5-.5 1.1-.7 1.6l-2.2.4v3l2.2.4c.2.6.4 1.1.7 1.6l-1.3 1.9 2.1 2.1 1.9-1.3c.5.3 1.1.5 1.7.7l.4 2.1h3l.4-2.1c.6-.2 1.1-.4 1.7-.7l1.9 1.3 2.1-2.1-1.3-1.9c.3-.5.5-1.1.7-1.6l2.1-.4Z", spark: "M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8L12 2Zm6 13 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z", - more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z" + more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z", + brand: "M4 7h16v2l-2 2v7a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-7L4 9V7Zm2 4 1 1v6h2v-4h6v4h2v-6l1-1V9H6v2Z" }; export function AcIcon({ name, size = 22 }: { name: IconName; size?: number }) { diff --git a/backend/web/src/components/DevToolsPanel.tsx b/backend/web/src/components/DevToolsPanel.tsx new file mode 100644 index 0000000..63ad6a7 --- /dev/null +++ b/backend/web/src/components/DevToolsPanel.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react"; +import { api, ThreadsConnection } from "../api/haixun"; +import { getAccessToken } from "../api/client"; +import { readActiveThreadsAccountId } from "../lib/activeAccount"; +import { + isExtensionBridgePresent, + pingExtensionBridge, + requestExtensionSync, + waitForExtensionBridge, +} from "../lib/extensionSync"; +import { ExtensionInstallCard } from "./ExtensionInstallCard"; +import { Badge, Button, Card, Field, Textarea } from "./ui"; + +type DevToolsPanelProps = { + accountId: string; + connection: ThreadsConnection; + onConnectionChange: (data: ThreadsConnection) => void; + onMessage: (message: string) => void; + onError: (message: string) => void; +}; + +export function DevToolsPanel({ + accountId, + connection, + onConnectionChange, + onMessage, + onError, +}: DevToolsPanelProps) { + const [open, setOpen] = useState(true); + const [extensionReady, setExtensionReady] = useState(false); + const [syncBusy, setSyncBusy] = useState(false); + const [manualState, setManualState] = useState(""); + const [importBusy, setImportBusy] = useState(false); + const [showManualImport, setShowManualImport] = useState(false); + + useEffect(() => { + let cancelled = false; + const onBridgeMessage = (event: MessageEvent) => { + if (event.source !== window) return; + if (event.data?.type === "HAIXUN_EXTENSION_READY") setExtensionReady(true); + }; + window.addEventListener("message", onBridgeMessage); + const timer = window.setInterval(() => { + if (cancelled) return; + if (isExtensionBridgePresent()) { + setExtensionReady(true); + return; + } + pingExtensionBridge(); + }, 1500); + pingExtensionBridge(); + void waitForExtensionBridge(6000).then((ready) => { + if (!cancelled && ready) setExtensionReady(true); + }); + return () => { + cancelled = true; + window.removeEventListener("message", onBridgeMessage); + window.clearInterval(timer); + }; + }, []); + + const reloadConnection = async () => { + const data = await api.getThreadsConnection(accountId); + onConnectionChange(data); + return data; + }; + + const syncChromeSession = async () => { + setSyncBusy(true); + onError(""); + onMessage(""); + try { + const ready = extensionReady || isExtensionBridgePresent() || (await waitForExtensionBridge(4000)); + if (!ready) { + throw new Error( + "找不到巡樓 Chrome 擴充。請到 chrome://extensions 載入 extension/haixun-threads-sync,按「重新載入」後刷新此頁(F5)", + ); + } + setExtensionReady(true); + + await api.memberMe(); + const accessToken = getAccessToken(); + if (!accessToken) throw new Error("登入狀態已失效,請重新登入"); + + const resolvedAccountId = accountId || readActiveThreadsAccountId(); + if (!resolvedAccountId) { + throw new Error("請先在頂部選擇經營帳號,或到 Threads 帳號頁設為使用中後再同步"); + } + + const result = await requestExtensionSync({ + accountId: resolvedAccountId, + accessToken, + serverUrl: window.location.origin, + }); + if (result.success === false || result.valid === false) { + throw new Error(result.message || "Chrome session 同步失敗"); + } + onMessage(result.message || "Chrome session 已同步"); + await reloadConnection(); + } catch (err) { + onError(err instanceof Error ? err.message : "Chrome session 同步失敗"); + } finally { + setSyncBusy(false); + } + }; + + const importManualSession = async () => { + const trimmed = manualState.trim(); + if (!trimmed) { + onError("請貼上 Playwright storage state JSON"); + return; + } + setImportBusy(true); + onError(""); + onMessage(""); + try { + JSON.parse(trimmed); + const data = await api.importThreadsAccountSession(accountId, trimmed); + if (!data.valid) throw new Error(data.message || "Session 驗證失敗"); + setManualState(""); + onMessage(data.message || "Session 已匯入"); + await reloadConnection(); + } catch (err) { + if (err instanceof SyntaxError) { + onError("JSON 格式不正確,請貼上完整的 Playwright storage state"); + } else { + onError(err instanceof Error ? err.message : "匯入失敗"); + } + } finally { + setImportBusy(false); + } + }; + + return ( + +
+ + + {open ? ( +
+
+ + {connection.browser_connected ? "Chrome Session 已同步" : "Chrome Session 未同步"} + + + {extensionReady ? "擴充已偵測" : "尚未偵測擴充"} + +
+ +
+ + +
+ + {showManualImport ? ( +
+ +